diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.browser.test.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.browser.test.tsx index 9679f42569c6b..2af2a1a9ce49a 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.browser.test.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.browser.test.tsx @@ -210,6 +210,60 @@ it("opens an idle child from Lineage when its shell has not been loaded", async ); }); +it("keeps the latest batch of finished agents in view after a later turn launches none", async () => { + setup(); + const projection = makeThreadProjectionFixture(); + const agent = (id: string, runId: string, title: string) => ({ + id: NodeId.make(id), + threadId: projection.thread.id, + runId, + parentNodeId: NodeId.make("parent"), + origin: "provider_native", + createdBy: "agent", + driver: "claudeAgent", + providerInstanceId: projection.thread.providerInstanceId, + providerThreadId: null, + childThreadId: ThreadId.make(`${id}-thread`), + nativeTaskRef: null, + prompt: title, + title, + model: null, + status: "completed", + result: null, + startedAt: projection.thread.updatedAt, + completedAt: projection.thread.updatedAt, + updatedAt: projection.thread.updatedAt, + }); + state.projection = { + ...projection, + runs: [1, 2, 3].map((ordinal) => ({ + id: `run-${ordinal}`, + ordinal, + status: "completed", + startedAt: projection.thread.updatedAt, + })), + subagents: [ + agent("older", "run-1", "Older batch agent"), + agent("latest", "run-2", "Latest batch agent"), + ], + }; + await using screen = await render( + , + ); + + await expect + .element(screen.getByRole("button", { name: /Latest batch agent/ })) + .toBeInTheDocument(); + await expect + .element(screen.getByRole("button", { name: /Older batch agent/ })) + .not.toBeInTheDocument(); +}); + 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..23a5023494ce9 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx @@ -37,6 +37,7 @@ import { type OrchestrationV2RemoteTaskChild, type OrchestrationV2Subagent, type OrchestrationV2ThreadShell, + type RunId, type ServerProvider, type ThreadId, } from "@t3tools/contracts"; @@ -541,6 +542,23 @@ export function ThreadRelationshipsPanel(props: { Object.hasOwn(resolvedThreadTitles, threadId), ); const canDetach = projection ? canDetachThreadProviderSession(projection) : false; + // The latest batch stays in view after it finishes, until a later turn launches agents. + const runOrdinals = new Map( + (projection?.runs ?? []) + .filter((run) => run.status !== "rolled_back") + .map((run) => [run.id, run.ordinal]), + ); + const latestBatchRunId = projected.reduce( + (latest, agent) => + agent.runId !== null && + runOrdinals.has(agent.runId) && + (latest === null || runOrdinals.get(agent.runId)! > runOrdinals.get(latest)!) + ? agent.runId + : latest, + null, + ); + const isPreviousAgent = (status: string | null | undefined, runId: RunId | null | undefined) => + isFinishedAgentStatus(status) && (latestBatchRunId === null || runId !== latestBatchRunId); const { related = [], @@ -552,13 +570,16 @@ export function ThreadRelationshipsPanel(props: { 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 isPreviousAgent(edge.status, subagentsByThreadId.get(threadId)?.runId) + ? "previous" + : "active"; }); const { remoteActive = [], remotePrevious = [] } = groupBy( (projection?.subagents ?? []) .filter((agent): agent is RemoteSubagent => agent.remoteChild !== undefined) .map((remote) => ({ remote })), - ({ remote }) => (isFinishedAgentStatus(remote.status) ? "remotePrevious" : "remoteActive"), + ({ remote }) => + isPreviousAgent(remote.status, remote.runId) ? "remotePrevious" : "remoteActive", ); const groups: ReadonlyArray<{ id: string;