From 010687e1e60ee3cfac32f2304513047d8679d4e5 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 12 Sep 2026 16:03:33 +0000 Subject: [PATCH 01/11] feat(web): subagent spawns render as an expandable work row The spawn CTA was a bordered card between plain work rows, exempt from turn folds, and clicking it only opened the Agents panel. It now uses the same icon, type, and chevron as sibling tool rows, expands in place to list each member with status, role, duration, tokens, and the first line of its result, and folds with its turn once every member settles. A batch with a live member stays outside the fold, decided from the agent panel model. --- .../chat/MessagesTimeline.logic.test.ts | 62 +++++ .../components/chat/MessagesTimeline.logic.ts | 17 +- .../src/components/chat/MessagesTimeline.tsx | 262 +++++++++++++++--- 3 files changed, 301 insertions(+), 40 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 0c5e53261b4f..8865b8ad785a 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1121,6 +1121,68 @@ describe("deriveMessagesTimelineRows", () => { ]); }); + it("folds a settled subagent spawn row and keeps a live one outside the fold", () => { + const timelineEntries = [ + { + id: "assistant-first-entry", + kind: "message" as const, + createdAt: "2026-01-01T00:00:01Z", + message: { + id: "assistant-first" as never, + role: "assistant" as const, + text: "Fanning out.", + turnId: "turn-1" as never, + createdAt: "2026-01-01T00:00:01Z", + updatedAt: "2026-01-01T00:00:02Z", + streaming: false, + }, + }, + { + id: "spawn-entry", + kind: "work" as const, + createdAt: "2026-01-01T00:00:03Z", + entry: { + id: "spawn-1", + createdAt: "2026-01-01T00:00:03Z", + turnId: "turn-1" as never, + label: "Ran 2 subagents", + tone: "tool" as const, + agentSpawn: { workflowId: null, agentTaskIds: ["agent-a", "agent-b"] }, + }, + }, + { + id: "assistant-final-entry", + kind: "message" as const, + createdAt: "2026-01-01T00:00:05Z", + message: { + id: "assistant-final" as never, + role: "assistant" as const, + text: "Done.", + turnId: "turn-1" as never, + createdAt: "2026-01-01T00:00:05Z", + updatedAt: "2026-01-01T00:00:06Z", + streaming: false, + }, + }, + ]; + const derive = (liveAgentTaskIds: ReadonlySet) => + deriveMessagesTimelineRows({ + timelineEntries, + isWorking: false, + activeTurnStartedAt: null, + turnDiffSummaries: [], + supportsConversationRollback: false, + liveAgentTaskIds, + }).map((row) => row.id); + + expect(derive(new Set())).toEqual(["turn-fold:turn-1", "assistant-final-entry"]); + expect(derive(new Set(["agent-b"]))).toEqual([ + "turn-fold:turn-1", + "spawn-entry", + "assistant-final-entry", + ]); + }); + it("only enables assistant copy for the terminal assistant message in a turn", () => { const rows = deriveMessagesTimelineRows({ timelineEntries: [ diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 866cbd0201b5..ddd2163880f8 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -577,6 +577,7 @@ function deriveTurnFolds(input: { terminalAssistantMessageIds: ReadonlySet; latestTurn: TimelineLatestTurn | null; unfoldedTurnIds: ReadonlySet; + liveAgentTaskIds: ReadonlySet | undefined; }): ReadonlyMap { interface TurnGroup { entries: Array; @@ -657,10 +658,15 @@ function deriveTurnFolds(input: { if (!isCompaction && index > terminalEntryIndex && !isSingleTrailingActivity) { continue; } - // Agent-spawn CTA rows never fold: workflows outlive their launching - // turn (dynamic spawns, background execution), and folding the CTA - // when the turn settles makes a still-running fleet invisible. - if (entry.kind === "work" && entry.entry.agentSpawn !== undefined) { + // Workflows outlive their launching turn (dynamic spawns, background + // execution), so a spawn row with a live member stays outside the fold + // instead of hiding a still-running fleet. Settled spawns fold with + // the rest of the turn. + if ( + entry.kind === "work" && + entry.entry.agentSpawn !== undefined && + entry.entry.agentSpawn.agentTaskIds.some((taskId) => input.liveAgentTaskIds?.has(taskId)) + ) { continue; } hiddenEntryIds.add(entry.id); @@ -854,6 +860,8 @@ export function deriveMessagesTimelineRows(input: { activeTurnStartedAt: string | null; turnDiffSummaries: ReadonlyArray; supportsConversationRollback: boolean; + /** Task ids of subagents still working; their spawn row stays outside turn folds. */ + liveAgentTaskIds?: ReadonlySet; }): MessagesTimelineRow[] { const turnDiffSummaryByAssistantMessageId = new Map(); for (const summary of input.turnDiffSummaries) { @@ -888,6 +896,7 @@ export function deriveMessagesTimelineRows(input: { terminalAssistantMessageIds, latestTurn: input.latestTurn ?? null, unfoldedTurnIds: activeVisualResponseTurnIds, + liveAgentTaskIds: input.liveAgentTaskIds, }); const collapsedEntryIds = new Set(); for (const fold of foldsByAnchorEntryId.values()) { diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index e2c3b6520de1..ba96e808232e 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -26,10 +26,15 @@ import { workEntryViewedImagePath, } from "@t3tools/client-runtime/work-log/presentation"; import { resolveWorkGroupScrollAnchor } from "@t3tools/client-runtime/work-log/scroll-anchor"; -import type { AgentPanelModel } from "@t3tools/client-runtime/state/subagentRuntime"; +import type { + AgentPanelModel, + RuntimeSubagent, +} from "@t3tools/client-runtime/state/subagentRuntime"; import { emptyAgentPanelModel, + formatSubagentModelLabel, formatSubagentTokenCount, + isActiveSubagentStatus, } from "@t3tools/client-runtime/state/subagentRuntime"; const EMPTY_AGENT_PANEL_MODEL = emptyAgentPanelModel(); @@ -569,6 +574,20 @@ export const MessagesTimeline = memo(function MessagesTimeline({ workspaceRoot: string | undefined; projection: MessagesTimelineRowsProjection; } | null>(null); + // Subagents still working keep their spawn row outside the turn fold. + const liveAgentTaskIds = useMemo(() => { + const ids = new Set(); + const consider = (agent: { id: string; status: RuntimeSubagent["status"] }) => { + if (isActiveSubagentStatus(agent.status)) ids.add(agent.id); + }; + agentPanelModel.directAgents.forEach(consider); + for (const group of agentPanelModel.workflows) { + consider(group.workflow); + group.unphasedMembers.forEach(consider); + group.phases.forEach((phase) => phase.members.forEach(consider)); + } + return ids; + }, [agentPanelModel]); const rawRows = useMemo(() => { const previous = rowsProjectionRef.current; const projection = deriveMessagesTimelineRowsWithState( @@ -582,6 +601,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ activeTurnStartedAt, turnDiffSummaries, supportsConversationRollback, + liveAgentTaskIds, }, previous?.threadKey === listIdentityKey && previous.workspaceRoot === workspaceRoot ? previous.projection @@ -602,6 +622,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ activeTurnStartedAt, turnDiffSummaries, supportsConversationRollback, + liveAgentTaskIds, ]); const rows = useStableRows(rawRows, listIdentityKey); const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]); @@ -3201,15 +3222,17 @@ const stopRowToggleWhileSelectingText = (e: MouseEvent) => { }; /** - * A1 spawn CTA: one anchored row per workflow run (or per-turn direct-spawn - * batch). Live status is derived from the shared agent panel model at render - * time — the row itself never re-renders a roster; the Agents panel is the - * only roster. Freezes to past tense when every member settles. Static dot, - * no animation. + * A batch of subagents as one work row. The header reads like the sibling + * tool rows; expanding lists each member with its outcome, and a member + * click shows its result in the standard tool body box. */ -const AgentSpawnCtaRow = memo(function AgentSpawnCtaRow(props: { workEntry: TimelineWorkEntry }) { +const AgentSpawnRow = memo(function AgentSpawnRow(props: { + workEntry: TimelineWorkEntry; + onToggleEntry?: ((collapsed: boolean) => void) | undefined; +}) { const { workEntry } = props; const { agentPanelModel, onOpenAgents } = use(TimelineRowCtx); + const [expanded, setExpanded] = useState(false); const spawn = workEntry.agentSpawn; if (!spawn) { return null; @@ -3225,8 +3248,8 @@ const AgentSpawnCtaRow = memo(function AgentSpawnCtaRow(props: { workEntry: Time const agentCount = Math.max( agents.length, Math.max(memberIds.size - (spawn.workflowId ? 1 : 0), 0), + 1, ); - const summary = deriveAgentSpawnSummary({ agents, agentCount, @@ -3237,45 +3260,212 @@ const AgentSpawnCtaRow = memo(function AgentSpawnCtaRow(props: { workEntry: Time // the coordinator, so count the coordinator only when no members exist. const totalTokens = agents.reduce( (sum, agent) => sum + (agent.usage?.totalTokens ?? 0), - spawn.workflowId && agents.length === 0 ? (workflowGroup?.workflow.usage?.totalTokens ?? 0) : 0, + agents.length === 0 ? (workflowGroup?.workflow.usage?.totalTokens ?? 0) : 0, ); - const livePhase = workflowGroup?.phases.find((phase) => phase.state === "running"); const workflowName = workflowGroup?.workflow.workflowName ?? workflowGroup?.workflow.title ?? null; - - const dotClass = { - working: "bg-info", - failed: "bg-destructive", - completed: "bg-success", - inactive: "bg-muted-foreground/50", - }[summary.tone]; const status = live && livePhase ? `${livePhase.title} · ${livePhase.activeCount} working` : summary.status; + // The verb already says the batch ran; only surface outcomes that differ. + const meta = [ + status === "✓ completed" ? null : status, + totalTokens > 0 ? `${formatSubagentTokenCount(totalTokens)} tok` : null, + ] + .filter(Boolean) + .join(" · "); + const toggleExpanded = () => { + props.onToggleEntry?.(expanded); + setExpanded((value) => !value); + }; return ( - + + + + + {expanded ? ( +
+ {agents.map((agent) => ( + + ))} + +
+ ) : null} + ); }); +const AGENT_MEMBER_DOT_CLASS: Record = { + pending: "bg-info", + running: "bg-info", + waiting: "bg-info", + idle: "bg-muted-foreground/50", + completed: "bg-success", + failed: "bg-destructive", + cancelled: "bg-muted-foreground/60", + interrupted: "bg-muted-foreground/60", +}; + +const AGENT_MEMBER_STATUS_LABEL: Record = { + pending: "Working", + running: "Working", + waiting: "Working", + idle: "Idle", + completed: "Completed", + failed: "Failed", + cancelled: "Stopped", + interrupted: "Stopped", +}; + +function AgentSpawnMemberRow({ agent }: { agent: RuntimeSubagent }) { + const [open, setOpen] = useState(false); + const activeStatus = isActiveSubagentStatus(agent.status); + const activity = activeStatus + ? (agent.progress ?? (agent.lastToolName ? `▸ ${agent.lastToolName}` : null)) + : (agent.error ?? agent.result ?? agent.progress ?? null); + const durationMs = + agent.startedAt && agent.completedAt + ? Date.parse(agent.completedAt) - Date.parse(agent.startedAt) + : null; + const meta = [ + durationMs !== null && Number.isFinite(durationMs) ? formatDuration(durationMs) : null, + agent.usage ? `${formatSubagentTokenCount(agent.usage.totalTokens)} tok` : null, + ] + .filter(Boolean) + .join(" · "); + const role = + agent.role && agent.role.trim().toLowerCase() !== agent.title.trim().toLowerCase() + ? agent.role + : null; + const firstLine = activity?.split("\n").find((line) => line.trim().length > 0) ?? null; + const canExpand = Boolean(activity && activity.trim().length > 0); + const body = [activity, formatSubagentModelLabel(agent.model, agent.effort)] + .filter(Boolean) + .join("\n\n"); + + return ( +
setOpen((value) => !value) : undefined} + onKeyDown={ + canExpand + ? (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + setOpen((value) => !value); + } + } + : undefined + } + className={cn( + "flex flex-col rounded-md px-1 py-0.5 transition-colors", + canExpand && + "cursor-pointer hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70", + )} + > +
+ +

+ + {agent.title} + + {role ? ( + + {role} + + ) : null} +

+ + {activeStatus + ? AGENT_MEMBER_STATUS_LABEL[agent.status] + : meta || AGENT_MEMBER_STATUS_LABEL[agent.status]} + +
+ {!open && firstLine ? ( +

{firstLine}

+ ) : null} + {open ? ( +
+
{body}
+
+ ) : null} +
+ ); +} + const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { workEntry: TimelineWorkEntry; workspaceRoot: string | undefined; @@ -3284,9 +3474,9 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { onToggleEntry?: ((collapsed: boolean) => void) | undefined; }) { const { workEntry, workspaceRoot, isExpandedToolGroupEntry, displayLabel } = props; - // Before any hooks: spawn CTA rows render their own component. + // Before any hooks: spawn rows render their own component. if (workEntry.agentSpawn) { - return ; + return ; } return ( Date: Sat, 12 Sep 2026 16:26:05 +0000 Subject: [PATCH 02/11] fix(web): spawn row keeps its header as the only button and pins open rows Nested member rows and the Agents panel link no longer bubble Enter and Space into the batch toggle, member expansion suspends end-scroll maintenance like other rows, and a row the user opened stays outside the turn fold until collapsed. Live detection follows the header's rule for workflow coordinators and keeps every spawn row out when no agent panel model is provided. --- apps/web/src/components/AgentsPanel.tsx | 5 +- .../chat/MessagesTimeline.logic.test.ts | 31 +++-- .../components/chat/MessagesTimeline.logic.ts | 31 +++-- .../src/components/chat/MessagesTimeline.tsx | 126 ++++++++++++------ 4 files changed, 129 insertions(+), 64 deletions(-) diff --git a/apps/web/src/components/AgentsPanel.tsx b/apps/web/src/components/AgentsPanel.tsx index 459506efc78c..43f1fa123f38 100644 --- a/apps/web/src/components/AgentsPanel.tsx +++ b/apps/web/src/components/AgentsPanel.tsx @@ -1,7 +1,6 @@ /** - * Agents right-panel surface: the fleet view over the native subagent fold, - * and the ONLY place the roster renders (the chat carries one CTA row per - * spawn batch). + * Agents right-panel surface: the fleet view over the native subagent fold. + * The chat carries one expandable row per spawn batch and links here. * * Visualization rules (from live-test feedback): * - Spawn order is stable. Activity and completion update rows in place. diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 8865b8ad785a..b285a26dcfa9 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1122,7 +1122,7 @@ describe("deriveMessagesTimelineRows", () => { }); it("folds a settled subagent spawn row and keeps a live one outside the fold", () => { - const timelineEntries = [ + const entriesWith = (agentSpawn: { workflowId: string | null; agentTaskIds: string[] }) => [ { id: "assistant-first-entry", kind: "message" as const, @@ -1147,7 +1147,7 @@ describe("deriveMessagesTimelineRows", () => { turnId: "turn-1" as never, label: "Ran 2 subagents", tone: "tool" as const, - agentSpawn: { workflowId: null, agentTaskIds: ["agent-a", "agent-b"] }, + agentSpawn, }, }, { @@ -1165,7 +1165,13 @@ describe("deriveMessagesTimelineRows", () => { }, }, ]; - const derive = (liveAgentTaskIds: ReadonlySet) => + const direct = entriesWith({ workflowId: null, agentTaskIds: ["agent-a", "agent-b"] }); + const workflow = entriesWith({ workflowId: "wf-1", agentTaskIds: ["wf-1", "agent-a"] }); + const derive = ( + timelineEntries: typeof direct, + liveAgentTaskIds: ReadonlySet | undefined, + expandedSpawnEntryIds?: ReadonlySet, + ) => deriveMessagesTimelineRows({ timelineEntries, isWorking: false, @@ -1173,14 +1179,19 @@ describe("deriveMessagesTimelineRows", () => { turnDiffSummaries: [], supportsConversationRollback: false, liveAgentTaskIds, + expandedSpawnEntryIds, }).map((row) => row.id); - - expect(derive(new Set())).toEqual(["turn-fold:turn-1", "assistant-final-entry"]); - expect(derive(new Set(["agent-b"]))).toEqual([ - "turn-fold:turn-1", - "spawn-entry", - "assistant-final-entry", - ]); + const folded = ["turn-fold:turn-1", "assistant-final-entry"]; + const unfolded = ["turn-fold:turn-1", "spawn-entry", "assistant-final-entry"]; + + expect(derive(direct, new Set())).toEqual(folded); + expect(derive(direct, new Set(["agent-b"]))).toEqual(unfolded); + // A workflow coordinator between phases keeps its batch out of the fold. + expect(derive(workflow, new Set(["wf-1"]))).toEqual(unfolded); + expect(derive(workflow, new Set())).toEqual(folded); + // The user has it open, or no live set is known. + expect(derive(direct, new Set(), new Set(["spawn-entry"]))).toEqual(unfolded); + expect(derive(direct, undefined)).toEqual(unfolded); }); it("only enables assistant copy for the terminal assistant message in a turn", () => { diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index ddd2163880f8..0418e7bf4c0b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -578,6 +578,7 @@ function deriveTurnFolds(input: { latestTurn: TimelineLatestTurn | null; unfoldedTurnIds: ReadonlySet; liveAgentTaskIds: ReadonlySet | undefined; + expandedSpawnEntryIds: ReadonlySet | undefined; }): ReadonlyMap { interface TurnGroup { entries: Array; @@ -660,14 +661,18 @@ function deriveTurnFolds(input: { } // Workflows outlive their launching turn (dynamic spawns, background // execution), so a spawn row with a live member stays outside the fold - // instead of hiding a still-running fleet. Settled spawns fold with - // the rest of the turn. - if ( - entry.kind === "work" && - entry.entry.agentSpawn !== undefined && - entry.entry.agentSpawn.agentTaskIds.some((taskId) => input.liveAgentTaskIds?.has(taskId)) - ) { - continue; + // instead of hiding a still-running fleet, as does one the user has + // open. Settled spawns fold with the rest of the turn. Without a live + // set (no agent panel model) every spawn row stays out. + if (entry.kind === "work" && entry.entry.agentSpawn !== undefined) { + const live = input.liveAgentTaskIds; + if ( + live === undefined || + entry.entry.agentSpawn.agentTaskIds.some((taskId) => live.has(taskId)) || + input.expandedSpawnEntryIds?.has(entry.id) + ) { + continue; + } } hiddenEntryIds.add(entry.id); } @@ -860,8 +865,13 @@ export function deriveMessagesTimelineRows(input: { activeTurnStartedAt: string | null; turnDiffSummaries: ReadonlyArray; supportsConversationRollback: boolean; - /** Task ids of subagents still working; their spawn row stays outside turn folds. */ - liveAgentTaskIds?: ReadonlySet; + /** + * Task ids of subagents still working; their spawn row stays outside turn + * folds. Undefined means unknown, which keeps every spawn row out. + */ + liveAgentTaskIds?: ReadonlySet | undefined; + /** Spawn rows the user expanded; they stay outside turn folds too. */ + expandedSpawnEntryIds?: ReadonlySet | undefined; }): MessagesTimelineRow[] { const turnDiffSummaryByAssistantMessageId = new Map(); for (const summary of input.turnDiffSummaries) { @@ -897,6 +907,7 @@ export function deriveMessagesTimelineRows(input: { latestTurn: input.latestTurn ?? null, unfoldedTurnIds: activeVisualResponseTurnIds, liveAgentTaskIds: input.liveAgentTaskIds, + expandedSpawnEntryIds: input.expandedSpawnEntryIds, }); const collapsedEntryIds = new Set(); for (const fold of foldsByAnchorEntryId.values()) { diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index b3538b8b4d12..a30828655cda 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -39,6 +39,7 @@ import { formatSubagentModelLabel, formatSubagentTokenCount, isActiveSubagentStatus, + isTerminalSubagentStatus, } from "@t3tools/client-runtime/state/subagentRuntime"; const EMPTY_AGENT_PANEL_MODEL = emptyAgentPanelModel(); @@ -268,8 +269,10 @@ interface TimelineRowSharedState { onToggleTurnFold: (turnId: TurnId) => void; onToggleWorkGroup: (groupId: string, anchorKey: string) => void; onToggleWorkEntry: (anchorKey: string, collapsed: boolean) => void; + onToggleSpawnRow: (entryId: string, expanded: boolean) => void; workGroupViewState: WorkGroupViewState; agentPanelModel: AgentPanelModel; + expandedSpawnEntryIds: ReadonlySet; onOpenAgents: () => void; } @@ -425,7 +428,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ isPreparingWorktree = false, isCompacting = false, activeTurnStartedAt, - agentPanelModel = EMPTY_AGENT_PANEL_MODEL, + agentPanelModel, onOpenAgents = NOOP_OPEN_AGENTS, listRef, timelineEntries, @@ -462,19 +465,36 @@ export const MessagesTimeline = memo(function MessagesTimeline({ }: MessagesTimelineProps) { const [expandedTurnIds, setExpandedTurnIds] = useState>(new Set()); const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState>(new Set()); + // Expanded spawn rows outlive virtualization and stay outside turn folds + // until the user collapses them, so a settling fleet is not pulled away mid-read. + const [expandedSpawnEntryIds, setExpandedSpawnEntryIds] = useState>( + new Set(), + ); const listIdentityKey = displayThreadKey ?? routeThreadKey; const listIdentityRef = useRef(listIdentityKey); const previousLatestTurnRef = useRef(latestTurn); let paintedExpandedTurnIds = expandedTurnIds; let paintedExpandedWorkGroupIds = expandedWorkGroupIds; + let paintedExpandedSpawnEntryIds = expandedSpawnEntryIds; if (listIdentityRef.current !== listIdentityKey) { listIdentityRef.current = listIdentityKey; previousLatestTurnRef.current = latestTurn; paintedExpandedTurnIds = new Set(); paintedExpandedWorkGroupIds = new Set(); + paintedExpandedSpawnEntryIds = new Set(); setExpandedTurnIds(paintedExpandedTurnIds); setExpandedWorkGroupIds(paintedExpandedWorkGroupIds); + setExpandedSpawnEntryIds(paintedExpandedSpawnEntryIds); } + const onToggleSpawnRow = useCallback((entryId: string, expanded: boolean) => { + setExpandedSpawnEntryIds((current) => { + if (current.has(entryId) === expanded) return current; + const next = new Set(current); + if (expanded) next.add(entryId); + else next.delete(entryId); + return next; + }); + }, []); const citationThreadRef = useMemo(() => parseScopedThreadKey(routeThreadKey), [routeThreadKey]); const openPullRequest = useOpenPrLink(citationThreadRef ?? undefined); const expandCitedTurn = useCallback((turnId: TurnId) => { @@ -608,20 +628,31 @@ export const MessagesTimeline = memo(function MessagesTimeline({ workspaceRoot: string | undefined; projection: MessagesTimelineRowsProjection; } | null>(null); - // Subagents still working keep their spawn row outside the turn fold. - const liveAgentTaskIds = useMemo(() => { - const ids = new Set(); + // Subagents still working keep their spawn row outside the turn fold. Same + // liveness rule as the row header (deriveAgentSpawnSummary): members while + // active, workflow coordinators until terminal. Keyed by content so the + // projection input keeps its identity across unrelated panel updates. + const liveAgentTaskKey = useMemo(() => { + if (agentPanelModel === undefined) return undefined; + const ids: string[] = []; const consider = (agent: { id: string; status: RuntimeSubagent["status"] }) => { - if (isActiveSubagentStatus(agent.status)) ids.add(agent.id); + if (isActiveSubagentStatus(agent.status)) ids.push(agent.id); }; agentPanelModel.directAgents.forEach(consider); for (const group of agentPanelModel.workflows) { - consider(group.workflow); + if (!isTerminalSubagentStatus(group.workflow.status)) ids.push(group.workflow.id); group.unphasedMembers.forEach(consider); group.phases.forEach((phase) => phase.members.forEach(consider)); } - return ids; + return ids.sort().join("\n"); }, [agentPanelModel]); + const liveAgentTaskIds = useMemo( + () => + liveAgentTaskKey === undefined + ? undefined + : new Set(liveAgentTaskKey.length > 0 ? liveAgentTaskKey.split("\n") : []), + [liveAgentTaskKey], + ); const rawRows = useMemo(() => { const previous = rowsProjectionRef.current; const projection = deriveMessagesTimelineRowsWithState( @@ -636,6 +667,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ turnDiffSummaries, supportsConversationRollback, liveAgentTaskIds, + expandedSpawnEntryIds: paintedExpandedSpawnEntryIds, }, previous?.threadKey === listIdentityKey && previous.workspaceRoot === workspaceRoot ? previous.projection @@ -657,6 +689,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ turnDiffSummaries, supportsConversationRollback, liveAgentTaskIds, + paintedExpandedSpawnEntryIds, ]); const rows = useStableRows(rawRows, listIdentityKey); const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]); @@ -844,8 +877,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onToggleTurnFold, onToggleWorkGroup, onToggleWorkEntry: suspendEndScrollMaintenanceForDisclosure, + onToggleSpawnRow, workGroupViewState, - agentPanelModel, + agentPanelModel: agentPanelModel ?? EMPTY_AGENT_PANEL_MODEL, + expandedSpawnEntryIds: paintedExpandedSpawnEntryIds, onOpenAgents, }), [ @@ -869,8 +904,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onToggleTurnFold, onToggleWorkGroup, suspendEndScrollMaintenanceForDisclosure, + onToggleSpawnRow, workGroupViewState, agentPanelModel, + paintedExpandedSpawnEntryIds, onOpenAgents, ], ); @@ -3680,20 +3717,22 @@ const stopRowToggleWhileSelectingText = (e: MouseEvent) => { /** * A batch of subagents as one work row. The header reads like the sibling - * tool rows; expanding lists each member with its outcome, and a member - * click shows its result in the standard tool body box. + * tool rows and is the only interactive part of the row; expanding lists + * each member below it, and a member click shows its result in the + * standard tool body box. */ const AgentSpawnRow = memo(function AgentSpawnRow(props: { workEntry: TimelineWorkEntry; onToggleEntry?: ((collapsed: boolean) => void) | undefined; }) { const { workEntry } = props; - const { agentPanelModel, onOpenAgents } = use(TimelineRowCtx); - const [expanded, setExpanded] = useState(false); + const { agentPanelModel, expandedSpawnEntryIds, onToggleSpawnRow, onOpenAgents } = + use(TimelineRowCtx); const spawn = workEntry.agentSpawn; if (!spawn) { return null; } + const expanded = expandedSpawnEntryIds.has(workEntry.id); const memberIds = new Set(spawn.agentTaskIds); const workflowGroup = spawn.workflowId @@ -3705,7 +3744,6 @@ const AgentSpawnRow = memo(function AgentSpawnRow(props: { const agentCount = Math.max( agents.length, Math.max(memberIds.size - (spawn.workflowId ? 1 : 0), 0), - 1, ); const summary = deriveAgentSpawnSummary({ agents, @@ -3726,32 +3764,32 @@ const AgentSpawnRow = memo(function AgentSpawnRow(props: { live && livePhase ? `${livePhase.title} · ${livePhase.activeCount} working` : summary.status; // The verb already says the batch ran; only surface outcomes that differ. const meta = [ - status === "✓ completed" ? null : status, + summary.tone === "completed" ? null : status, totalTokens > 0 ? `${formatSubagentTokenCount(totalTokens)} tok` : null, ] .filter(Boolean) .join(" · "); const toggleExpanded = () => { props.onToggleEntry?.(expanded); - setExpanded((value) => !value); + onToggleSpawnRow(workEntry.id, !expanded); }; return ( -
{ - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - toggleExpanded(); - } - }} - className="flex cursor-pointer flex-col rounded-md px-0.5 py-0.5 transition-colors hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70" - > -
+
+
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + toggleExpanded(); + } + }} + className="flex cursor-pointer select-none items-center gap-1.5 rounded-md px-0.5 py-0.5 transition-colors hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70" + > {live ? ( @@ -3787,13 +3825,9 @@ const AgentSpawnRow = memo(function AgentSpawnRow(props: {
{expanded ? ( -
+
{agents.map((agent) => ( - + ))} {expanded ? (
{agents.map((agent) => ( diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 2093d402ea25..2dbcaeeb7929 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -2127,19 +2127,19 @@ describe("deriveActiveWorkStartedAt", () => { describe("deriveWorkLogEntries quiet-timeline guarantee", () => { it("concurrent subagents replace their launch tools with one lifecycle row", () => { const activities: OrchestrationThreadActivity[] = []; - for (const [index, toolName] of ["Agent", "Task"].entries()) { + for (let agent = 0; agent < 5; agent += 1) { activities.push( makeActivity({ kind: "tool.updated", summary: "Subagent task", payload: { - toolCallId: `launch-${index}`, + toolCallId: `launch-${agent}`, itemType: "collab_agent_tool_call", status: "inProgress", - data: { toolName }, + data: { toolName: agent % 2 === 0 ? "Agent" : "Task" }, }, turnId: "turn-batch", - sequence: index - 5, + sequence: agent - 10, }), ); expect(deriveWorkLogEntries(activities)).toHaveLength(0); @@ -2147,20 +2147,6 @@ describe("deriveWorkLogEntries quiet-timeline guarantee", () => { for (let agent = 0; agent < 5; agent += 1) { const taskId = `task-${agent}`; const toolUseId = `launch-${agent}`; - activities.push( - makeActivity({ - kind: "tool.updated", - summary: "Subagent task", - payload: { - toolCallId: toolUseId, - itemType: "collab_agent_tool_call", - status: "inProgress", - data: { toolName: "Agent" }, - }, - turnId: "turn-batch", - sequence: agent * 20 - 2, - }), - ); expect(deriveWorkLogEntries(activities)).toHaveLength(agent === 0 ? 0 : 1); activities.push( makeActivity({ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index a15e0ab3e60c..6a0920681bc3 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -101,7 +101,7 @@ interface DerivedWorkLogEntry extends WorkLogEntry { [workLogCollapseKey]?: string; toolCallId?: string; isWorkflowCoordinator?: boolean; - /** Shell/monitor/plan tasks: ordinary work-log rows, never spawn rows. */ + /** Shell/monitor/plan tasks: ordinary work-log rows, never spawn CTAs. */ isBackgroundTask?: boolean; } @@ -171,7 +171,7 @@ export function workEntrySignalsSevereFailure(entry: WorkLogEntry): boolean { /** Tool-like row with neither clear success nor failure (empty, incomplete, in progress, etc.). */ export function workEntryIndicatesToolNeutralStatus(entry: WorkLogEntry): boolean { - // Spawn rows are never neutral-hidden: mid-run they derive from + // Spawn CTA rows are never neutral-hidden: mid-run they derive from // task.progress (tone "thinking") and the neutral filter was swallowing // them exactly while the fleet ran — the one moment they matter most. if (entry.agentSpawn !== undefined) { @@ -397,7 +397,7 @@ export function hasActionableProposedPlan( * Unattributed rows stay unless a linked agent row replaces their launch; * failed launches stay so the only terminal signal cannot disappear. */ -/** Agent (non-background) task.started rows seed spawn batches. */ +/** Agent (non-background) task.started rows seed spawn CTA batches. */ function isAgentTaskStartedActivity(activity: OrchestrationThreadActivity): boolean { const payload = activity.payload && typeof activity.payload === "object" @@ -427,7 +427,7 @@ function isAgentInternalActivity(activity: OrchestrationThreadActivity): boolean // (agentId + "agent") stays visible so its rows can anchor a spawn row // (review finding: hiding on agentId alone removed nested agents and // their anchors). Bypassed agent lifecycle rows also pass — collapse - // folds every such row into its batch's single spawn row, which is how + // folds every such row into its batch's single CTA row, which is how // Codex children (whose rows are ALL bypassed) get an anchor at the // spawn point. if (isTaskRow) { @@ -460,8 +460,7 @@ export function deriveWorkLogEntries( (activity.kind === "task.started" || activity.kind === "task.progress" || activity.kind === "task.completed") && - isAgentTaskStartedActivity(activity) && - !isAgentInternalActivity(activity) + isAgentTaskStartedActivity(activity) ) { const toolUseId = asTrimmedString(asRecord(activity.payload)?.toolUseId); if (toolUseId) agentLaunchToolIds.add(toolUseId); @@ -471,10 +470,10 @@ export function deriveWorkLogEntries( for (const activity of foldUserInputActivities(ordered)) { if (activity.tone !== "error" && isWorktreeSetupActivity(activity.kind)) continue; if (activity.kind === "tool.started") continue; - // Agent task.started rows are spawn seeds: they carry the true spawn turn, + // Agent task.started rows are CTA seeds: they carry the true spawn turn, // which is the batch key (completions of background subagents arrive // under later synthetic turns and must not start new batches). They - // collapse into the batch's single spawn row, never render standalone. + // collapse into the batch's single CTA row, never render standalone. if (activity.kind === "task.started" && !isAgentTaskStartedActivity(activity)) continue; if (activity.kind === "task.updated") continue; if (activity.kind === "tool.progress") continue; @@ -678,7 +677,7 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo /** * Spawn-group key for a subagent lifecycle row. Workflow members and their * coordinator share the coordinator's group; direct spawns batch per turn. - * One row per group: "Kicked off N subagents". + * One CTA row per group (A1 design): "Kicked off N subagents". */ function agentSpawnGroupKey(entry: DerivedWorkLogEntry): string { const taskId = entry.taskId ?? ""; @@ -694,7 +693,7 @@ function agentSpawnGroupKey(entry: DerivedWorkLogEntry): string { } // No turn id means no batch signal at all: fall back to one group per // task. Unrelated turn-less spawns (separate fleets whose rows lost their - // turn) must not collapse into one immortal "direct:no-turn" spawn + // turn) must not collapse into one immortal "direct:no-turn" CTA // accumulating every agent the thread ever ran (review finding). Adapters // stamp spawn turns (Codex spawnTurnId; Claude rows ride real turns), so // this path is defensive. @@ -750,7 +749,7 @@ function collapseDerivedWorkLogEntries( : [...(existing.agentSpawn?.agentTaskIds ?? []), entry.taskId]; collapsed[existingIndex] = { ...mergeDerivedWorkLogEntries(existing, entry), - // The spawn row keeps the group's ANCHOR identity, not the last + // The CTA row keeps the group's ANCHOR identity, not the last // agent's: id/createdAt/turnId stay pinned to the spawn point so // the row renders where the run launched instead of drifting to // the newest progress tick (mid-run it drifted below the whole