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 c677e6fff65e..f5a26ad9b021 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1122,6 +1122,141 @@ describe("deriveMessagesTimelineRows", () => { ]); }); + it("folds a settled subagent spawn row and keeps a live one outside the fold", () => { + const firstMessage: ChatMessage = { + id: MessageId.make("assistant-first-entry"), + role: "assistant", + text: "Fanning out.", + turnId: TurnId.make("turn-1"), + createdAt: "2026-01-01T00:00:01Z", + updatedAt: "2026-01-01T00:00:02Z", + streaming: false, + }; + const entriesWith = (agentSpawn: { workflowId: string | null; agentTaskIds: string[] }) => + deriveTimelineEntries( + [ + firstMessage, + { + ...firstMessage, + id: MessageId.make("assistant-final-entry"), + text: "Done.", + createdAt: "2026-01-01T00:00:05Z", + updatedAt: "2026-01-01T00:00:06Z", + }, + ], + [], + [ + { + id: "spawn-entry", + createdAt: "2026-01-01T00:00:03Z", + turnId: firstMessage.turnId, + label: "Ran 2 subagents", + tone: "tool", + agentSpawn, + }, + ], + ); + 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, + expandedTurnIds?: ReadonlySet, + ) => + deriveMessagesTimelineRows({ + timelineEntries, + isWorking: false, + activeTurnStartedAt: null, + turnDiffSummaries: [], + supportsConversationRollback: false, + liveAgentTaskIds, + expandedSpawnEntryIds, + ...(expandedTurnIds ? { expandedTurnIds } : {}), + }).map((row) => row.id); + const folded = ["turn-fold:turn-1", "assistant-final-entry"]; + const unfolded = ["turn-fold:turn-1", "spawn-entry", "assistant-final-entry"]; + + const activeRows = ( + timelineEntries: typeof direct, + liveAgentTaskIds: ReadonlySet, + runningTurnId = "turn-1", + ) => + deriveMessagesTimelineRows({ + timelineEntries: timelineEntries.slice(0, 2), + isWorking: true, + runningTurnId: runningTurnId as TurnId, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaries: [], + supportsConversationRollback: false, + liveAgentTaskIds, + }).map((row) => row.kind); + expect(activeRows(direct, new Set(["agent-b"]))).not.toContain("thinking"); + expect(activeRows(workflow, new Set(["wf-1"]))).not.toContain("thinking"); + expect(activeRows(direct, new Set())).toContain("thinking"); + expect(activeRows(direct, new Set(["agent-b"]), "turn-2")).toContain("thinking"); + + for (const [toolLifecycleStatus, tone, sourceActivityKind] of [ + ["inProgress", "tool", "tool.updated"], + ["completed", "tool", "tool.completed"], + // Claude background Bash completions arrive without a command or item type. + ["completed", "info", "task.completed"], + ] as const) { + const laterTool = { + id: "later-tool", + kind: "work" as const, + createdAt: "2026-01-01T00:00:04Z", + entry: { + id: "later-tool", + turnId: "turn-1" as TurnId, + createdAt: "2026-01-01T00:00:04Z", + label: "Read file", + tone, + sourceActivityKind, + toolLifecycleStatus, + }, + }; + for (const trailingEntries of [[], [laterTool]]) { + const rows = deriveMessagesTimelineRows({ + timelineEntries: [...direct.slice(0, 2), ...trailingEntries], + isWorking: true, + runningTurnId: "turn-1" as TurnId, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaries: [], + supportsConversationRollback: false, + liveAgentTaskIds: new Set(["agent-b"]), + }); + expect(rows.filter((row) => row.kind === "work-live")).toMatchObject([ + { + id: "live-activity-row", + entry: { id: trailingEntries.length ? "later-tool" : "spawn-entry" }, + active: true, + }, + ]); + expect(rows.some((row) => row.kind === "work" || row.kind === "thinking")).toBe(false); + } + } + + 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); + // No live set is known. + expect(derive(direct, undefined)).toEqual(unfolded); + // The user has it open: it stays visible under the collapsed fold and + // keeps its place when the fold is expanded. + expect(derive(direct, new Set(), new Set(["spawn-entry"]))).toEqual(unfolded); + expect( + derive(direct, new Set(), new Set(["spawn-entry"]), new Set(["turn-1" as TurnId])), + ).toEqual([ + "turn-fold:turn-1", + "assistant-first-entry", + "spawn-entry", + "assistant-final-entry", + ]); + }); + it("only enables assistant copy for the terminal assistant message in a turn", () => { const rows = deriveMessagesTimelineRows({ timelineEntries: [ @@ -2286,7 +2421,7 @@ describe("deriveMessagesTimelineRows", () => { it.each([ [undefined, true], ["inProgress", true], - ["completed", false], + ["completed", true], ["failed", null], ["declined", false], ["stopped", false], @@ -2329,6 +2464,7 @@ describe("deriveMessagesTimelineRows", () => { expect(rows.at(-1)).toMatchObject({ kind: "thinking", id: "live-activity-row" }); } else { expect(workLiveRow).toMatchObject({ active }); + if (active) expect(rows.some((row) => row.kind === "thinking")).toBe(false); } }, ); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index fc3ffdb98a90..4630aedec1d4 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; @@ -661,11 +662,22 @@ function deriveTurnFolds(input: { if (entry.kind === "work" && entry.entry.questionAnswer !== undefined) { 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. + // Workflows outlive their launching turn (dynamic spawns, background + // execution), so a spawn row with a live member or coordinator stays + // outside the fold instead of hiding a still-running fleet. Settled + // spawns fold with the rest of the turn. Without a live set (no agent + // panel model, as in the held paint during a thread switch) every + // spawn row stays out. if (entry.kind === "work" && entry.entry.agentSpawn !== undefined) { - continue; + const live = input.liveAgentTaskIds; + const { workflowId, agentTaskIds } = entry.entry.agentSpawn; + if ( + live === undefined || + (workflowId !== null && live.has(workflowId)) || + agentTaskIds.some((taskId) => live.has(taskId)) + ) { + continue; + } } hiddenEntryIds.add(entry.id); } @@ -858,6 +870,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. Undefined means unknown, which keeps every spawn row out. + */ + liveAgentTaskIds?: ReadonlySet | undefined; + /** Spawn rows the user opened stay visible while their turn fold is collapsed. */ + expandedSpawnEntryIds?: ReadonlySet | undefined; }): MessagesTimelineRow[] { const turnDiffSummaryByAssistantMessageId = new Map(); for (const summary of input.turnDiffSummaries) { @@ -892,11 +911,15 @@ export function deriveMessagesTimelineRows(input: { terminalAssistantMessageIds, latestTurn: input.latestTurn ?? null, unfoldedTurnIds: activeVisualResponseTurnIds, + liveAgentTaskIds: input.liveAgentTaskIds, }); const collapsedEntryIds = new Set(); for (const fold of foldsByAnchorEntryId.values()) { if (!input.expandedTurnIds?.has(fold.turnId)) { for (const entryId of fold.hiddenEntryIds) { + // An opened spawn row keeps its fold membership but is not pulled + // away mid-read when its last member settles. + if (input.expandedSpawnEntryIds?.has(entryId)) continue; collapsedEntryIds.add(entryId); } } @@ -922,7 +945,6 @@ export function deriveMessagesTimelineRows(input: { if ( !entryBelongsToActiveTurn(entry, index) || entry.kind !== "work" || - entry.entry.agentSpawn !== undefined || entry.entry.questionAnswer !== undefined || entry.entry.sourceActivityKind === "context-compaction" || entry.entry.tone === "error" @@ -937,9 +959,14 @@ export function deriveMessagesTimelineRows(input: { ); const activeWorkAnchor = activeToolEntries[0]; const latestVisibleToolEntry = visibleActiveToolEntries.at(-1); - const latestRunningToolEntry = visibleActiveToolEntries.findLast((entry) => - workEntryIsActiveTurnActivity(entry.entry), - ); + const latestRunningToolEntry = visibleActiveToolEntries.findLast((entry) => { + const spawn = entry.entry.agentSpawn; + return spawn + ? entry === latestVisibleToolEntry && + ((spawn.workflowId !== null && input.liveAgentTaskIds?.has(spawn.workflowId)) || + spawn.agentTaskIds.some((taskId) => input.liveAgentTaskIds?.has(taskId))) + : workEntryIsActiveTurnActivity(entry.entry); + }); const latestToolFailed = latestRunningToolEntry === undefined && latestVisibleToolEntry !== undefined && @@ -948,7 +975,10 @@ export function deriveMessagesTimelineRows(input: { const latestToolKeepsActivityLive = latestRunningToolEntry !== undefined || (latestVisibleToolEntry !== undefined && - workEntryIndicatesToolSuccess(latestVisibleToolEntry.entry)); + latestVisibleToolEntry.entry.agentSpawn === undefined && + (workEntryIndicatesToolSuccess(latestVisibleToolEntry.entry) || + (latestVisibleToolEntry.entry.toolLifecycleStatus === "completed" && + !workEntryDisplayIndicatesToolFailure(latestVisibleToolEntry.entry)))); const activeWorkPlacementEntryId = latestVisibleToolEntry?.id; const activeWorkRow = activeWorkAnchor && latestVisibleToolEntry && !latestToolFailed @@ -990,7 +1020,7 @@ export function deriveMessagesTimelineRows(input: { if (activeWorkRow === null) return; nextRows.push(activeWorkRow); hasActivityRow ||= activeWorkRow.active; - if (!activeWorkRow.expanded) return; + if (!activeWorkRow.expanded || activeWorkRow.entry.agentSpawn) return; nextRows.push( expandedWorkGroupRow( activeWorkRow.groupId, @@ -1053,6 +1083,12 @@ export function deriveMessagesTimelineRows(input: { timelineEntry.entry.questionAnswer !== undefined || timelineEntry.entry.tone === "error" ) { + const spawn = timelineEntry.entry.agentSpawn; + if (spawn && entryBelongsToActiveTurn(timelineEntry, index)) { + hasActivityRow ||= + (spawn.workflowId !== null && input.liveAgentTaskIds?.has(spawn.workflowId)) || + spawn.agentTaskIds.some((taskId) => input.liveAgentTaskIds?.has(taskId)); + } nextRows.push({ kind: "work", id: timelineEntry.id, diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index da0fc52059e7..9994f032fa3a 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -29,11 +29,17 @@ 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 { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; import { emptyAgentPanelModel, + formatSubagentModelLabel, formatSubagentTokenCount, + isActiveSubagentStatus, + isTerminalSubagentStatus, } from "@t3tools/client-runtime/state/subagentRuntime"; const EMPTY_AGENT_PANEL_MODEL = emptyAgentPanelModel(); @@ -263,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; } @@ -420,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, @@ -457,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 visible while their + // turn fold is collapsed, 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) => { @@ -603,6 +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. 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.push(agent.id); + }; + agentPanelModel.directAgents.forEach(consider); + for (const group of agentPanelModel.workflows) { + if (!isTerminalSubagentStatus(group.workflow.status)) ids.push(group.workflow.id); + group.unphasedMembers.forEach(consider); + group.phases.forEach((phase) => phase.members.forEach(consider)); + } + 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( @@ -616,6 +666,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ activeTurnStartedAt, turnDiffSummaries, supportsConversationRollback, + liveAgentTaskIds, + expandedSpawnEntryIds: paintedExpandedSpawnEntryIds, }, previous?.threadKey === listIdentityKey && previous.workspaceRoot === workspaceRoot ? previous.projection @@ -636,6 +688,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ activeTurnStartedAt, turnDiffSummaries, supportsConversationRollback, + liveAgentTaskIds, + paintedExpandedSpawnEntryIds, ]); const rows = useStableRows(rawRows, listIdentityKey); const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]); @@ -823,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, }), [ @@ -848,8 +904,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onToggleTurnFold, onToggleWorkGroup, suspendEndScrollMaintenanceForDisclosure, + onToggleSpawnRow, workGroupViewState, agentPanelModel, + paintedExpandedSpawnEntryIds, onOpenAgents, ], ); @@ -2334,6 +2392,15 @@ function LiveActivityContent({ function LiveWorkEntryTimelineRow({ row }: { row: Extract }) { const ctx = use(TimelineRowCtx); + if (row.entry.agentSpawn) { + return ( + ctx.onToggleWorkEntry(row.id, collapsed)} + /> + ); + } const label = liveWorkEntryLabel(row.entry, ctx.workspaceRoot, row.active); const failed = workEntryDisplayIndicatesToolFailure(row.entry); @@ -3657,20 +3724,20 @@ 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. - */ -const AgentSpawnCtaRow = memo(function AgentSpawnCtaRow(props: { workEntry: TimelineWorkEntry }) { +/** One tool row per batch, with member results available on expansion. */ +const AgentSpawnRow = memo(function AgentSpawnRow(props: { + workEntry: TimelineWorkEntry; + active?: boolean | undefined; + onToggleEntry?: ((collapsed: boolean) => void) | undefined; +}) { const { workEntry } = props; - const { agentPanelModel, onOpenAgents } = use(TimelineRowCtx); + 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 @@ -3683,55 +3750,168 @@ const AgentSpawnCtaRow = memo(function AgentSpawnCtaRow(props: { workEntry: Time agents.length, Math.max(memberIds.size - (spawn.workflowId ? 1 : 0), 0), ); - const summary = deriveAgentSpawnSummary({ agents, agentCount, coordinatorStatus: workflowGroup?.workflow.status, }); const { live, lead } = summary; - // Same rule as the panel footer: providers may aggregate member usage into - // 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, - ); - - const livePhase = workflowGroup?.phases.find((phase) => phase.state === "running"); + const failed = summary.tone === "failed"; const workflowName = workflowGroup?.workflow.workflowName ?? workflowGroup?.workflow.title ?? null; + const toggleExpanded = () => { + props.onToggleEntry?.(expanded); + onToggleSpawnRow(workEntry.id, !expanded); + }; + + return ( +
+ + {expanded ? ( +
+ {agents.map((agent) => ( + + ))} + +
+ ) : 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; +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, + onToggleEntry, +}: { + agent: RuntimeSubagent; + onToggleEntry?: ((collapsed: boolean) => void) | undefined; +}) { + 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 && durationMs >= 0 ? formatDuration(durationMs) : null, + agent.usage && agent.usage.totalTokens > 0 + ? `${formatSubagentTokenCount(agent.usage.totalTokens)} tok` + : null, + ] + .filter(Boolean) + .join(" · "); + // Settled members show their metrics; anything other than success keeps + // the status word so the outcome remains explicit. + const statusLabel = + activeStatus || !meta + ? AGENT_MEMBER_STATUS_LABEL[agent.status] + : agent.status === "completed" + ? meta + : `${AGENT_MEMBER_STATUS_LABEL[agent.status]} · ${meta}`; + 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 body = [activity?.trim() || null, formatSubagentModelLabel(agent.model, agent.effort)] + .filter(Boolean) + .join("\n\n"); + const canExpand = body.length > 0; + const toggleOpen = () => { + onToggleEntry?.(open); + setOpen((value) => !value); + }; return ( - +
+

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

+ + {statusLabel} + +
+ {!open && firstLine ? ( +

{firstLine}

+ ) : null} + {open ? ( +
+
{body}
+
+ ) : null} + ); -}); +} const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { workEntry: TimelineWorkEntry; @@ -3741,9 +3921,15 @@ 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 ( { }); describe("deriveWorkLogEntries quiet-timeline guarantee", () => { - it("N concurrent subagents produce exactly N lifecycle rows, zero attributed tool rows", () => { + it("concurrent subagents replace their launch tools with one lifecycle row", () => { const activities: OrchestrationThreadActivity[] = []; + for (let agent = 0; agent < 5; agent += 1) { + activities.push( + makeActivity({ + kind: "tool.updated", + summary: "Subagent task", + payload: { + toolCallId: `launch-${agent}`, + itemType: "collab_agent_tool_call", + status: "inProgress", + data: { toolName: agent % 2 === 0 ? "Agent" : "Task" }, + }, + turnId: "turn-batch", + sequence: agent - 10, + }), + ); + expect(deriveWorkLogEntries(activities)).toHaveLength(0); + } for (let agent = 0; agent < 5; agent += 1) { const taskId = `task-${agent}`; + const toolUseId = `launch-${agent}`; + expect(deriveWorkLogEntries(activities)).toHaveLength(agent === 0 ? 0 : 1); + activities.push( + makeActivity({ + id: `started-${agent}`, + kind: "task.started", + summary: "Task started", + payload: { taskId, toolUseId, taskType: "local_agent" }, + turnId: "turn-batch", + sequence: agent * 20 - 1, + }), + ); + const runningEntries = deriveWorkLogEntries(activities); + expect(runningEntries).toHaveLength(1); + expect(runningEntries[0]!.id).toBe("started-0"); + expect(runningEntries[0]!.agentSpawn?.agentTaskIds).toHaveLength(agent + 1); // Progress ticks (several per agent) + attributed tool rows. for (let tick = 0; tick < 4; tick += 1) { activities.push( @@ -2136,7 +2169,7 @@ describe("deriveWorkLogEntries quiet-timeline guarantee", () => { kind: "task.progress", summary: `agent ${agent} tick ${tick}`, tone: "info", - payload: { taskId, summary: `working ${tick}`, role: "explorer" }, + payload: { taskId, toolUseId, summary: `working ${tick}`, role: "explorer" }, turnId: "turn-batch", sequence: agent * 20 + tick, }), @@ -2157,6 +2190,7 @@ describe("deriveWorkLogEntries quiet-timeline guarantee", () => { tone: "info", payload: { taskId, + toolUseId, status: "completed", summary: `agent ${agent} done`, role: "explorer", @@ -2164,6 +2198,13 @@ describe("deriveWorkLogEntries quiet-timeline guarantee", () => { turnId: "turn-batch", sequence: agent * 20 + 19, }), + makeActivity({ + kind: "tool.completed", + summary: "Subagent task", + payload: { toolCallId: toolUseId, status: "completed" }, + turnId: "turn-batch", + sequence: agent * 20 + 19, + }), ); } @@ -2210,15 +2251,69 @@ describe("deriveWorkLogEntries quiet-timeline guarantee", () => { ); }); - it("keeps unattributed tool rows (over-hiding loses the only signal)", () => { + it("keeps unrelated tools and failed launches, including failures after a task starts", () => { const entries = deriveWorkLogEntries([ makeActivity({ kind: "tool.completed", summary: "Bash", payload: { itemType: "command_execution", command: "ls" }, }), + makeActivity({ + id: "unlinked-failure", + kind: "tool.completed", + summary: "Subagent task", + tone: "error", + payload: { toolCallId: "unlinked", status: "failed" }, + }), + makeActivity({ + id: "linked-task", + kind: "task.started", + summary: "Task started", + payload: { taskId: "agent", toolUseId: "linked", taskType: "local_agent" }, + }), + makeActivity({ + id: "linked-failure", + kind: "tool.completed", + summary: "Subagent task", + payload: { toolCallId: "linked", status: "failed" }, + }), + makeActivity({ + id: "orphan-completion", + kind: "tool.completed", + summary: "Subagent task", + payload: { + toolCallId: "orphan", + itemType: "collab_agent_tool_call", + status: "completed", + data: { toolName: "Agent" }, + }, + }), + makeActivity({ + id: "send-input", + kind: "tool.updated", + payload: { + toolCallId: "send-input", + itemType: "collab_agent_tool_call", + status: "inProgress", + data: { toolName: "send_input" }, + }, + }), + makeActivity({ + id: "active-launch-error", + kind: "tool.updated", + tone: "error", + payload: { + toolCallId: "active-launch-error", + itemType: "collab_agent_tool_call", + status: "inProgress", + data: { toolName: "Task" }, + }, + }), ]); - expect(entries).toHaveLength(1); + expect(entries).toHaveLength(7); + expect(entries.map((entry) => entry.id)).toEqual( + expect.arrayContaining(["unlinked-failure", "linked-task", "linked-failure"]), + ); }); it("folds timelineBypass agent rows into one CTA (Codex children, workflow members)", () => { diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index d3d81dde9c45..6a0920681bc3 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -83,10 +83,9 @@ export interface WorkLogEntry { /** Agent role (subagent_type) for labeled timeline rows. */ agentRole?: string; /** - * Present on agent-spawn CTA rows: one per workflow run or per-turn batch - * of direct spawns. The row renders as a call-to-action ("Kicked off N - * subagents") whose live status is derived from the agent panel model at - * render time; clicking opens the Agents panel. + * Present on agent-spawn rows: one per workflow run or per-turn batch of + * direct spawns. The row ("Kicked off N subagents") derives its live + * status and member list from the agent panel model at render time. */ agentSpawn?: { /** Workflow coordinator taskId, or null for a direct-spawn batch. */ @@ -395,7 +394,8 @@ export function hasActionableProposedPlan( * - tool rows attributed to an owning agent (payload.agentId) are re-homed; * - task.progress ticks collapse into one row per taskId; * - task.updated is fold input only (status patches are not narrative). - * Unattributed rows always stay: over-hiding loses the only terminal signal. + * 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 CTA batches. */ function isAgentTaskStartedActivity(activity: OrchestrationThreadActivity): boolean { @@ -424,7 +424,7 @@ function isAgentInternalActivity(activity: OrchestrationThreadActivity): boolean activity.kind === "task.completed"; // Task rows classify by the server stamp: a subagent's own background // shell (agentId + "background") is agent-internal, but a nested AGENT - // (agentId + "agent") stays visible so its rows can anchor a spawn CTA + // (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 CTA row, which is how @@ -452,6 +452,20 @@ export function deriveWorkLogEntries( activities: ReadonlyArray, ): WorkLogEntry[] { const ordered = [...activities].toSorted(compareActivitiesByOrder); + // A launch tool and its task lifecycle describe the same run. Only hide + // launch rows once their tool-use id has an agent row to replace them. + const agentLaunchToolIds = new Set(); + for (const activity of ordered) { + if ( + (activity.kind === "task.started" || + activity.kind === "task.progress" || + activity.kind === "task.completed") && + isAgentTaskStartedActivity(activity) + ) { + const toolUseId = asTrimmedString(asRecord(activity.payload)?.toolUseId); + if (toolUseId) agentLaunchToolIds.add(toolUseId); + } + } const entries: DerivedWorkLogEntry[] = []; for (const activity of foldUserInputActivities(ordered)) { if (activity.tone !== "error" && isWorktreeSetupActivity(activity.kind)) continue; @@ -469,7 +483,28 @@ export function deriveWorkLogEntries( if (isNoContentRuntimeWarning(activity)) continue; if (isPlanBoundaryToolActivity(activity)) continue; if (isAgentInternalActivity(activity)) continue; - entries.push(toDerivedWorkLogEntry(activity)); + const entry = toDerivedWorkLogEntry(activity); + // Native agent launches get their visible row from task.started. Defer + // their active tool row so another launch cannot duplicate the batch. + if ( + activity.kind === "tool.updated" && + entry.itemType === "collab_agent_tool_call" && + entry.toolLifecycleStatus === "inProgress" && + entry.tone !== "error" + ) { + const toolName = asRecord(asRecord(activity.payload)?.data)?.toolName; + if (toolName === "Agent" || toolName === "Task") continue; + } + if ( + (activity.kind === "tool.updated" || activity.kind === "tool.completed") && + entry.toolCallId && + agentLaunchToolIds.has(entry.toolCallId) && + entry.tone !== "error" && + entry.toolLifecycleStatus !== "failed" + ) { + continue; + } + entries.push(entry); } return collapseDerivedWorkLogEntries(entries); } @@ -681,7 +716,7 @@ function collapseDerivedWorkLogEntries( const collapsed: DerivedWorkLogEntry[] = []; // Subagent rows collapse by spawn group, not adjacency: a workflow run (or // a turn's batch of direct spawns) is ONE narrative event in the chat — a - // CTA row that opens the Agents panel — no matter how many agents it + // spawn row in the timeline — no matter how many agents it // contains or how their progress rows interleave (quiet-timeline // guarantee). const spawnRowIndex = new Map();