From 5f6269968987f57418f9968c6847b33e05217b67 Mon Sep 17 00:00:00 2001 From: Braulio Oliveira Date: Fri, 6 Nov 2026 21:31:41 -0300 Subject: [PATCH 1/5] lineage: nest a subagent's own subagents under it and make the list taller Co-Authored-By: Claude Sonnet 5.5 --- ...hreadRelationshipsControl.browser.test.tsx | 33 ++++++++++++++++ .../chat/ThreadRelationshipsControl.tsx | 38 +++++++++++++++---- .../state/entities.relationshipShells.test.ts | 13 +++++-- apps/web/src/state/entities.ts | 13 ++++++- 4 files changed, 85 insertions(+), 12 deletions(-) diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.browser.test.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.browser.test.tsx index 6a677fc02f665..5062cdb9d51a2 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.browser.test.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.browser.test.tsx @@ -398,3 +398,36 @@ it("hides a deleted fork and keeps unloaded and archived forks with their curren await expect.element(screen.getByText("Archived fork", { exact: true })).toBeVisible(); await expect.element(screen.getByText("deleted-fork", { exact: true })).not.toBeInTheDocument(); }); + +it("nests a subagent's own subagents under it", async () => { + setup(); + const projection = makeThreadProjectionFixture(); + const environmentId = EnvironmentId.make("local"); + const shell = (id: string, title: string, parentThreadId: ThreadId) => ({ + id: ThreadId.make(id), + title, + activityRunStatus: "running", + status: "running", + lineage: { parentThreadId, relationshipToParent: "subagent" }, + forkedFrom: null, + }); + state.projection = projection; + state.relatedShells = [ + shell("child", "Camera", projection.thread.id), + shell("grandchild", "Research lighting", ThreadId.make("child")), + ]; + await using screen = await render( + , + ); + + await expect.element(screen.getByRole("button", { name: /Camera/ })).toBeVisible(); + await expect.element(screen.getByRole("button", { name: /Research lighting/ })).toBeVisible(); + await expect + .element(screen.getByRole("listitem").filter({ hasText: "Research lighting" })) + .toHaveClass("ms-4"); +}); diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx index f392c652e05de..de6b58c233730 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx @@ -56,7 +56,7 @@ import { SquareIcon, UnplugIcon, } from "lucide-react"; -import { useRef, useState, type ReactNode } from "react"; +import { Fragment, useRef, useState, type ReactNode } from "react"; import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; import { buildThreadRouteParams } from "../../threadRoutes"; @@ -118,7 +118,7 @@ function ThreadLineageRowList(props: { {/* ScrollArea's max-height viewport measures badly inside this scrolling panel. */}
    {props.children}
