From 15fa5e7fb9a53b7d471e66124417da3e7c10c3ad Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Mon, 2 Nov 2026 04:22:44 -0800 Subject: [PATCH] fix(web): remote subagents no longer split from the lineage list Subagents running on a linked environment rendered in their own list after "Previous agents", whatever their status. Group them with local subagents by status instead. Co-Authored-By: Claude Opus 5.5 --- ...hreadRelationshipsControl.browser.test.tsx | 5 + .../chat/ThreadRelationshipsControl.tsx | 98 ++++++++++++------- 2 files changed, 65 insertions(+), 38 deletions(-) diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.browser.test.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.browser.test.tsx index 59f9fb69adc1d..6a677fc02f665 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.browser.test.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.browser.test.tsx @@ -310,6 +310,11 @@ it("shows a remote subagent's full title in its tooltip even when its environmen { router: "/environment/fork" }, ); + // A finished remote task sits with the other finished agents, not in its own list. + await expect + .element(screen.getByRole("button", { name: new RegExp(longTitle) })) + .not.toBeInTheDocument(); + await screen.getByRole("button", { name: /Previous agents/ }).click(); const row = screen.getByRole("button", { name: new RegExp(longTitle) }); await userEvent.hover(row); await vi.waitFor(() => diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx index 55abc90a0fa4f..f392c652e05de 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx @@ -136,18 +136,32 @@ function ThreadLineageRowList(props: { ); } -function ThreadLineageGroup(props: { +type RemoteSubagent = OrchestrationV2Subagent & { + readonly remoteChild: OrchestrationV2RemoteTaskChild; +}; + +/** A task delegated to a linked environment has no thread here, so it gets its own row kind. */ +type LineageRow = ThreadRelationshipWalkRow | { readonly remote: RemoteSubagent }; + +const lineageRowStatus = (row: LineageRow) => + "remote" in row ? row.remote.status : row.edge.status; + +const isFinishedAgentStatus = (status: string | null | undefined) => + ["completed", "failed", "error", "cancelled", "interrupted", "idle"].includes(status ?? ""); + +function ThreadLineageGroup(props: { readonly label: string | null; - readonly rows: ReadonlyArray; + readonly rows: ReadonlyArray; readonly expanded: boolean; - readonly children: (rows: ReadonlyArray) => ReactNode; + readonly children: (rows: ReadonlyArray) => ReactNode; }) { const [expanded, setExpanded] = useState(props.expanded); const [visibleCount, setVisibleCount] = useState(THREAD_LINEAGE_INITIAL_COUNT); const { visibleRows, hiddenCount } = resolveThreadLineageWindow(props.rows, visibleCount); - const failedCount = props.rows.filter( - ({ edge }) => edge.status === "failed" || edge.status === "error", - ).length; + const failedCount = props.rows.filter((row) => { + const status = lineageRowStatus(row); + return status === "failed" || status === "error"; + }).length; if (props.rows.length === 0) return null; return (
@@ -247,9 +261,7 @@ function currentSubagent( * the thread there, when this client is connected to that environment. */ function RemoteSubagentRow(props: { - readonly agent: OrchestrationV2Subagent & { - readonly remoteChild: OrchestrationV2RemoteTaskChild; - }; + readonly agent: RemoteSubagent; readonly provider: ServerProvider | undefined; }) { const navigate = useNavigate(); @@ -537,22 +549,29 @@ export function ThreadRelationshipsPanel(props: { if (!isParent && graph.nodes.get(threadId)?.thread?.lineage.sideChat === true) return "sideChats"; if (edge.kind !== "subagent" || isParent) return "related"; - return ["completed", "failed", "error", "cancelled", "interrupted", "idle"].includes( - edge.status ?? "", - ) - ? "previous" - : "active"; + return isFinishedAgentStatus(edge.status) ? "previous" : "active"; }); - const groups = [ + const { remoteActive = [], remotePrevious = [] } = groupBy( + (projection?.subagents ?? []) + .filter((agent): agent is RemoteSubagent => agent.remoteChild !== undefined) + .map((remote) => ({ remote })), + ({ remote }) => (isFinishedAgentStatus(remote.status) ? "remotePrevious" : "remoteActive"), + ); + const groups: ReadonlyArray<{ + id: string; + label: string | null; + rows: ReadonlyArray; + expanded: boolean; + }> = [ { id: "related", label: null, rows: related, expanded: true }, - { id: "active", label: null, rows: active, expanded: true }, - { id: "previous", label: "Previous agents", rows: previous, expanded: false }, + { id: "active", label: null, rows: [...active, ...remoteActive], expanded: true }, + { + id: "previous", + label: "Previous agents", + rows: [...previous, ...remotePrevious], + expanded: false, + }, ]; - // Tasks running in a linked environment have no thread here; they get their own rows. - const remoteSubagents = (projection?.subagents ?? []).filter( - (agent): agent is OrchestrationV2Subagent & { remoteChild: OrchestrationV2RemoteTaskChild } => - agent.remoteChild !== undefined, - ); const runningCount = (projection?.subagents.filter( (agent) => agent.childThreadId === null && agent.status === "running", @@ -567,7 +586,8 @@ export function ThreadRelationshipsPanel(props: { active.length > 0 || previous.length > 0 || runningCount > 0 || - remoteSubagents.length > 0 || + remoteActive.length > 0 || + remotePrevious.length > 0 || delegatedFrom !== undefined || // Disconnect lives in this heading, so a thread with only side chats still shows it. (canDetach && sideChats.length > 0)); @@ -929,6 +949,7 @@ export function ThreadRelationshipsPanel(props: { canDetach || related.length > 0 || active.length > 0 || + remoteActive.length > 0 || delegatedFrom !== undefined } data-thread-relationships-panel @@ -968,23 +989,24 @@ export function ThreadRelationshipsPanel(props: { )} {groups.map((group) => ( - {(visibleRows) => visibleRows.map(renderRelationshipRow)} + {(visibleRows) => + visibleRows.map((row) => + "remote" in row ? ( + entry.instanceId === row.remote.providerInstanceId)} + /> + ) : ( + renderRelationshipRow(row) + ), + ) + } ))} - {remoteSubagents.length > 0 ? ( - {}}> - {remoteSubagents.map((agent) => ( - entry.instanceId === agent.providerInstanceId)} - /> - ))} - - ) : null} ) : null} {sideChatsVisible ? (