diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index b57758b50c12..9b09ef6d903d 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -153,6 +153,7 @@ import { } from "./thread-feed-live-follow"; import { collapsedWorkLogHeight, + ThreadAgentSpawnCard, ThreadDisclosureChevron, ThreadWorkGroupToggle, ThreadThinkingRow, @@ -504,12 +505,7 @@ function MessageAttachmentFile(props: { function MessageAttachmentUnknown(props: { readonly name: string }) { return ( - + {props.name} @@ -1357,7 +1353,7 @@ function renderFeedEntry( accessibilityState={{ expanded: entry.expanded }} onPress={() => props.onToggleTurnFold(entry.turnId)} hitSlop={4} - className="mb-1 min-h-11 flex-row items-center gap-2 border-b border-border px-2" + className="mb-1 min-h-11 flex-row items-center gap-2 border-b border-adaptive-neutral-200-a80-white-a8 px-2" style={{ minHeight: Math.max(TURN_FOLD_HEIGHT - 3.5, props.workRowSizing.estimatedRowHeight), }} @@ -1382,6 +1378,19 @@ function renderFeedEntry( return ; } + if (entry.type === "agent-spawn") { + return ( + props.onToggleWorkGroup(entry.id, entry.id)} + onCopy={() => props.onCopyWorkRow(entry.activity.id, entry.activity.getCopyText())} + /> + ); + } + if (entry.type === "work-toggle") { return ( - + {label} - + ); } @@ -1508,7 +1517,7 @@ function renderFeedEntry( })} - + {timestampLabel} {message.text.trim().length > 0 ? ( @@ -1557,7 +1566,7 @@ function renderFeedEntry( attachmentId={attachment.id} name={attachment.name} mimeType={attachment.mimeType} - className="mt-1.5 aspect-[1.3] w-full rounded-[18px] bg-subtle-strong" + className="mt-1.5 aspect-[1.3] w-full rounded-[18px] bg-adaptive-neutral-200-800" onPressPreview={props.onPressPreview} /> ) : isFileAttachment(attachment) ? ( @@ -1581,7 +1590,7 @@ function renderFeedEntry( buttonSize={28} iconSize={13} /> - + {timestampLabel} diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index 9d5b40fce6dc..e812e77ef088 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -34,7 +34,11 @@ import { AppText as Text } from "../../components/AppText"; import { T3Wordmark } from "../../components/T3Wordmark"; import { cn } from "../../lib/cn"; import { THREAD_WORK_ROW_MIN_HEIGHT, type deriveThreadWorkLogSizing } from "../../lib/layout"; -import { type ThreadFeedActivity, workEntryRowLabel } from "../../lib/threadActivity"; +import { + type AgentSpawnSummary, + type ThreadFeedActivity, + workEntryRowLabel, +} from "../../lib/threadActivity"; import { resolveThreadWorkGroupInitialScroll, shouldFollowThreadWorkGroupAppend, @@ -135,6 +139,7 @@ export function ThreadDisclosureChevron(props: { } function ShimmerWorkContent(props: { + readonly compact?: boolean; readonly environmentId?: EnvironmentId; readonly highlighted: boolean; readonly icon: WorkContentIcon; @@ -147,26 +152,29 @@ function ShimmerWorkContent(props: { }) { return ( - - {props.showIcon && props.toolIcon && props.environmentId ? ( - - ) : props.showIcon ? ( - - ) : null} - + {props.showIcon ? ( + + {props.toolIcon && props.environmentId ? ( + + ) : ( + + )} + + ) : null} { const subscription = AppState.addEventListener("change", (state) => { @@ -250,6 +263,7 @@ export function ShimmeringWorkContent(props: { onLayout={(event) => setAvailableWidth(event.nativeEvent.layout.width)} > @@ -832,7 +847,7 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( entering={WORK_LOG_DETAIL_ENTER_TRANSITION} exiting={WORK_LOG_DETAIL_EXIT_TRANSITION} layout={WORK_LOG_LAYOUT_TRANSITION} - className="ml-7 border-l border-border pb-1 pl-3 pt-0.5" + className="ml-7 border-l border-adaptive-neutral-300-a60-white-a12 pb-1 pl-3 pt-0.5" > {viewedImagePath ? ( @@ -938,6 +953,140 @@ export function ThreadWorkGroupToggle(props: { ); } +const AGENT_SPAWN_TONE_DOT_CLASS = { + working: "bg-adaptive-sky-600-400", + completed: "bg-adaptive-emerald-600-400", + failed: "bg-adaptive-rose-600-400", + stopped: "bg-foreground-muted", +} as const satisfies Record; + +/** + * A batch of spawned subagents. The status line updates in place as members + * report progress; expanding lists each member. Text nodes carry keys tied to + * the row identity only, so a progress tick re-renders the labels without + * remounting the card (see the batch key in appendActivityGroupRows). + */ +export const ThreadAgentSpawnCard = memo(function ThreadAgentSpawnCard(props: { + readonly summary: AgentSpawnSummary; + readonly expanded: boolean; + readonly iconSubtleColor: ColorValue; + readonly rowSizing: ReturnType; + readonly onToggle: () => void; + readonly onCopy: () => void; +}) { + const { summary, expanded } = props; + const working = summary.tone === "working"; + const memberCount = summary.members.length; + const canExpand = memberCount > 0; + return ( + + { + if (!canExpand) return; + void Haptics.selectionAsync(); + props.onToggle(); + }} + onLongPress={props.onCopy} + className="rounded-xl border border-adaptive-neutral-200-a80-white-a8 bg-card px-2.5 py-2 active:bg-subtle" + > + + + + + + + {summary.title} + + + + {working ? ( + + ) : ( + + {summary.status} + + )} + + + {canExpand ? ( + + ) : null} + + {expanded && canExpand ? ( + + {summary.members.map((member) => ( + + + + + {member.title} + + {member.status} + + {member.detail ? ( + + {member.detail} + + ) : null} + + ))} + + ) : null} + + + ); +}); + export function ThreadThinkingRow(props: { readonly rowSizing: ReturnType; readonly iconSubtleColor: ColorValue; diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 6131dc9a1b31..7d6cc39ea616 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -13,6 +13,7 @@ import { } from "@t3tools/contracts"; import { + agentSpawnSummary, buildPendingUserInputAnswers, buildThreadFeed, derivePendingApprovals, @@ -24,6 +25,7 @@ import { workEntryRowLabel, type ThreadFeedActivity, type ThreadFeedEntry, + type WorkLogEntry, } from "./threadActivity"; describe("Codex feedback pseudo-messages", () => { @@ -2303,10 +2305,12 @@ describe("buildThreadFeed", () => { new Set(), latestTurn.startedAt, ); + // The shimmering row is the turn's live slot; once it stops shimmering + // the slot belongs to "Thinking" and the group keeps its own identity. expect(rows.slice(0, 3).map((entry) => [entry.id, entry.type])).toEqual([ ["work-toggle:work-group:activity-1", "work-toggle"], ["activity-2", "activity-group"], - ["work-live:work-group:activity-3", "work-toggle"], + [shimmer ? "live-activity-row" : "work-live:work-group:activity-3", "work-toggle"], ]); expect(rows.slice(0, 3).map((entry) => entry.type === "work-toggle" && entry.live)).toEqual([ false, @@ -2381,7 +2385,7 @@ describe("buildThreadFeed", () => { const rows = deriveThreadFeedPresentation(feed, latestTurn, new Set(), new Set(), "now"); expect(rows.map((entry) => entry.type)).toEqual(["message", "thinking"]); - expect(rows[1]).toMatchObject({ id: "thinking", createdAt: "now", turnId }); + expect(rows[1]).toMatchObject({ id: "live-activity-row", createdAt: "now", turnId }); // The row identity is stable across re-derivations so the list can reuse it. expect(deriveThreadFeedPresentation(feed, latestTurn, new Set(), new Set(), "now")[1]).toBe( rows[1], @@ -2394,6 +2398,79 @@ describe("buildThreadFeed", () => { ).toEqual(["message"]); }); + it("keeps one live slot while calls fail and restart", () => { + // Recorded from a Claude session whose Bash was broken: every call went + // inProgress → failed within two seconds. Each transition used to insert + // or remove a Thinking row under the group; now the same row id holds + // the live call and then "Thinking", so the list updates it in place. + const turnId = TurnId.make("turn-failing-calls"); + const latestTurn = { + turnId, + state: "running" as const, + requestedAt: "2026-04-01T00:00:00.000Z", + startedAt: "2026-04-01T00:00:00.000Z", + completedAt: null, + assistantMessageId: null, + }; + const call = (n: number, status: "inProgress" | "failed") => + makeActivity({ + id: EventId.make(`call-${n}-${status}`), + kind: status === "failed" ? "tool.completed" : "tool.updated", + tone: "tool", + summary: "Command run", + createdAt: `2026-04-01T00:00:${String(n * 2 + (status === "failed" ? 1 : 0)).padStart(2, "0")}.000Z`, + turnId, + payload: { + itemType: "command_execution", + toolCallId: `call-${n}`, + title: "Command run", + status, + detail: `Bash: ls ${n}`, + }, + }); + const liveIds = (activities: ReadonlyArray>) => + deriveThreadFeedPresentation( + buildThreadFeed( + makeThread({ + id: ThreadId.make("thread-failing-calls"), + projectId: ProjectId.make("project-1"), + title: "Failing calls", + latestTurn, + activities, + }), + ), + latestTurn, + new Set(), + new Set(), + latestTurn.startedAt, + ).map((row) => `${row.type}:${row.id}`); + + expect(liveIds([call(1, "inProgress")])).toEqual(["work-toggle:live-activity-row"]); + expect(liveIds([call(1, "inProgress"), call(1, "failed")])).toEqual([ + "work-toggle:work-live:work-group:tool:turn-failing-calls:call-1", + "thinking:live-activity-row", + ]); + expect(liveIds([call(1, "inProgress"), call(1, "failed"), call(2, "inProgress")])).toEqual([ + "work-toggle:live-activity-row", + ]); + // A call whose end was never reported, in a run before an error row, + // keeps its own identity: only the trailing run can hold the live slot. + const errorRow = makeActivity({ + id: EventId.make("runtime-error"), + kind: "runtime.error", + tone: "error", + summary: "Provider error", + createdAt: "2026-04-01T00:00:02.500Z", + turnId, + payload: { message: "boom" }, + }); + expect(liveIds([call(1, "inProgress"), errorRow, call(2, "inProgress")])).toEqual([ + "work-toggle:work-live:work-group:tool:turn-failing-calls:call-1", + "activity-group:runtime-error", + "work-toggle:live-activity-row", + ]); + }); + it("hands a settled tool run off to Thinking once assistant text streams after it", () => { const turnId = TurnId.make("turn-streaming-tail"); const latestTurn = { @@ -2813,19 +2890,22 @@ describe("quiet timeline: nested agents", () => { }), ).flatMap((entry) => (entry.type === "activity-group" ? entry.activities : [])); + // The batch anchors on the first task.started: a fixed id and timestamp, + // unlike progress ticks (which the server rewrites in place). const running = rowsFor([]); expect(running.map((row) => [row.id, row.summary])).toEqual([ - ["a-progress", "Kicked off 2 subagents · 2 working"], + ["a-start", "Kicked off 2 subagents · 2 working"], ["shell-1", "Run tests"], ]); expect(running[0]).toMatchObject({ + createdAt: "2026-04-01T00:00:01.000Z", lifecycleStatus: "inProgress", workEntry: { agentSpawn: { agentTaskIds: ["a", "b"] } }, }); const oneDone = rowsFor([agent("a-done", "task.completed", "a", "completed", 7)]); expect(oneDone[0]).toMatchObject({ - id: "a-progress", + id: "a-start", summary: "Kicked off 2 subagents · 1 working", lifecycleStatus: "inProgress", }); @@ -2835,7 +2915,7 @@ describe("quiet timeline: nested agents", () => { agent("b-failed", "task.updated", "b", "failed", 8, { error: "boom" }), ]); expect(allDone[0]).toMatchObject({ - id: "a-progress", + id: "a-start", summary: "Ran 2 subagents · 1 failed", lifecycleStatus: "failed", status: "failure", @@ -2843,6 +2923,194 @@ describe("quiet timeline: nested agents", () => { expect(allDone).toHaveLength(2); }); + it("folds the tool call that launched an agent into its spawn card", () => { + const turnId = TurnId.make("turn-agent-tool"); + const at = (seconds: number) => `2026-04-01T00:00:${String(seconds).padStart(2, "0")}.000Z`; + const feed = buildThreadFeed( + makeThread({ + id: ThreadId.make("thread-agent-tool"), + projectId: ProjectId.make("project-1"), + title: "Agent tool", + activities: [ + makeActivity({ + id: EventId.make("agent-call-updated"), + kind: "tool.updated", + tone: "tool", + summary: "Subagent task", + createdAt: at(1), + turnId, + payload: { + itemType: "collab_agent_tool_call", + toolCallId: "toolu_agent", + status: "inProgress", + title: "Subagent task", + detail: "Locate code", + data: { toolName: "Agent" }, + }, + }), + makeActivity({ + id: EventId.make("agent-started"), + kind: "task.started", + summary: "Locate code", + createdAt: at(2), + turnId, + payload: { + taskId: "a1", + agentKind: "agent", + taskType: "local_agent", + title: "Locate code", + toolUseId: "toolu_agent", + }, + }), + makeActivity({ + id: EventId.make("agent-done"), + kind: "task.completed", + summary: "Locate code", + createdAt: at(3), + turnId, + payload: { + taskId: "a1", + agentKind: "agent", + taskType: "local_agent", + title: "Locate code", + toolUseId: "toolu_agent", + status: "completed", + }, + }), + makeActivity({ + id: EventId.make("agent-call-completed"), + kind: "tool.completed", + tone: "tool", + summary: "Subagent task", + createdAt: at(4), + turnId, + payload: { + itemType: "collab_agent_tool_call", + toolCallId: "toolu_agent", + status: "completed", + title: "Subagent task", + detail: "Locate code", + data: { toolName: "Agent" }, + }, + }), + ], + }), + ); + const rows = feed.flatMap((entry) => + entry.type === "activity-group" ? entry.activities.map((row) => row.id) : [], + ); + expect(rows).toEqual(["agent-started"]); + expect( + deriveThreadFeedPresentation(feed, null, new Set([turnId])).map((row) => row.type), + ).toEqual(["turn-fold", "agent-spawn"]); + }); + + it("presents a spawn batch as one card whose status line follows the newest member activity", () => { + const turnId = TurnId.make("turn-spawn-card"); + const latestTurn = { + turnId, + state: "running" as const, + requestedAt: "2026-04-01T00:00:00.000Z", + startedAt: "2026-04-01T00:00:00.000Z", + completedAt: null, + assistantMessageId: null, + }; + const agent = ( + id: string, + kind: "task.started" | "task.progress" | "task.completed", + taskId: string, + seconds: number, + extra: Record = {}, + ) => + makeActivity({ + id: EventId.make(id), + kind, + summary: `Agent ${taskId}`, + createdAt: `2026-04-01T00:00:${String(seconds).padStart(2, "0")}.000Z`, + turnId, + payload: { + taskId, + agentKind: "agent", + taskType: "local_agent", + title: `Agent ${taskId}`, + ...extra, + }, + }); + const presentFor = (activities: ReadonlyArray>) => + deriveThreadFeedPresentation( + buildThreadFeed( + makeThread({ + id: ThreadId.make("thread-spawn-card"), + projectId: ProjectId.make("project-1"), + title: "Spawn card", + latestTurn, + activities, + }), + ), + latestTurn, + new Set(), + new Set(), + latestTurn.startedAt, + ); + + // A working card is the live activity; no Thinking row sits under it. + const single = presentFor([agent("a-start", "task.started", "a", 1)]); + expect(single.map((row) => row.type)).toEqual(["agent-spawn"]); + expect(single[0]).toMatchObject({ + id: `agent-spawn:${turnId}`, + summary: { title: "Agent a", status: "Working", tone: "working" }, + }); + + // The server upserts the progress row with a new createdAt each tick; + // the card keeps its identity and only the status line changes. + const tick = (seconds: number, detail: string) => + presentFor([ + agent("a-start", "task.started", "a", 1), + agent("task-progress:a", "task.progress", "a", seconds, { detail }), + ]); + expect(tick(2, "Reading a.ts")[0]).toMatchObject({ + id: `agent-spawn:${turnId}`, + createdAt: "2026-04-01T00:00:01.000Z", + summary: { title: "Agent a", status: "Reading a.ts", tone: "working" }, + }); + expect(tick(3, "Reading b.ts")[0]).toMatchObject({ + id: `agent-spawn:${turnId}`, + createdAt: "2026-04-01T00:00:01.000Z", + summary: { status: "Reading b.ts" }, + }); + + const batch = presentFor([ + agent("a-start", "task.started", "a", 1), + agent("b-start", "task.started", "b", 2), + agent("task-progress:b", "task.progress", "b", 3, { detail: "Grepping" }), + agent("a-done", "task.completed", "a", 4, { status: "completed" }), + ]); + expect(batch[0]).toMatchObject({ + id: `agent-spawn:${turnId}`, + summary: { + title: "2 subagents", + status: "Grepping", + tone: "working", + members: [ + { title: "Agent a", status: "completed", tone: "completed" }, + { title: "Agent b", status: "working", tone: "working", detail: "Grepping" }, + ], + }, + }); + + const settled = presentFor([ + agent("a-start", "task.started", "a", 1), + agent("b-start", "task.started", "b", 2), + agent("a-done", "task.completed", "a", 4, { status: "completed" }), + agent("b-done", "task.completed", "b", 5, { status: "failed", error: "boom" }), + ]); + expect(settled[0]).toMatchObject({ + type: "agent-spawn", + summary: { title: "2 subagents", status: "1 failed", tone: "failed" }, + }); + expect(settled.map((row) => row.type)).toEqual(["agent-spawn", "thinking"]); + }); + it.each(["cancelled", "failed", "interrupted", "idle"] as const)( "replaces Antigravity batch progress with %s", (status) => { @@ -2988,6 +3256,55 @@ describe("quiet timeline: nested agents", () => { expect(rows[0]?.getFullDetail()).toBe("Reviewer 0 · completed\nReviewer 1 · completed"); }); + it("summarizes a spawn card from the newest member report and the batch outcome", () => { + type Member = NonNullable["agents"][number]; + const member = (title: string, status: Member["status"], detail: string, seconds: number) => + ({ + title, + status, + detail, + updatedAt: `2026-04-01T00:00:${String(seconds).padStart(2, "0")}.000Z`, + }) satisfies Member; + const direct = (agents: ReadonlyArray) => ({ + workflowId: null, + agentTaskIds: agents.map((_, index) => `a${index}`), + agents, + }); + + // The newest report wins regardless of member order. + expect( + agentSpawnSummary( + direct([ + member("Agent 0", "inProgress", "Reading b.ts", 5), + member("Agent 1", "inProgress", "Reading a.ts", 2), + ]), + "inProgress", + ), + ).toMatchObject({ title: "2 subagents", status: "Reading b.ts", tone: "working" }); + + // A declined request is a failed batch, not a completed one. + expect( + agentSpawnSummary(direct([member("Agent 0", "declined", "", 1)]), "declined"), + ).toMatchObject({ status: "failed", tone: "failed" }); + + // A coordinator that failed on its own reports the failure even when every + // member succeeded; before any member reports, the card has a neutral title. + const workflow = (agents: ReadonlyArray) => ({ + workflowId: "wf", + agentTaskIds: ["wf", ...agents.map((_, index) => `wf:wf:${index}`)], + agents: [member("review", "failed", "", 9), ...agents], + }); + expect( + agentSpawnSummary(workflow([member("Reviewer", "completed", "", 3)]), "failed"), + ).toMatchObject({ title: "Reviewer", status: "failed", tone: "failed" }); + expect( + agentSpawnSummary( + { workflowId: "wf", agentTaskIds: ["wf"], agents: [member("review", undefined, "", 1)] }, + "inProgress", + ), + ).toMatchObject({ title: "Subagents", status: "Working", tone: "working", members: [] }); + }); + it("treats a Codex child's idle turn end as a finished batch member", () => { const turnId = TurnId.make("turn-codex"); const child = ( @@ -3063,7 +3380,12 @@ describe("quiet timeline: nested agents", () => { expect(ids).toContain("nested-done"); expect(ids).not.toContain("shell-done"); expect(deriveThreadFeedPresentation(feed, null, new Set())).toMatchObject([ - { type: "activity-group", id: "nested-done" }, + { + type: "agent-spawn", + id: "agent-spawn:n-1", + activity: { id: "nested-done" }, + summary: { title: "Task completed", status: "completed", tone: "completed" }, + }, ]); }); }); diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 1d6fb6c0e252..7286446fb2e8 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -122,6 +122,8 @@ export interface WorkLogEntry { readonly title: string; readonly status: WorkLogToolLifecycleStatus | undefined; readonly detail: string | undefined; + /** When this member last reported, so the card can show the newest activity. */ + readonly updatedAt: string; }>; }; toolData?: unknown; @@ -132,6 +134,8 @@ interface DerivedWorkLogEntry extends WorkLogEntry { collapseKey?: string; /** Grouping key for subagent lifecycle rows (one row per agent). */ taskId?: string; + /** The tool call that launched this agent, when the provider reports one. */ + agentSpawnToolCallId?: string; isWorkflowCoordinator?: boolean; /** Shell/monitor/plan tasks: ordinary work-log rows, never spawn batches. */ isBackgroundTask?: boolean; @@ -187,12 +191,47 @@ export type ThreadFeedEntry = readonly expanded: boolean; } | { + /** + * The turn's single live slot. Web keys its live tool row and its + * "Thinking" row identically so the slot updates in place; here the + * slot holds "Thinking" whenever no tool row is shimmering, so a tool + * failing does not insert a row under the group it lives in. + */ readonly type: "thinking"; readonly id: string; readonly createdAt: string; readonly turnId: TurnId | null; + } + | { + /** + * One batch of spawned subagents. Rendered as its own card because a + * single-line tool row has no room for what the agents are doing now, + * which on a phone is the one thing worth showing. + */ + readonly type: "agent-spawn"; + readonly id: string; + readonly createdAt: string; + readonly turnId: TurnId | null; + readonly activity: ThreadFeedActivity; + readonly expanded: boolean; + readonly summary: AgentSpawnSummary; }; +export interface AgentSpawnSummary { + /** "Locate UNO hand rendering code" for one agent, "3 subagents" for a batch. */ + readonly title: string; + /** Latest member activity while working, else the batch outcome. */ + readonly status: string; + readonly tone: "working" | "completed" | "failed" | "stopped"; + readonly members: ReadonlyArray<{ + readonly title: string; + readonly status: string; + readonly tone: "working" | "completed" | "failed" | "stopped"; + readonly detail: string | undefined; + readonly updatedAt: string; + }>; +} + export type ThreadFeedLatestTurn = Pick< OrchestrationLatestTurn, "turnId" | "state" | "startedAt" | "completedAt" @@ -420,6 +459,7 @@ function isAgentInternalActivity(activity: OrchestrationThreadActivity): boolean return false; } const isTaskRow = + activity.kind === "task.started" || activity.kind === "task.progress" || activity.kind === "task.updated" || activity.kind === "task.completed"; @@ -441,6 +481,15 @@ function isAgentInternalActivity(activity: OrchestrationThreadActivity): boolean return payload.timelineBypass === true || ownedByAgent; } +/** Agent (non-background) task.started rows seed spawn batches. */ +function isAgentTaskStartedActivity(activity: OrchestrationThreadActivity): boolean { + const payload = + activity.payload && typeof activity.payload === "object" + ? (activity.payload as Record) + : null; + return typeof payload?.taskId === "string" && payload.agentKind === "agent"; +} + function deriveWorkLogEntries( activities: ReadonlyArray, ): DerivedWorkLogEntry[] { @@ -449,7 +498,11 @@ function deriveWorkLogEntries( for (const activity of ordered) { if (activity.tone !== "error" && isWorktreeSetupActivity(activity.kind)) continue; if (activity.kind === "tool.started") continue; - if (activity.kind === "task.started") continue; + // Like web: an agent's task.started row anchors its batch. It has a fixed + // id and timestamp, unlike progress ticks, whose stable per-task id is + // rewritten with a new createdAt on every update (and would otherwise + // make the batch row a "fresh" row again on each tick). + if (activity.kind === "task.started" && !isAgentTaskStartedActivity(activity)) continue; if (activity.kind === "task.updated" && !isTerminalTaskUpdate(activity)) continue; if (activity.kind === "tool.progress") continue; if (activity.kind === "context-window.updated") continue; @@ -496,6 +549,7 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo const toolPresentation = extractToolActivityPresentation(payload); // Terminal task updates carry identity so they replace each child's progress row. const isTaskActivity = + activity.kind === "task.started" || activity.kind === "task.progress" || activity.kind === "task.completed" || activity.kind === "task.updated"; @@ -539,6 +593,10 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo if (payload.agentKind !== "agent") { entry.isBackgroundTask = true; } + const spawnToolCallId = asTrimmedString(payload.toolUseId); + if (spawnToolCallId) { + entry.agentSpawnToolCallId = spawnToolCallId; + } if ( payload.taskType === "local_workflow" || (typeof payload.workflowName === "string" && payload.workflowName.length > 0) @@ -608,8 +666,11 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo entry.requestKind = requestKind; } let toolLifecycleStatus = extractWorkLogToolLifecycleStatus(payload); - if (!toolLifecycleStatus && activity.kind === "tool.completed") { - toolLifecycleStatus = "completed"; + if ( + !toolLifecycleStatus && + (activity.kind === "tool.completed" || activity.kind === "task.completed") + ) { + toolLifecycleStatus = activity.tone === "error" ? "failed" : "completed"; } // A Codex child that finishes its turn reports "idle" (resumable, not // terminal). For the batch row that is a finished member. @@ -681,6 +742,7 @@ function agentSpawnMember( title: entry.toolTitle ?? previous?.title ?? entry.label, status: entry.toolLifecycleStatus ?? previous?.status, detail: entry.detail ?? previous?.detail, + updatedAt: entry.createdAt, }; } @@ -713,6 +775,7 @@ function agentSpawnLifecycleStatus( return "inProgress"; } if (statuses.includes("failed")) return "failed"; + if (statuses.includes("declined")) return "declined"; if (statuses.includes("stopped")) return "stopped"; return "completed"; } @@ -729,10 +792,26 @@ function collapseDerivedWorkLogEntries( const spawnRowIndex = new Map(); const spawnGroupByTaskId = new Map(); const toolLifecycleRowIndex = new Map(); + // Tool calls that launched an agent (Claude's Agent tool, ACP subagent + // calls). The batch card is the whole story of that call, so its own + // lifecycle row is dropped. + const spawnToolCallIds = new Set( + entries.flatMap((entry) => + entry.agentSpawnToolCallId !== undefined ? [entry.agentSpawnToolCallId] : [], + ), + ); for (const entry of entries) { + if ( + entry.toolCallId !== undefined && + entry.taskId === undefined && + spawnToolCallIds.has(entry.toolCallId) + ) { + continue; + } const isTaskRow = entry.taskId !== undefined && - (entry.sourceActivityKind === "task.progress" || + (entry.sourceActivityKind === "task.started" || + entry.sourceActivityKind === "task.progress" || entry.sourceActivityKind === "task.completed" || entry.sourceActivityKind === "task.updated"); if (isTaskRow && entry.taskId !== undefined) { @@ -1142,6 +1221,76 @@ function agentSpawnMembers(spawn: NonNullable) { return spawn.agents.filter((_, index) => spawn.agentTaskIds[index] !== spawn.workflowId); } +function agentSpawnTone(status: WorkLogToolLifecycleStatus | undefined): AgentSpawnSummary["tone"] { + switch (status) { + case undefined: + case "inProgress": + return "working"; + case "completed": + return "completed"; + case "failed": + case "declined": + return "failed"; + case "stopped": + return "stopped"; + } +} + +/** + * What the spawn card shows. While members work, the status line is the + * newest member activity (its progress detail), so the card reads like the + * live tool row does for a single call. Once every member settles, it is the + * batch outcome in web's CTA wording. + */ +export function agentSpawnSummary( + spawn: NonNullable, + batchStatus: WorkLogToolLifecycleStatus | undefined, +): AgentSpawnSummary { + const members = agentSpawnMembers(spawn).map((agent) => { + const tone = agentSpawnTone(agent.status); + return { + title: agent.title, + status: tone === "working" ? "working" : (agent.status ?? tone), + tone, + detail: agent.detail, + updatedAt: agent.updatedAt, + }; + }); + const tone = agentSpawnTone(batchStatus); + // A workflow's coordinator is not a member; before any member reports the + // batch has none. + const title = + members.length === 0 + ? "Subagents" + : members.length === 1 + ? members[0]!.title + : `${members.length} subagents`; + if (tone === "working") { + const working = members.filter((member) => member.tone === "working"); + const latest = working + .filter((member) => member.detail !== undefined) + .reduce<(typeof working)[number] | undefined>( + (newest, member) => + newest === undefined || member.updatedAt > newest.updatedAt ? member : newest, + undefined, + ); + const status = + latest?.detail ?? + (members.length > 1 ? `${working.length} of ${members.length} working` : "Working"); + return { title, status, tone, members }; + } + // The batch tone covers a coordinator that failed or stopped on its own. + const failed = members.filter((member) => member.tone === "failed").length; + const stopped = members.filter((member) => member.tone === "stopped").length; + const outcome = + tone === "failed" || failed > 0 + ? `${members.length > 1 && failed > 0 ? `${failed} ` : ""}failed` + : tone === "stopped" || stopped > 0 + ? `${members.length > 1 && stopped > 0 ? `${stopped} ` : ""}stopped` + : "completed"; + return { title, status: outcome, tone, members }; +} + function agentSpawnExpandedBody(spawn: NonNullable): string | null { const lines = agentSpawnMembers(spawn).map((agent) => { const status = @@ -1750,7 +1899,10 @@ export function deriveThreadFeedPresentation( ): ThreadFeedEntry[] { const sourceFeed = feed.filter( (entry) => - entry.type !== "turn-fold" && entry.type !== "work-toggle" && entry.type !== "thinking", + entry.type !== "turn-fold" && + entry.type !== "work-toggle" && + entry.type !== "thinking" && + entry.type !== "agent-spawn", ); const activeTailGroup = sourceFeed.findLast( (entry) => entry.type !== "message" || !isEmptyMessage(entry), @@ -1812,18 +1964,36 @@ export function deriveThreadFeedPresentation( } // A working turn always shows one live activity. When no tool row is // shimmering (no tools yet, or the latest failed), that row is "Thinking". + // The trailing group's live row and this row share LIVE_ACTIVITY_ROW_ID, so + // the handoff between them happens in place (one row, new content) instead + // of a row being inserted below the group every time a call fails. if ( activeWorkStartedAt !== null && - !result.some((row) => row.type === "work-toggle" && row.shimmer) + !result.some( + (row) => + (row.type === "work-toggle" && row.shimmer) || + // A working spawn card is the live activity: its status line shows + // what the agents are doing, so a Thinking row under it would lie. + (row.type === "agent-spawn" && + row.summary.tone === "working" && + row.turnId === unsettledTurnId), + ) ) { result.push(thinkingRow(activeWorkStartedAt, unsettledTurnId)); } return result; } +/** + * Shared by the trailing tool group's live row and the "Thinking" row so the + * list keeps one mounted row for the turn's live slot (mirrors web's + * LIVE_ACTIVITY_ROW_ID). Anything keyed by row id must not distinguish them. + */ +export const LIVE_ACTIVITY_ROW_ID = "live-activity-row"; + function thinkingRow(createdAt: string, turnId: TurnId | null) { if (cachedThinkingRow?.createdAt !== createdAt || cachedThinkingRow.turnId !== turnId) { - cachedThinkingRow = { type: "thinking", id: "thinking", createdAt, turnId }; + cachedThinkingRow = { type: "thinking", id: LIVE_ACTIVITY_ROW_ID, createdAt, turnId }; } return cachedThinkingRow; } @@ -1852,7 +2022,9 @@ function appendPresentedFeedEntry( cached.isWorking !== isWorking || cached.activeTail !== activeTail || cached.rows.some( - (row) => row.type === "work-toggle" && expandedWorkGroupIds.has(row.groupId) !== row.expanded, + (row) => + (row.type === "work-toggle" && expandedWorkGroupIds.has(row.groupId) !== row.expanded) || + (row.type === "agent-spawn" && expandedWorkGroupIds.has(row.id) !== row.expanded), ) ) { const rows: ThreadFeedEntry[] = []; @@ -1908,11 +2080,27 @@ function appendActivityGroupRows( groupableRun = []; }; for (const activity of activities) { - if (activity.workEntry.tone !== "error" && activity.workEntry.agentSpawn === undefined) { + const spawn = activity.workEntry.agentSpawn; + if (activity.workEntry.tone !== "error" && spawn === undefined) { groupableRun.push(activity); continue; } flushGroupableRun(false); + if (spawn !== undefined) { + // Keyed by the batch, not the anchor activity: the anchor can change + // as members arrive, and a changed key remounts the card. + const groupId = `agent-spawn:${spawn.workflowId ?? activity.turnId ?? spawn.agentTaskIds[0]}`; + result.push({ + type: "agent-spawn", + id: groupId, + createdAt: activity.createdAt, + turnId: activity.turnId, + activity, + expanded: expandedWorkGroupIds.has(groupId), + summary: agentSpawnSummary(spawn, activity.lifecycleStatus), + }); + continue; + } result.push({ type: "activity-group", id: activity.id, @@ -1953,7 +2141,9 @@ function appendToolGroupRows( const latestActivity = latestActiveActivity ?? activities.at(-1)!; // Like web, the trailing run keeps shining after its latest call succeeds; // only a failed, declined, or stopped call hands the live slot to "Thinking". - const shimmer = active || (activeTail && latestActivity.status === "success"); + // Only the trailing run can be the turn's live slot; an in-progress row in + // an earlier run (a call whose end was never reported) stays in place. + const shimmer = activeTail && (active || latestActivity.status === "success"); const singleActivity = activities.length === 1 ? latestActivity : null; const summary = live ? liveToolActivitySummary(latestActivity, live) @@ -1994,7 +2184,9 @@ function appendToolGroupRows( : undefined; result.push({ type: "work-toggle", - id: `${live ? "work-live" : "work-toggle"}:${groupId}`, + // The shimmering trailing row is the turn's live slot; it keeps that + // identity (and so its mounted view) until "Thinking" takes the slot. + id: shimmer ? LIVE_ACTIVITY_ROW_ID : `${live ? "work-live" : "work-toggle"}:${groupId}`, createdAt: sourceGroup.createdAt, turnId: sourceGroup.turnId, groupId,