;
readonly isEditing: boolean;
readonly timestampFormat: TimestampFormat;
- readonly onOpenThread: (threadId: ThreadId) => void;
+ readonly onOpenThread: (threadId: ThreadId, environmentId?: EnvironmentId) => void;
readonly children: ReactNode;
}) {
const presentation = props.message ? resolveUserMessagePresentation(props.message) : null;
const attribution = presentation?.attribution ?? null;
const scheduledTaskId = presentation?.scheduledTaskId;
const senderThreadId = props.message?.senderThreadId;
+ // A linked environment's agent sent it from a thread there.
+ const senderRef =
+ props.message?.linkedSender ??
+ (senderThreadId === undefined ? null : scopeThreadRef(props.environmentId, senderThreadId));
const [expanded, setExpanded] = useState(false);
const taskTitle = useScheduledTaskTitle(props.environmentId, scheduledTaskId);
- const senderShell = useQuietThreadShell(
- attribution === "agent" && senderThreadId
- ? { environmentId: props.environmentId, threadId: senderThreadId }
- : null,
- );
+ const senderShell = useQuietThreadShell(attribution === "agent" ? senderRef : null);
const fold =
attribution === "automation"
? {
@@ -435,7 +435,8 @@ function QueuedMessage(props: {
: attribution === "agent"
? {
icon: MessagesSquareIcon,
- label: senderShell?.title.trim() || "Agent",
+ label:
+ senderShell?.title.trim() || props.message?.linkedSender?.title.trim() || "Agent",
summary: props.previewText.split(/\r?\n/).find((line) => line.trim()) ?? "",
}
: null;
@@ -508,8 +509,14 @@ function QueuedMessage(props: {
{props.message ? (
- {attribution === "agent" && senderThreadId ? (
- props.onOpenThread(senderThreadId)}>
+ {attribution === "agent" &&
+ senderRef &&
+ // Only a linked environment this client reaches can open the sender's thread.
+ (props.message?.linkedSender === undefined || senderShell !== null) ? (
+ props.onOpenThread(senderRef.threadId, senderRef.environmentId)}
+ >
Open thread
) : null}
diff --git a/apps/web/src/components/chat/TimelineUserMessageRow.tsx b/apps/web/src/components/chat/TimelineUserMessageRow.tsx
index 21b27f7c64724..22755557e51b8 100644
--- a/apps/web/src/components/chat/TimelineUserMessageRow.tsx
+++ b/apps/web/src/components/chat/TimelineUserMessageRow.tsx
@@ -9,6 +9,7 @@ import {
type ServerProviderSkill,
ThreadId,
} from "@t3tools/contracts";
+import { scopeThreadRef } from "@t3tools/client-runtime/environment";
import { resolveUserMessagePresentation } from "@t3tools/client-runtime/user-message";
import { Link } from "@tanstack/react-router";
import { useQuietThreadShell } from "../../state/entities";
@@ -429,7 +430,12 @@ function UserTimelineRow({ row }: { row: Extract setExpanded(true));
- const senderShell = useQuietThreadShell(
- folded && senderThreadId
- ? { environmentId: ctx.activeThreadEnvironmentId, threadId: senderThreadId }
- : null,
- );
- const senderLabel = senderShell?.title.trim() || "Agent";
+ const senderShell = useQuietThreadShell(folded ? senderRef : null);
+ const senderLabel =
+ senderShell?.title.trim() || row.message.linkedSender?.title.trim() || "Agent";
+ // Only a linked environment this client reaches can open the sender's thread.
+ const canOpenSender =
+ senderRef !== null && (row.message.linkedSender === undefined || senderShell !== null);
const resolvedContext = resolveUserMessageContext({ ...row.message, text: userMessage.text });
const previewImages = userImages.filter((image) => image.name.startsWith("preview-annotation-"));
const revertTurnCount = row.revertTurnCount;
@@ -786,8 +792,11 @@ function UserTimelineRow({ row }: { row: Extract
- {folded && senderThreadId ? (
- ctx.onOpenThread(senderThreadId)} tone="muted">
+ {folded && senderRef && canOpenSender ? (
+ ctx.onOpenThread(senderRef.threadId, senderRef.environmentId)}
+ tone="muted"
+ >
Open thread
) : null}
diff --git a/apps/web/src/components/chat/timelineRowContext.ts b/apps/web/src/components/chat/timelineRowContext.ts
index 45224ea70f62c..4c9b94f8893ec 100644
--- a/apps/web/src/components/chat/timelineRowContext.ts
+++ b/apps/web/src/components/chat/timelineRowContext.ts
@@ -78,7 +78,10 @@ export interface TimelineRowSharedState {
renderHtmlRender: ((row: Extract) => ReactNode) | undefined;
/** Unset, expanded rows read detail and command output from the environment. */
turnItemSource: TurnItemSource | undefined;
- onOpenThread: (threadId: OrchestrationV2TurnItem["threadId"]) => void;
+ onOpenThread: (
+ threadId: OrchestrationV2TurnItem["threadId"],
+ environmentId?: EnvironmentId,
+ ) => void;
workflowOpening: WorkflowOpening | null;
/** Absent hides "Fork from this response", as in the guest view. */
onForkFromRun:
diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts
index 67d88025c0e35..e77c8354caf45 100644
--- a/apps/web/src/session-logic.ts
+++ b/apps/web/src/session-logic.ts
@@ -593,6 +593,7 @@ export function deriveTimelineEntriesFromVisibleTurnItems(
createdBy: item.createdBy,
creationSource: item.creationSource,
...(item.senderThreadId !== undefined ? { senderThreadId: item.senderThreadId } : {}),
+ ...(item.linkedSender !== undefined ? { linkedSender: item.linkedSender } : {}),
...(item.idleCompaction !== undefined ? { idleCompaction: item.idleCompaction } : {}),
...(item.scheduledTaskId !== undefined
? { scheduledTaskId: item.scheduledTaskId }
diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts
index cbfdf2a6adf44..2f61b38b9b182 100644
--- a/apps/web/src/types.ts
+++ b/apps/web/src/types.ts
@@ -13,6 +13,7 @@ import type {
ProviderInteractionMode,
RunId,
RuntimeMode,
+ OrchestrationV2LinkedSender,
ScheduledTaskId,
ThreadId,
} from "@t3tools/contracts";
@@ -83,6 +84,7 @@ export interface ChatMessage {
readonly creationSource?: OrchestrationV2CreationSource;
readonly scheduledTaskId?: ScheduledTaskId;
readonly senderThreadId?: ThreadId;
+ readonly linkedSender?: OrchestrationV2LinkedSender;
readonly idleCompaction?: import("@t3tools/contracts").OrchestrationV2IdleCompaction;
readonly createdAt: string;
readonly updatedAt: string;
diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts
index afe8802139a52..ae4a5697fc803 100644
--- a/packages/contracts/src/orchestrationV2.ts
+++ b/packages/contracts/src/orchestrationV2.ts
@@ -139,6 +139,18 @@ export const OrchestrationV2DelegatedFrom = Schema.Struct({
});
export type OrchestrationV2DelegatedFrom = typeof OrchestrationV2DelegatedFrom.Type;
+/**
+ * The thread in a linked environment whose agent sent this message here, and its title
+ * then, for a client that cannot reach that environment. `senderThreadId` names only a
+ * thread in this environment.
+ */
+export const OrchestrationV2LinkedSender = Schema.Struct({
+ environmentId: EnvironmentId,
+ threadId: ThreadId,
+ title: Schema.String,
+});
+export type OrchestrationV2LinkedSender = typeof OrchestrationV2LinkedSender.Type;
+
export const OrchestrationV2NativeRefStrength = Schema.Literals(["strong", "weak", "none"]);
export type OrchestrationV2NativeRefStrength = typeof OrchestrationV2NativeRefStrength.Type;
@@ -1314,6 +1326,7 @@ export const OrchestrationV2ConversationMessage = Schema.Struct({
...OrchestrationV2CreationFields,
scheduledTaskId: Schema.optional(ScheduledTaskId),
senderThreadId: Schema.optional(ThreadId),
+ linkedSender: Schema.optional(OrchestrationV2LinkedSender),
idleCompaction: Schema.optional(OrchestrationV2IdleCompaction),
/**
* The message is the script a provider-native workflow run executes, not prose. Clients show
@@ -1633,6 +1646,7 @@ export const OrchestrationV2TurnItem = Schema.Union([
messageId: MessageId,
scheduledTaskId: Schema.optional(ScheduledTaskId),
senderThreadId: Schema.optional(ThreadId),
+ linkedSender: Schema.optional(OrchestrationV2LinkedSender),
idleCompaction: Schema.optional(OrchestrationV2IdleCompaction),
workflowScript: OrchestrationV2ConversationMessage.fields.workflowScript,
freshContext: OrchestrationV2Run.fields.freshContext,
@@ -2482,6 +2496,7 @@ export const OrchestrationV2TurnItemJson = Schema.Union([
messageId: MessageId,
scheduledTaskId: Schema.optional(ScheduledTaskId),
senderThreadId: Schema.optional(ThreadId),
+ linkedSender: Schema.optional(OrchestrationV2LinkedSender),
idleCompaction: Schema.optional(OrchestrationV2IdleCompaction),
workflowScript: OrchestrationV2ConversationMessage.fields.workflowScript,
freshContext: OrchestrationV2Run.fields.freshContext,
@@ -3197,6 +3212,7 @@ export const OrchestrationV2Command = Schema.Union([
...OrchestrationV2CreationFields,
scheduledTaskId: Schema.optional(ScheduledTaskId),
senderThreadId: Schema.optional(ThreadId),
+ linkedSender: Schema.optional(OrchestrationV2LinkedSender),
idleCompaction: Schema.optional(OrchestrationV2IdleCompaction),
/** Start a new provider session without the thread's earlier context. Never steers. */
freshContext: Schema.optional(Schema.Boolean),