diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.browser.test.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.browser.test.tsx index 9679f42569c6b..a21db09c8408e 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.browser.test.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.browser.test.tsx @@ -210,6 +210,126 @@ it("opens an idle child from Lineage when its shell has not been loaded", async ); }); +it("keeps status order across forks and collapsed previous agents", async () => { + setup(); + const projection = makeThreadProjectionFixture(); + state.projection = { + ...projection, + subagents: (["running", "idle", "completed", "failed"] as const).map((status) => ({ + id: NodeId.make(`${status}-agent`), + threadId: projection.thread.id, + runId: null, + parentNodeId: NodeId.make("parent"), + origin: "app_owned", + createdBy: "agent", + driver: "codex", + providerInstanceId: projection.thread.providerInstanceId, + providerThreadId: null, + childThreadId: ThreadId.make(`${status}-child`), + nativeTaskRef: null, + prompt: "Inspect the change", + title: `${status} child`, + model: null, + status, + result: null, + startedAt: projection.thread.updatedAt, + completedAt: null, + updatedAt: projection.thread.updatedAt, + })), + }; + state.relatedShells = [ + { + ...projection.thread, + id: ThreadId.make("finished-fork"), + title: "Finished fork", + status: "completed", + lineage: { parentThreadId: projection.thread.id, relationshipToParent: "fork" }, + }, + ]; + await using screen = await render( + , + ); + + await expect.element(screen.getByText("Finished fork")).toBeVisible(); + const titles = () => + [...document.querySelectorAll("[data-thread-relationships-panel] li")].map( + (row) => row.textContent, + ); + await vi.waitFor(() => + expect(titles()).toEqual([ + expect.stringContaining("running child"), + expect.stringContaining("Finished fork"), + ]), + ); + + const previous = screen.getByRole("button", { name: /Previous agents/ }); + await previous.click(); + await vi.waitFor(() => + expect(titles()).toEqual([ + expect.stringContaining("running child"), + expect.stringContaining("idle child"), + expect.stringContaining("Finished fork"), + expect.stringContaining("completed child"), + expect.stringContaining("failed child"), + ]), + ); + + await previous.click(); + await vi.waitFor(() => + expect(titles()).toEqual([ + expect.stringContaining("running child"), + expect.stringContaining("Finished fork"), + ]), + ); +}); + +it("reveals previous agents even when working rows fill the first page", async () => { + setup(); + const projection = makeThreadProjectionFixture(); + const statuses = [...Array.from({ length: 6 }, () => "running" as const), "completed" as const]; + state.projection = { + ...projection, + subagents: statuses.map((status, index) => ({ + id: NodeId.make(`agent-${index}`), + threadId: projection.thread.id, + runId: null, + parentNodeId: NodeId.make("parent"), + origin: "app_owned", + createdBy: "agent", + driver: "codex", + providerInstanceId: projection.thread.providerInstanceId, + providerThreadId: null, + childThreadId: ThreadId.make(`child-${index}`), + nativeTaskRef: null, + prompt: "Inspect the change", + title: `${status} child ${index}`, + model: null, + status, + result: null, + startedAt: projection.thread.updatedAt, + completedAt: null, + updatedAt: projection.thread.updatedAt, + })), + }; + await using screen = await render( + , + ); + + await screen.getByRole("button", { name: /Previous agents/ }).click(); + await expect.element(screen.getByText("completed child 6")).toBeVisible(); + expect(document.querySelectorAll("[data-thread-relationships-panel] li")).toHaveLength(7); +}); + it("shows a lineage thread's full title in its tooltip", async () => { setup(); const longTitle = "Compare the lineage tooltip against a title that the row truncates"; diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx index 102d9734ded92..7839a47309db3 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx @@ -21,6 +21,7 @@ import { threadRelationshipRowStatus, orderWebThreadLineageRows, resolveMergeBackTargetThreadId, + webThreadLineageStatusRank, type ThreadRelationshipEdge, type ThreadRelationshipWalkRow, } from "@t3tools/client-runtime/state/thread-relationships"; @@ -150,31 +151,52 @@ 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 expanded: boolean; + readonly previousRows?: ReadonlyArray; readonly children: (rows: ReadonlyArray) => ReactNode; }) { - const [expanded, setExpanded] = useState(props.expanded); + const [expanded, setExpanded] = useState(false); const [visibleCount, setVisibleCount] = useState(THREAD_LINEAGE_INITIAL_COUNT); - const { visibleRows, hiddenCount } = resolveThreadLineageWindow(props.rows, visibleCount); - const failedCount = props.rows.filter((row) => { + const [previousVisibleCount, setPreviousVisibleCount] = useState(THREAD_LINEAGE_INITIAL_COUNT); + const previousRows = props.previousRows ?? []; + const previousSet = new Set(previousRows); + // Previous agents keep their own window so expanding them always reveals rows. + const current = resolveThreadLineageWindow( + props.rows.filter((row) => !previousSet.has(row)), + visibleCount, + ); + const previous = resolveThreadLineageWindow(expanded ? previousRows : [], previousVisibleCount); + const shownSet = new Set([...current.visibleRows, ...previous.visibleRows]); + const visibleRows = props.rows.filter((row) => shownSet.has(row)); + const failedCount = previousRows.filter((row) => { const status = lineageRowStatus(row); return status === "failed" || status === "error"; }).length; if (props.rows.length === 0) return null; return (
- {props.label ? ( + {visibleRows.length > 0 ? ( + 0 ? current.hiddenCount : previous.hiddenCount} + onShowMore={() => + (current.hiddenCount > 0 ? setVisibleCount : setPreviousVisibleCount)( + (count) => count + THREAD_LINEAGE_PAGE_COUNT, + ) + } + > + {props.children(visibleRows)} + + ) : null} + {previousRows.length > 0 ? ( setExpanded(!expanded)}> - {props.label} + Previous agents {failedCount > 0 ? {failedCount} failed : null} {expanded ? null : ( - {props.rows.length} + {previousRows.length} )} (props: { /> ) : null} - {expanded ? ( - setVisibleCount((count) => count + THREAD_LINEAGE_PAGE_COUNT)} - > - {props.children(visibleRows)} - - ) : null}
); } @@ -498,22 +512,22 @@ export function ThreadRelationshipsPanel(props: { const mergeTargetThreadId = resolveMergeBackTargetThreadId(projection); const relationshipRows = orderWebThreadLineageRows({ graph, - rows: immediateThreadRelationships(graph, props.threadId), + rows: immediateThreadRelationships(graph, props.threadId) + // The graph rebuilds missing parents from lineage, so filter the rows it returns. + .filter((row) => !isHiddenRelation(row.threadId) && !isHiddenUnloadedRelation(row)) + .map((row) => { + const agent = + row.edge.kind === "subagent" && !isParentThreadRelationship(row.edge, props.threadId) + ? subagentsByThreadId.get(row.threadId) + : undefined; + const workflowGroup = agent === undefined ? undefined : workflowGroupsById.get(agent.id); + return workflowGroup === undefined + ? row + : { ...row, edge: { ...row.edge, status: workflowGroup.workflow.status } }; + }), currentThreadId: props.threadId, mergeTargetThreadId, - }) - // The graph rebuilds missing parents from lineage, so filter the rows it returns. - .filter((row) => !isHiddenRelation(row.threadId) && !isHiddenUnloadedRelation(row)) - .map((row) => { - const agent = - row.edge.kind === "subagent" && !isParentThreadRelationship(row.edge, props.threadId) - ? subagentsByThreadId.get(row.threadId) - : undefined; - const workflowGroup = agent === undefined ? undefined : workflowGroupsById.get(agent.id); - return workflowGroup === undefined - ? row - : { ...row, edge: { ...row.edge, status: workflowGroup.workflow.status } }; - }); + }); const canMerge = mergeTargetThreadId !== null && latestMergeBackRun !== null; const unlistedThreadIds = relationshipRows .filter( @@ -542,55 +556,54 @@ export function ThreadRelationshipsPanel(props: { ); const canDetach = projection ? canDetachThreadProviderSession(projection) : false; - const { - related = [], - active = [], - previous = [], - sideChats = [], - } = groupBy(visibleRelationships, ({ threadId, edge }) => { + const { lineage = [], sideChats = [] } = groupBy(visibleRelationships, ({ threadId, edge }) => { const isParent = isParentThreadRelationship(edge, props.threadId); if (!isParent && graph.nodes.get(threadId)?.thread?.lineage.sideChat === true) return "sideChats"; - if (edge.kind !== "subagent" || isParent) return "related"; - return isFinishedAgentStatus(edge.status) ? "previous" : "active"; + return "lineage"; }); - const { remoteActive = [], remotePrevious = [] } = groupBy( - (projection?.subagents ?? []) + const isPinnedRow = ({ threadId, edge }: ThreadRelationshipWalkRow) => + isParentThreadRelationship(edge, props.threadId) || threadId === mergeTargetThreadId; + // Remote tasks have no thread here; stable-sort them into the status band they belong to. + const lineageRows: ReadonlyArray = [ + ...lineage, + ...(projection?.subagents ?? []) .filter((agent): agent is RemoteSubagent => agent.remoteChild !== undefined) .map((remote) => ({ remote })), - ({ remote }) => (isFinishedAgentStatus(remote.status) ? "remotePrevious" : "remoteActive"), + ].toSorted((left, right) => { + const band = (row: LineageRow) => + "remote" in row + ? webThreadLineageStatusRank(row.remote.status) + : isPinnedRow(row) + ? -1 + : webThreadLineageStatusRank(threadRelationshipRowStatus(graph, row)); + return band(left) - band(right); + }); + const previous = lineageRows.filter((row) => + "remote" in row + ? isFinishedAgentStatus(row.remote.status) + : row.edge.kind === "subagent" && + !isParentThreadRelationship(row.edge, props.threadId) && + isFinishedAgentStatus(row.edge.status), ); - 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, ...remoteActive], expanded: true }, - { - id: "previous", - label: "Previous agents", - rows: [...previous, ...remotePrevious], - expanded: false, - }, - ]; const runningCount = (projection?.subagents.filter( (agent) => agent.childThreadId === null && agent.status === "running", - ).length ?? 0) + active.filter(({ edge }) => edge.status === "running").length; + ).length ?? 0) + + lineage.filter( + ({ edge }) => + edge.kind === "subagent" && + !isParentThreadRelationship(edge, props.threadId) && + edge.status === "running", + ).length; // A linked environment's agent may have delegated this thread from there. const delegatedFrom = currentThread?.delegatedFrom; const lineageVisible = props.showLineage && - (related.length > 0 || - active.length > 0 || - previous.length > 0 || + (lineageRows.length > 0 || runningCount > 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)); @@ -950,9 +963,7 @@ export function ThreadRelationshipsPanel(props: { showHeading={ runningCount > 0 || canDetach || - related.length > 0 || - active.length > 0 || - remoteActive.length > 0 || + lineageRows.length > previous.length || delegatedFrom !== undefined } data-thread-relationships-panel @@ -990,26 +1001,28 @@ export function ThreadRelationshipsPanel(props: { /> )} - {groups.map((group) => ( - - {(visibleRows) => - visibleRows.map((row) => - "remote" in row ? ( - entry.instanceId === row.remote.providerInstanceId)} - /> - ) : ( - renderRelationshipRow(row) - ), - ) - } - - ))} + + {(visibleRows) => + visibleRows.map((row) => + "remote" in row ? ( + entry.instanceId === row.remote.providerInstanceId)} + /> + ) : ( + renderRelationshipRow(row) + ), + ) + } + ) : null} {sideChatsVisible ? ( @@ -1018,12 +1031,7 @@ export function ThreadRelationshipsPanel(props: { title={runningSideChats > 0 ? `Side chats ยท ${runningSideChats} running` : "Side chats"} data-thread-side-chats-panel > - + {(visibleRows) => visibleRows.map(renderRelationshipRow)} diff --git a/packages/client-runtime/src/state/threadRelationships.test.ts b/packages/client-runtime/src/state/threadRelationships.test.ts index 90dc68c21dd82..7ade5c7796bd8 100644 --- a/packages/client-runtime/src/state/threadRelationships.test.ts +++ b/packages/client-runtime/src/state/threadRelationships.test.ts @@ -368,7 +368,6 @@ describe("web thread lineage ordering", () => { parentThreadId: current, createdAt: "2026-06-03T00:00:00.000Z", updatedAt: "2026-07-30T00:00:00.000Z", - status: "running", }), forkShell({ id: third, parentThreadId: current, createdAt: "2026-06-04T00:00:00.000Z" }), forkShell({ id: current, parentThreadId: null, createdAt: "2026-06-01T00:00:00.000Z" }), @@ -377,7 +376,6 @@ describe("web thread lineage ordering", () => { parentThreadId: current, createdAt: "2026-06-02T00:00:00.000Z", updatedAt: "2026-07-31T00:00:00.000Z", - status: "failed", }), ], }); @@ -386,6 +384,56 @@ describe("web thread lineage ordering", () => { expect(after).toEqual(before); }); + it("puts working rows above idle rows, and idle rows above finished ones", () => { + const parent = ThreadId.make("thread-parent"); + const newestDone = ThreadId.make("thread-newest-done"); + const failed = ThreadId.make("thread-failed"); + const idle = ThreadId.make("thread-idle"); + const oldRunning = ThreadId.make("thread-old-running"); + const pendingSubagent = ThreadId.make("thread-pending-subagent"); + + expect( + orderedLineageIds({ + threads: [ + forkShell({ id: parent, parentThreadId: null, createdAt: "2026-06-01T00:00:00.000Z" }), + forkShell({ + id: current, + parentThreadId: parent, + createdAt: "2026-06-02T00:00:00.000Z", + }), + forkShell({ + id: newestDone, + parentThreadId: current, + createdAt: "2026-06-09T00:00:00.000Z", + }), + forkShell({ + id: failed, + parentThreadId: current, + createdAt: "2026-06-08T00:00:00.000Z", + status: "failed", + }), + forkShell({ + id: idle, + parentThreadId: current, + createdAt: "2026-06-07T00:00:00.000Z", + status: "idle", + }), + forkShell({ + id: oldRunning, + parentThreadId: current, + createdAt: "2026-06-03T00:00:00.000Z", + status: "running", + }), + ], + projection: { + thread: { id: current }, + subagents: [{ childThreadId: pendingSubagent, status: "pending" }], + contextTransfers: [], + }, + }), + ).toEqual([parent, oldRunning, pendingSubagent, idle, newestDone, failed]); + }); + it("pins the parent first and a distinct merge-back target second", () => { const parent = ThreadId.make("thread-parent"); const mergeTarget = ThreadId.make("thread-merge-target"); diff --git a/packages/client-runtime/src/state/threadRelationships.ts b/packages/client-runtime/src/state/threadRelationships.ts index 2aecc93470c2f..d0bd68cb6c5ec 100644 --- a/packages/client-runtime/src/state/threadRelationships.ts +++ b/packages/client-runtime/src/state/threadRelationships.ts @@ -176,9 +176,32 @@ function threadCreatedAtMillis(node: ThreadRelationshipNode | undefined): number return Number.isFinite(millis) ? millis : null; } +const WORKING_ROW_STATUSES = new Set([ + "pending", + "preparing", + "queued", + "starting", + "running", + "waiting", +]); +const FINISHED_ROW_STATUSES = new Set([ + "completed", + "failed", + "error", + "cancelled", + "interrupted", + "rolled_back", +]); + +export function webThreadLineageStatusRank(status: string | null): number { + if (status !== null && WORKING_ROW_STATUSES.has(status)) return 0; + if (status !== null && FINISHED_ROW_STATUSES.has(status)) return 2; + return 1; +} + /** - * Web pins the parent and distinct merge-back target before newest-created-first ordering. - * Mobile does not share this exception; do not reuse it as the canonical relationship order. + * Web pins the parent and distinct merge-back target, then orders working, idle and finished + * rows newest-created-first. Mobile does not share this order; do not reuse it as canonical. */ export function orderWebThreadLineageRows(input: { readonly graph: ThreadRelationshipGraph; @@ -195,6 +218,10 @@ export function orderWebThreadLineageRows(input: { return [...input.rows].sort((left, right) => { const rankDelta = pinRank(left) - pinRank(right); if (rankDelta !== 0) return rankDelta; + const statusDelta = + webThreadLineageStatusRank(threadRelationshipRowStatus(input.graph, left)) - + webThreadLineageStatusRank(threadRelationshipRowStatus(input.graph, right)); + if (statusDelta !== 0) return statusDelta; const leftCreatedAt = threadCreatedAtMillis(input.graph.nodes.get(left.threadId)); const rightCreatedAt = threadCreatedAtMillis(input.graph.nodes.get(right.threadId)); if (leftCreatedAt !== rightCreatedAt) {