@@ -689,11 +689,12 @@ export function ThreadRelationshipsPanel(props: { ? null : (graph.nodes.get(mergeTargetThreadId)?.thread?.title ?? null); - const renderRelationshipRow = ({ threadId, edge }: ThreadRelationshipWalkRow) => { + const renderRelationshipRow = ({ threadId, fromThreadId, edge }: ThreadRelationshipWalkRow) => { + const nested = fromThreadId !== props.threadId; const node = graph.nodes.get(threadId); const isSubagent = edge.kind === "subagent"; const isMergeTarget = threadId === mergeTargetThreadId; - const isParent = isParentThreadRelationship(edge, props.threadId); + const isParent = isParentThreadRelationship(edge, fromThreadId); const rowStatus = (isParent && threadId === parentRef?.threadId ? (node?.thread?.activityRunStatus ?? parentNativeStatus) @@ -708,7 +709,7 @@ export function ThreadRelationshipsPanel(props: { const relationship = node?.thread?.lineage.sideChat === true && !isParent ? "Side chat" - : relationshipLabel(edge, props.threadId); + : relationshipLabel(edge, fromThreadId); const projectedAgent = isSubagent && !isParent ? subagentsByThreadId.get(threadId) : undefined; const workflowGroup = projectedAgent === undefined ? undefined : workflowGroupsById.get(projectedAgent.id); @@ -848,7 +849,10 @@ export function ThreadRelationshipsPanel(props: { ); } return ( -
  • +
  • {isMergeTarget ? (
    @@ -937,6 +941,26 @@ export function ThreadRelationshipsPanel(props: { ); }; + // A subagent's own subagents nest one level under it. + const renderRelationshipTree = (row: ThreadRelationshipWalkRow) => ( + + {renderRelationshipRow(row)} + {row.edge.kind === "subagent" && !isParentThreadRelationship(row.edge, props.threadId) + ? orderWebThreadLineageRows({ + graph, + rows: immediateThreadRelationships(graph, row.threadId).filter( + (child) => + child.edge.kind === "subagent" && + child.edge.sourceThreadId === row.threadId && + !isHiddenRelation(child.threadId), + ), + currentThreadId: row.threadId, + mergeTargetThreadId: null, + }).map(renderRelationshipRow) + : null} + + ); + return ( <> {lineageVisible ? ( @@ -1001,7 +1025,7 @@ export function ThreadRelationshipsPanel(props: { ?.find((entry) => entry.instanceId === row.remote.providerInstanceId)} /> ) : ( - renderRelationshipRow(row) + renderRelationshipTree(row) ), ) } diff --git a/apps/web/src/state/entities.relationshipShells.test.ts b/apps/web/src/state/entities.relationshipShells.test.ts index 0a39f4a786586..9a63618e95327 100644 --- a/apps/web/src/state/entities.relationshipShells.test.ts +++ b/apps/web/src/state/entities.relationshipShells.test.ts @@ -8,17 +8,20 @@ import { selectThreadRelationshipShells } from "./entities"; type Projection = Parameters[1]; -const shell = (id: string, parentThreadId: string | null = null) => +const shell = (id: string, parentThreadId: string | null = null, relationship = "fork") => ({ id, forkedFrom: null, - lineage: { parentThreadId, relationshipToParent: parentThreadId === null ? null : "fork" }, + lineage: { + parentThreadId, + relationshipToParent: parentThreadId === null ? null : relationship, + }, }) as unknown as OrchestrationV2ThreadShell; const threadId = "current" as OrchestrationV2ThreadShell["id"]; describe("selectThreadRelationshipShells", () => { - it("keeps the thread, its parent, its children and the threads its projection names", () => { + it("keeps the thread, its parent, its children, their subagents and the threads its projection names", () => { const threads = [ shell("current", "parent"), shell("parent"), @@ -28,6 +31,8 @@ describe("selectThreadRelationshipShells", () => { shell("transfer-source"), shell("unrelated"), shell("grandchild", "child"), + shell("sub-subagent", "child", "subagent"), + shell("projected-sub-subagent", "subagent-child", "subagent"), ]; const projection = { thread: threads[0], @@ -53,6 +58,8 @@ describe("selectThreadRelationshipShells", () => { "subagent-child", "workflow-member", "transfer-source", + "sub-subagent", + "projected-sub-subagent", ]); }); diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index 19c0736779897..377523d83ebb2 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -392,11 +392,15 @@ export function selectThreadRelationshipShells( threads: ReadonlyArray, ): ReadonlyArray { const named = new Set([threadId]); + const childIds = new Set(); if (projection !== null) { const parentThreadId = shellParentThreadId(projection.thread); if (parentThreadId !== null) named.add(parentThreadId); for (const subagent of projection.subagents) { - if (subagent.childThreadId !== null) named.add(subagent.childThreadId); + if (subagent.childThreadId !== null) { + named.add(subagent.childThreadId); + childIds.add(subagent.childThreadId); + } for (const agent of subagent.workflow?.agents ?? []) { if (agent.childThreadId !== undefined) named.add(agent.childThreadId); } @@ -409,8 +413,13 @@ export function selectThreadRelationshipShells( const current = threads.find((thread) => thread.id === threadId); const currentParentId = current === undefined ? null : shellParentThreadId(current); if (currentParentId !== null) named.add(currentParentId); + const isChild = (thread: OrchestrationV2ThreadShell) => shellParentThreadId(thread) === threadId; + for (const thread of threads) if (isChild(thread)) childIds.add(thread.id); + const isSubagentGrandchild = (thread: OrchestrationV2ThreadShell) => + thread.lineage.relationshipToParent === "subagent" && + childIds.has(shellParentThreadId(thread) ?? ""); return threads.filter( - (thread) => named.has(thread.id) || shellParentThreadId(thread) === threadId, + (thread) => named.has(thread.id) || isChild(thread) || isSubagentGrandchild(thread), ); } From 5a427105a69140bdb9a055ff88038b1e90045cf4 Mon Sep 17 00:00:00 2001 From: Braulio Oliveira Date: Fri, 6 Nov 2026 22:11:18 -0300 Subject: [PATCH 2/5] lineage: count running nested subagents in the heading Co-Authored-By: Claude Sonnet 5.5 --- ...hreadRelationshipsControl.browser.test.tsx | 1 + .../chat/ThreadRelationshipsControl.tsx | 36 +++++++++++-------- 2 files changed, 22 insertions(+), 15 deletions(-) diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.browser.test.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.browser.test.tsx index 5062cdb9d51a2..c1ffb666d1eb9 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.browser.test.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.browser.test.tsx @@ -427,6 +427,7 @@ it("nests a subagent's own subagents under it", async () => { await expect.element(screen.getByRole("button", { name: /Camera/ })).toBeVisible(); await expect.element(screen.getByRole("button", { name: /Research lighting/ })).toBeVisible(); + await expect.element(screen.getByText("Lineage · 2 running")).toBeVisible(); await expect .element(screen.getByRole("listitem").filter({ hasText: "Research lighting" })) .toHaveClass("ms-4"); diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx index de6b58c233730..8181dba9fe59a 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx @@ -572,10 +572,29 @@ export function ThreadRelationshipsPanel(props: { expanded: false, }, ]; + // A subagent's own subagents nest one level under it. + const nestedSubagentRows = (row: ThreadRelationshipWalkRow) => + row.edge.kind === "subagent" && !isParentThreadRelationship(row.edge, props.threadId) + ? orderWebThreadLineageRows({ + graph, + rows: immediateThreadRelationships(graph, row.threadId).filter( + (child) => + child.edge.kind === "subagent" && + child.edge.sourceThreadId === row.threadId && + !isHiddenRelation(child.threadId), + ), + currentThreadId: row.threadId, + mergeTargetThreadId: null, + }) + : []; const runningCount = (projection?.subagents.filter( (agent) => agent.childThreadId === null && agent.status === "running", - ).length ?? 0) + active.filter(({ edge }) => edge.status === "running").length; + ).length ?? 0) + + active.filter(({ edge }) => edge.status === "running").length + + [...active, ...previous] + .flatMap(nestedSubagentRows) + .filter((row) => threadRelationshipRowStatus(graph, row) === "running").length; // A linked environment's agent may have delegated this thread from there. const delegatedFrom = currentThread?.delegatedFrom; @@ -941,23 +960,10 @@ export function ThreadRelationshipsPanel(props: { ); }; - // A subagent's own subagents nest one level under it. const renderRelationshipTree = (row: ThreadRelationshipWalkRow) => ( {renderRelationshipRow(row)} - {row.edge.kind === "subagent" && !isParentThreadRelationship(row.edge, props.threadId) - ? orderWebThreadLineageRows({ - graph, - rows: immediateThreadRelationships(graph, row.threadId).filter( - (child) => - child.edge.kind === "subagent" && - child.edge.sourceThreadId === row.threadId && - !isHiddenRelation(child.threadId), - ), - currentThreadId: row.threadId, - mergeTargetThreadId: null, - }).map(renderRelationshipRow) - : null} + {nestedSubagentRows(row).map(renderRelationshipRow)} ); From 1cf0c110b25f807df8d8e84189f11e60cc7790c1 Mon Sep 17 00:00:00 2001 From: Braulio Oliveira Date: Sat, 14 Nov 2026 09:59:20 -0300 Subject: [PATCH 3/5] lineage: index a graph's immediate relationships once Co-Authored-By: Claude Sonnet 5.5 --- .../chat/ThreadRelationshipsControl.tsx | 4 +- .../src/state/threadRelationships.ts | 40 +++++++++++-------- 2 files changed, 27 insertions(+), 17 deletions(-) diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx index 7dee74b958731..559278080b5eb 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx @@ -17,6 +17,7 @@ import { deriveProviderSubagentStatus } from "@t3tools/client-runtime/state/thre import { deriveThreadRelationshipGraph, immediateThreadRelationships, + indexImmediateThreadRelationships, isParentThreadRelationship, threadRelationshipRowStatus, orderWebThreadLineageRows, @@ -603,11 +604,12 @@ export function ThreadRelationshipsPanel(props: { }, ]; // A subagent's own subagents nest one level under it. + const immediateRelationships = indexImmediateThreadRelationships(graph); const nestedSubagentRows = (row: ThreadRelationshipWalkRow) => row.edge.kind === "subagent" && !isParentThreadRelationship(row.edge, props.threadId) ? orderWebThreadLineageRows({ graph, - rows: immediateThreadRelationships(graph, row.threadId).filter( + rows: (immediateRelationships.get(row.threadId) ?? []).filter( (child) => child.edge.kind === "subagent" && child.edge.sourceThreadId === row.threadId && diff --git a/packages/client-runtime/src/state/threadRelationships.ts b/packages/client-runtime/src/state/threadRelationships.ts index 26ff658a5191b..667977d2a29cf 100644 --- a/packages/client-runtime/src/state/threadRelationships.ts +++ b/packages/client-runtime/src/state/threadRelationships.ts @@ -130,26 +130,34 @@ export function deriveThreadRelationshipGraph(input: { return { nodes, edges: [...edgesByKey.values()] }; } -export function immediateThreadRelationships( +/** The immediate relationships of every thread in the graph, in one pass over its edges. */ +export function indexImmediateThreadRelationships( graph: ThreadRelationshipGraph, - threadId: ThreadId, -): ReadonlyArray { - const visited = new Set(); - const rows: ThreadRelationshipWalkRow[] = []; - +): ReadonlyMap> { + const rowsByThread = new Map(); + const relatedByThread = new Map>(); + const add = (threadId: ThreadId, relatedId: ThreadId, edge: ThreadRelationshipEdge) => { + const related = relatedByThread.get(threadId) ?? new Set(); + if (related.has(relatedId)) return; + related.add(relatedId); + relatedByThread.set(threadId, related); + const row = { threadId: relatedId, fromThreadId: threadId, depth: 1, edge }; + const rows = rowsByThread.get(threadId); + if (rows === undefined) rowsByThread.set(threadId, [row]); + else rows.push(row); + }; for (const edge of graph.edges) { - const relatedId = - edge.sourceThreadId === threadId - ? edge.targetThreadId - : edge.targetThreadId === threadId - ? edge.sourceThreadId - : null; - if (relatedId === null || visited.has(relatedId)) continue; - visited.add(relatedId); - rows.push({ threadId: relatedId, fromThreadId: threadId, depth: 1, edge }); + add(edge.sourceThreadId, edge.targetThreadId, edge); + add(edge.targetThreadId, edge.sourceThreadId, edge); } + return rowsByThread; +} - return rows; +export function immediateThreadRelationships( + graph: ThreadRelationshipGraph, + threadId: ThreadId, +): ReadonlyArray { + return indexImmediateThreadRelationships(graph).get(threadId) ?? []; } export function isParentThreadRelationship( From a7e78078dc91f957141044214664904d404768de Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 15 Nov 2026 03:14:54 +0000 Subject: [PATCH 4/5] fix(web): keep nested native agent lineage current --- .../orchestration-v2/ProjectionStore.test.ts | 78 ++++++++++++++++++- .../src/orchestration-v2/ProjectionStore.ts | 32 +++++++- .../src/orchestration-v2/ShellStream.test.ts | 10 +++ .../src/orchestration-v2/ShellStream.ts | 7 ++ ...hreadRelationshipsControl.browser.test.tsx | 14 ++-- .../chat/ThreadRelationshipsControl.tsx | 13 +++- 6 files changed, 144 insertions(+), 10 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 50f2a0a12d1ae..b2f32c55ca821 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -2753,10 +2753,11 @@ it.effect("SQL shells read a subagent family and a worktree's threads", () => readonly root?: string; readonly worktreePath?: string; readonly archived?: boolean; + readonly native?: boolean; }) => ({ createdBy: "user", - creationSource: input.parent === undefined ? "web" : "mcp", + creationSource: input.native ? "provider" : input.parent === undefined ? "web" : "mcp", id: id(input.name), projectId, title: input.name, @@ -2791,7 +2792,7 @@ it.effect("SQL shells read a subagent family and a worktree's threads", () => }); yield* createThread({ name: "root", worktreePath }); yield* createThread({ name: "child", parent: "root", root: "root", worktreePath }); - yield* createThread({ name: "grandchild", parent: "child", root: "root" }); + yield* createThread({ name: "grandchild", parent: "child", root: "root", native: true }); yield* createThread({ name: "archived-child", parent: "root", root: "root", archived: true }); yield* createThread({ name: "other", worktreePath: "/repo" }); yield* createThread({ name: "other-child", parent: "other", root: "other", worktreePath }); @@ -2806,6 +2807,79 @@ it.effect("SQL shells read a subagent family and a worktree's threads", () => assert.sameMembers([...(yield* family(id("grandchild")))], tree); assert.deepEqual([...(yield* family(id("missing")))], []); + const nativeRootId = NodeId.make("node:family:grandchild-root"); + const nativeRoot = { + id: nativeRootId, + threadId: id("grandchild"), + runId: null, + parentNodeId: null, + rootNodeId: nativeRootId, + kind: "root_turn" as const, + countsForRun: false, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + runtimeRequestId: null, + checkpointScopeId: null, + startedAt: now, + completedAt: null, + }; + for (const status of ["pending", "running", "completed", "failed", "cancelled"] as const) { + yield* store.apply({ + id: EventId.make(`event:family:native-root:${status}`), + type: "node.updated", + threadId: id("grandchild"), + nodeId: nativeRootId, + driver, + occurredAt: now, + payload: { ...nativeRoot, status }, + }); + const snapshot = yield* store.getShellSnapshot({ location: "active", familyOf: id("root") }); + const projection = yield* store.getThreadProjection(id("grandchild")); + const shells = [ + yield* store.getThreadShell(id("grandchild")), + snapshot.threads.find((thread) => thread.id === id("grandchild")), + ProjectionStore.threadShellFromProjection(projection), + ]; + assert.deepEqual( + shells.map((shell) => shell?.status), + Array(3).fill(status === "pending" ? "queued" : status), + ); + yield* assertShellIndexCoversShells(); + } + + const followupRunId = RunId.make("run:family:grandchild-followup"); + yield* store.apply({ + id: EventId.make("event:family:grandchild-followup"), + type: "run.created", + threadId: id("grandchild"), + runId: followupRunId, + occurredAt: now, + payload: { + id: followupRunId, + threadId: id("grandchild"), + ordinal: 1, + providerInstanceId, + modelSelection, + providerThreadId: null, + userMessageId: MessageId.make("message:family:grandchild-followup"), + rootNodeId: NodeId.make("node:family:grandchild-followup"), + activeAttemptId: null, + status: "running", + requestedAt: now, + startedAt: now, + completedAt: null, + checkpointId: null, + contextHandoffId: null, + }, + }); + assert.equal((yield* store.getThreadShell(id("grandchild")))?.status, "running"); + assert.equal( + ProjectionStore.threadShellFromProjection(yield* store.getThreadProjection(id("grandchild"))) + .status, + "running", + ); + // Includes another root's idle child on the same path, which a shell may leave out. const { statements, tracer } = traceSqlStatements(); const onWorktree = yield* store diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 3c1d353f061b4..8119c1448b6c7 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -51,6 +51,7 @@ import type { import { IDLE_COMPACTION_TEXT, idleCompactionOf, + isProviderNativeSubagentThread, OrchestrationV2AppThreadJson as OrchestrationV2AppThreadJsonSchema, OrchestrationV2CheckpointJson as OrchestrationV2CheckpointJsonSchema, OrchestrationV2CheckpointScopeJson as OrchestrationV2CheckpointScopeJsonSchema, @@ -1100,6 +1101,7 @@ type ShellThreadRow = { readonly forked_from_run_source_thread_id: string | null; readonly latest_run_id: string | null; readonly latest_run_status: string | null; + readonly native_root_status: string | null; readonly latest_run_requested_at: string | null; readonly latest_run_started_at: string | null; readonly latest_run_completed_at: string | null; @@ -1711,6 +1713,10 @@ export function threadShellFromProjection( const activityRun = runs.filter(isActivityRunForShell).toSorted((left, right) => right.ordinal - left.ordinal)[0] ?? null; + const nativeRoot = + latestRun === null && isProviderNativeSubagentThread(projection.thread) + ? projection.nodes.findLast((node) => node.kind === "root_turn" && node.runId === null) + : undefined; const liveRunIds = new Set(runs.filter(isActivityRunForShell).map((run) => run.id)); let pendingRuntimeRequest = pendingRuntimeRequestSummary( projection.runtimeRequests @@ -1810,7 +1816,7 @@ export function threadShellFromProjection( activityRunStatus: activityRun?.status ?? null, activityRunStartedAt: activityRun === null ? null : orchestrationV2RunWorkStartedAt(activityRun), - status: latestRun?.status ?? "idle", + status: latestRun?.status ?? shellStatusFromStoredRunStatus(nativeRoot?.status ?? null), ...threadErrorSummary( latestRootProviderFailure(latestRun, projection.turnItems), providerSession?.lastError ?? null, @@ -1969,6 +1975,8 @@ function shellStatusFromStoredRunStatus(status: string | null): OrchestrationV2S switch (status) { case null: return "idle"; + case "pending": + return "queued"; case "preparing": case "queued": case "starting": @@ -7679,6 +7687,15 @@ export const layer: Layer.Layer = END AS forked_from_run_source_thread_id, presented.run_id AS latest_run_id, presented.status AS latest_run_status, + CASE WHEN presented.run_id IS NULL + AND json_extract(t.payload_json, '$.creationSource') = 'provider' + AND json_extract(t.payload_json, '$.lineage.relationshipToParent') = 'subagent' + THEN ( + SELECT node.status FROM orchestration_v2_projection_nodes node + WHERE node.thread_id = t.thread_id AND node.run_id IS NULL AND node.kind = 'root_turn' + ORDER BY COALESCE(node.started_at, '') DESC, node.node_id DESC + LIMIT 1 + ) END AS native_root_status, presented.requested_at AS latest_run_requested_at, json_extract(presented.payload_json, '$.startedAt') AS latest_run_started_at, presented.completed_at AS latest_run_completed_at, @@ -8365,7 +8382,9 @@ export const layer: Layer.Layer = ? null : yield* decodeTurnItemPayload(row.terminal_failure_payload_json); let latestRunId = row.latest_run_id === null ? null : RunId.make(row.latest_run_id); - let latestRunStatus = shellStatusFromStoredRunStatus(row.latest_run_status); + let latestRunStatus = shellStatusFromStoredRunStatus( + row.latest_run_status ?? row.native_root_status, + ); let latestRunRequestedAt = row.latest_run_requested_at === null ? null @@ -8776,6 +8795,15 @@ export const layer: Layer.Layer = WHERE r.thread_id = t.thread_id AND r.status IN ('queued', 'preparing', 'starting', 'running', 'waiting') ) + OR ( + json_extract(t.payload_json, '$.creationSource') = 'provider' + AND json_extract(t.payload_json, '$.lineage.relationshipToParent') = 'subagent' + AND EXISTS ( + SELECT 1 FROM orchestration_v2_projection_nodes node + WHERE node.thread_id = t.thread_id AND node.run_id IS NULL AND node.kind = 'root_turn' + AND node.status IN ('pending', 'starting', 'running', 'waiting') + ) + ) OR EXISTS ( SELECT 1 FROM orchestration_v2_projection_runtime_requests request WHERE request.thread_id = t.thread_id AND request.status = 'pending' diff --git a/apps/server/src/orchestration-v2/ShellStream.test.ts b/apps/server/src/orchestration-v2/ShellStream.test.ts index fbc610235dd01..ab65a382eaca8 100644 --- a/apps/server/src/orchestration-v2/ShellStream.test.ts +++ b/apps/server/src/orchestration-v2/ShellStream.test.ts @@ -60,6 +60,16 @@ describe("subagent shell events", () => { expect(shouldPublishShellApplicationEvent(stored("child", "node.updated"), subagents)).toBe( false, ); + for (const [kind, runId, expected] of [ + ["root_turn", null, true], + ["root_turn", "run:followup", false], + ["tool_call", null, false], + ] as const) { + const event = { + event: { threadId: "child", type: "node.updated", payload: { kind, runId } }, + } as ApplicationStoredEvent; + expect(shouldPublishShellApplicationEvent(event, subagents)).toBe(expected); + } expect( shouldPublishShellApplicationEvent(stored("child", "runtime-request.updated"), subagents), ).toBe(true); diff --git a/apps/server/src/orchestration-v2/ShellStream.ts b/apps/server/src/orchestration-v2/ShellStream.ts index 5ee1005b16830..b67e8064d9582 100644 --- a/apps/server/src/orchestration-v2/ShellStream.ts +++ b/apps/server/src/orchestration-v2/ShellStream.ts @@ -551,6 +551,13 @@ export function shouldPublishShellApplicationEvent( if (event.type === "turn-item.updated" && turnItemCanChangeUserWait(event.payload)) { return true; } + if ( + event.type === "node.updated" && + event.payload.kind === "root_turn" && + event.payload.runId === null + ) { + return true; + } return !( subagentThreadIds.has(event.threadId) && (event.type === "node.updated" || diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.browser.test.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.browser.test.tsx index 07c0c6e119799..073ddaac67d94 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.browser.test.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.browser.test.tsx @@ -14,6 +14,7 @@ const state = vi.hoisted(() => ({ relatedShells: [] as unknown[], threadTitles: {} as Record, archivedThreads: [] as unknown[], + familyThreads: [] as unknown[], })); vi.mock(import("../../state/query"), async (original) => ({ @@ -21,7 +22,11 @@ vi.mock(import("../../state/query"), async (original) => ({ useEnvironmentQuery: ((atom: unknown) => atom === null ? { data: null, isSuccess: false, isPending: false } - : { data: state.threadTitles, isSuccess: true, isPending: false }) as never, + : { + data: { ...state.threadTitles, threads: state.familyThreads }, + isSuccess: true, + isPending: false, + }) as never, })); vi.mock(import("@effect/atom-react"), async (original) => ({ @@ -71,6 +76,7 @@ function setup() { state.relatedShells = []; state.threadTitles = { source: "Source" }; state.archivedThreads = []; + state.familyThreads = []; state.projection = { thread: { id: threadId, @@ -422,10 +428,8 @@ it("nests a subagent's own subagents under it", async () => { forkedFrom: null, }); state.projection = projection; - state.relatedShells = [ - shell("child", "Camera", projection.thread.id), - shell("grandchild", "Research lighting", ThreadId.make("child")), - ]; + state.relatedShells = [shell("child", "Camera", projection.thread.id)]; + state.familyThreads = [shell("grandchild", "Research lighting", ThreadId.make("child"))]; await using screen = await render( + thread.lineage.parentThreadId === props.threadId && + thread.lineage.relationshipToParent === "subagent", + ) + ? threadFamilies.familyAtom(props.environmentId, props.threadId) + : null, + ); const hiddenKeys = useHiddenThreadKeys(); const hidingAnywhere = useAnyEnvironmentHiding(); const allProjects = useProjects(); @@ -510,6 +520,7 @@ export function ThreadRelationshipsPanel(props: { ); const shells: ReadonlyArray = [ ...relationshipShells, + ...(family.data?.threads ?? []), ...(archivedShells ?? []), ]; const graph = deriveThreadRelationshipGraph({ threads: shells, projection }); From 19a54bd072ce4e2a7fd652370fcfbec81cddd25a Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 15 Nov 2026 03:49:35 +0000 Subject: [PATCH 5/5] fix(web): preserve lineage removals and workflow expansion --- ...hreadRelationshipsControl.browser.test.tsx | 121 +++++++++++++----- .../chat/ThreadRelationshipsControl.tsx | 30 +++-- 2 files changed, 107 insertions(+), 44 deletions(-) diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.browser.test.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.browser.test.tsx index 073ddaac67d94..5cc0c7d975f61 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.browser.test.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.browser.test.tsx @@ -14,7 +14,6 @@ const state = vi.hoisted(() => ({ relatedShells: [] as unknown[], threadTitles: {} as Record, archivedThreads: [] as unknown[], - familyThreads: [] as unknown[], })); vi.mock(import("../../state/query"), async (original) => ({ @@ -23,7 +22,7 @@ vi.mock(import("../../state/query"), async (original) => ({ atom === null ? { data: null, isSuccess: false, isPending: false } : { - data: { ...state.threadTitles, threads: state.familyThreads }, + data: state.threadTitles, isSuccess: true, isPending: false, }) as never, @@ -62,6 +61,11 @@ vi.mock(import("../../state/use-atom-command"), () => ({ : AsyncResult.failure(Cause.fail(new Error("Connection lost")))) as never, })); +vi.mock(import("../../state/threads"), async (original) => ({ + ...(await original()), + threadFamilies: { ...(await original()).threadFamilies, mount: () => () => {} }, +})); + import { render } from "../../../test/render"; import { makeThreadProjectionFixture } from "../../test-fixtures"; import { ScrollArea } from "../ui/scroll-area"; @@ -76,7 +80,6 @@ function setup() { state.relatedShells = []; state.threadTitles = { source: "Source" }; state.archivedThreads = []; - state.familyThreads = []; state.projection = { thread: { id: threadId, @@ -415,37 +418,89 @@ it("hides a deleted fork and keeps unloaded and archived forks with their curren await expect.element(screen.getByText("deleted-fork", { exact: true })).not.toBeInTheDocument(); }); -it("nests a subagent's own subagents under it", async () => { - setup(); - const projection = makeThreadProjectionFixture(); - const environmentId = EnvironmentId.make("local"); - const shell = (id: string, title: string, parentThreadId: ThreadId) => ({ - id: ThreadId.make(id), - title, - activityRunStatus: "running", - status: "running", - lineage: { parentThreadId, relationshipToParent: "subagent" }, - forkedFrom: null, - }); - state.projection = projection; - state.relatedShells = [shell("child", "Camera", projection.thread.id)]; - state.familyThreads = [shell("grandchild", "Research lighting", ThreadId.make("child"))]; - await using screen = await render( - , - ); +it.each([false, true])( + "nests subagents while preserving workflow expansion (%s)", + async (workflow) => { + setup(); + const projection = makeThreadProjectionFixture(); + const environmentId = EnvironmentId.make("local"); + const shell = (id: string, title: string, parentThreadId: ThreadId) => ({ + id: ThreadId.make(id), + title, + activityRunStatus: "running", + status: "running", + lineage: { parentThreadId, relationshipToParent: "subagent" }, + forkedFrom: null, + }); + state.projection = { + ...projection, + subagents: workflow + ? [ + { + id: NodeId.make("workflow"), + childThreadId: ThreadId.make("child"), + title: "Camera", + prompt: "Research lighting", + model: null, + status: "running", + result: null, + startedAt: projection.thread.createdAt, + completedAt: null, + updatedAt: projection.thread.updatedAt, + workflow: { + name: "Camera workflow", + phases: [{ index: 1, title: "Research" }], + agents: [ + { + index: 1, + label: "Research lighting", + state: "running", + phaseIndex: 1, + phaseTitle: "Research", + childThreadId: ThreadId.make("grandchild"), + }, + ], + }, + }, + ] + : [], + }; + state.relatedShells = [ + shell("child", "Camera", projection.thread.id), + shell("grandchild", "Research lighting", ThreadId.make("child")), + ]; + await using screen = await render( + , + ); - await expect.element(screen.getByRole("button", { name: /Camera/ })).toBeVisible(); - await expect.element(screen.getByRole("button", { name: /Research lighting/ })).toBeVisible(); - await expect.element(screen.getByText("Lineage · 2 running")).toBeVisible(); - await expect - .element(screen.getByRole("listitem").filter({ hasText: "Research lighting" })) - .toHaveClass("ms-4"); -}); + await expect + .element(screen.getByText(workflow ? "Camera workflow" : "Camera", { exact: true })) + .toBeVisible(); + if (workflow) { + await expect + .element(screen.getByText("Research lighting", { exact: true })) + .not.toBeInTheDocument(); + await screen.getByRole("button", { name: "Expand Camera workflow", exact: true }).click(); + await expect.element(screen.getByText("Research lighting", { exact: true })).toBeVisible(); + await expect.element(screen.getByText("Lineage · 1 running")).toBeVisible(); + await screen.getByRole("button", { name: "Collapse Camera workflow", exact: true }).click(); + await expect + .element(screen.getByText("Research lighting", { exact: true })) + .not.toBeInTheDocument(); + return; + } + await expect.element(screen.getByRole("button", { name: /Research lighting/ })).toBeVisible(); + await expect.element(screen.getByText("Lineage · 2 running")).toBeVisible(); + await expect + .element(screen.getByRole("listitem").filter({ hasText: "Research lighting" })) + .toHaveClass("ms-4"); + }, +); it.each([1, 18])("continues scrolling the panel past a list of %i subagents", async (count) => { setup(); diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx index d5c54a5ea27fe..61b19eed1575e 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx @@ -57,7 +57,7 @@ import { SquareIcon, UnplugIcon, } from "lucide-react"; -import { Fragment, useRef, useState, type ReactNode } from "react"; +import { Fragment, useEffect, useRef, useState, type ReactNode } from "react"; import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; import { buildThreadRouteParams } from "../../threadRoutes"; @@ -76,6 +76,7 @@ import { deriveProviderInstanceEntries, } from "../../providerInstances"; import { threadEnvironment, threadFamilies } from "../../state/threads"; +import { appAtomRegistry } from "../../rpc/atomRegistry"; import { orchestrationEnvironment, threadTitlesRead } from "../../state/orchestration"; import { useAtomCommand } from "../../state/use-atom-command"; import { useEnvironmentQuery } from "../../state/query"; @@ -433,16 +434,18 @@ export function ThreadRelationshipsPanel(props: { const providersByEnvironment = useEnvironmentProviders(); const settings = useAtomValue(serverEnvironment.settingsValueAtom(props.environmentId)); const relationshipShells = useThreadRelationshipShells(ref); - const family = useEnvironmentQuery( + const needsFamily = props.showLineage && - relationshipShells.some( - (thread) => - thread.lineage.parentThreadId === props.threadId && - thread.lineage.relationshipToParent === "subagent", - ) - ? threadFamilies.familyAtom(props.environmentId, props.threadId) - : null, - ); + relationshipShells.some( + (thread) => + thread.lineage.parentThreadId === props.threadId && + thread.lineage.relationshipToParent === "subagent", + ); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- holds completed descendants in the shell's family registry while lineage is visible + useEffect(() => { + if (!needsFamily) return; + return threadFamilies.mount(appAtomRegistry, props.environmentId, props.threadId); + }, [needsFamily, props.environmentId, props.threadId]); const hiddenKeys = useHiddenThreadKeys(); const hidingAnywhere = useAnyEnvironmentHiding(); const allProjects = useProjects(); @@ -495,6 +498,11 @@ export function ThreadRelationshipsPanel(props: { : undefined; const projected = projection?.subagents ?? []; const runtimeSubagents = projectedSubagentsToRuntime(projected); + const workflowMemberThreadIds = new Set( + runtimeSubagents + .filter((agent) => agent.parentAgentId !== null) + .map((agent) => agent.childThreadId), + ); const byId = new Map(runtimeSubagents.map((agent) => [agent.id, agent])); const subagentsByThreadId = new Map( projected @@ -520,7 +528,6 @@ export function ThreadRelationshipsPanel(props: { ); const shells: ReadonlyArray = [ ...relationshipShells, - ...(family.data?.threads ?? []), ...(archivedShells ?? []), ]; const graph = deriveThreadRelationshipGraph({ threads: shells, projection }); @@ -624,6 +631,7 @@ export function ThreadRelationshipsPanel(props: { (child) => child.edge.kind === "subagent" && child.edge.sourceThreadId === row.threadId && + !workflowMemberThreadIds.has(child.threadId) && !isHiddenRelation(child.threadId), ), currentThreadId: row.threadId,