From 3904753309a7fb46417026462ca6b84de564481f Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Wed, 5 Aug 2026 18:14:11 -0400 Subject: [PATCH 1/4] fix(chat): unfreeze the turn timer and surface background jobs Three bugs that combined to make a working turn look hung. The "working for" timer froze at 0s. The ticker captured the timer once, but the status line swaps from a bare into an expander + ) : null} + ); } diff --git a/apps/desktop/src/renderer/components/chat/chatTranscriptRows.test.ts b/apps/desktop/src/renderer/components/chat/chatTranscriptRows.test.ts index 3d32aeba2..48599510e 100644 --- a/apps/desktop/src/renderer/components/chat/chatTranscriptRows.test.ts +++ b/apps/desktop/src/renderer/components/chat/chatTranscriptRows.test.ts @@ -2466,6 +2466,40 @@ describe("subagent two-row rendering", () => { status: "failed", summary: "tests failed", }), + // Both background-job producers, so `background_job_line`'s context cache + // (`backgroundJobRowIndexByKey`) and the `backgroundLineOpened` latch are + // covered by the parity guarantee too — they are rebuilt from the event + // stream on a full recompute and must not diverge from the incremental + // path's carried state. + env("2026-06-01T10:00:08.000Z", { + type: "scheduled_work_update", + id: "background:bg-live", + kind: "background_task", + status: "running", + title: "cd /repo && npm run dev", + sourceTaskId: "bg-live", + }), + env("2026-06-01T10:00:09.000Z", { + type: "subagent_started", + taskId: "bg-legacy", + taskType: "background", + description: "cd /repo && npm install", + }), + env("2026-06-01T10:00:10.000Z", { + type: "scheduled_work_update", + id: "background:bg-live", + kind: "background_task", + status: "completed", + title: "cd /repo && npm run dev", + sourceTaskId: "bg-live", + }), + env("2026-06-01T10:00:11.000Z", { + type: "subagent_result", + taskId: "bg-legacy", + taskType: "background", + status: "completed", + summary: "exited 0", + }), ]; const full = collapseChatTranscriptEvents(stream); @@ -2584,7 +2618,7 @@ describe("subagent two-row rendering", () => { expect(incremental.rows).toEqual(full); }); - it("renders old-style background shell subagent events as one finish chip, no cards", () => { + it("renders old-style background shell subagent events as one job line, no cards", () => { const rows = collapseChatTranscriptEvents([ env("2026-06-01T10:00:00.000Z", { type: "subagent_started", @@ -2607,13 +2641,123 @@ describe("subagent two-row rendering", () => { ]); expect(rows).toHaveLength(1); - if (rows[0]!.event.type !== "background_finish_chip") throw new Error("Expected finish chip"); + if (rows[0]!.event.type !== "background_job_line") throw new Error("Expected background job line"); expect(rows[0]!.event.status).toBe("completed"); expect(rows[0]!.event.label).toBe("npm run dev"); expect(rows[0]!.key).toBe("background-chip:bg-1"); }); - it("dedupes a double background result into one finish chip", () => { + it("shows a background job in the thread while it is still running", () => { + // Regression: the line used to be pushed only on the terminal event, so a + // long background job left the thread completely silent while the sidebar + // flipped to a duration-less "Working" — together they read as a hung turn. + const rows = collapseChatTranscriptEvents([ + env("2026-06-01T10:00:00.000Z", { + type: "subagent_started", + taskId: "bg-1", + taskType: "background", + description: "cd /repo && npm install", + }), + ]); + + expect(rows).toHaveLength(1); + if (rows[0]!.event.type !== "background_job_line") throw new Error("Expected background job line"); + expect(rows[0]!.event.status).toBe("running"); + expect(rows[0]!.event.label).toBe("npm install"); + expect(rows[0]!.key).toBe("background-chip:bg-1"); + }); + + it("mutates the running background line in place instead of adding a finish row", () => { + const rows = collapseChatTranscriptEvents([ + env("2026-06-01T10:00:00.000Z", { + type: "subagent_started", + taskId: "bg-1", + taskType: "background", + description: "cd /repo && npm install", + }), + env("2026-06-01T10:00:01.000Z", { + type: "subagent_progress", + taskId: "bg-1", + summary: "resolving packages", + }), + env("2026-06-01T10:00:30.000Z", { + type: "subagent_result", + taskId: "bg-1", + taskType: "background", + status: "completed", + summary: "exited 0", + }), + ]); + + // One row for the job's whole life — spawn, progress, and finish all land + // on the same key, so a turn that starts several jobs cannot stack rows. + expect(rows).toHaveLength(1); + const settled = rows[0]!.event; + if (settled.type !== "background_job_line") throw new Error("Expected background job line"); + if (settled.status === "running") throw new Error("Expected a settled job line"); + expect(settled.status).toBe("completed"); + expect(settled.durationMs).toBe(30_000); + expect(rows[0]!.key).toBe("background-chip:bg-1"); + }); + + it("does not reopen a settled background line when a late progress tick arrives", () => { + // Providers do emit a trailing progress notification after a job already + // settled. Rewriting the row back to `running` would drop its exit code and + // duration and restart a ticker that then never stops. + const rows = collapseChatTranscriptEvents([ + env("2026-06-01T10:00:00.000Z", { + type: "subagent_started", + taskId: "bg-1", + taskType: "background", + description: "cd /repo && npm install", + }), + env("2026-06-01T10:00:30.000Z", { + type: "subagent_result", + taskId: "bg-1", + taskType: "background", + status: "completed", + summary: "exited 0", + }), + env("2026-06-01T10:00:31.000Z", { + type: "subagent_progress", + taskId: "bg-1", + summary: "late tick", + }), + ]); + + expect(rows).toHaveLength(1); + const settled = rows[0]!.event; + if (settled.type !== "background_job_line") throw new Error("Expected background job line"); + expect(settled.status).toBe("completed"); + }); + + it("keeps a background job as one line when a late agentType would reclassify it", () => { + // `preferredSubagentAgentType` upgrades "background" to a real agent type, + // which used to flip the classification mid-flight: the running one-liner + // was stranded forever AND a full subagent result card was pushed for the + // same task. + const rows = collapseChatTranscriptEvents([ + env("2026-06-01T10:00:00.000Z", { + type: "subagent_started", + taskId: "bg-1", + taskType: "background", + description: "cd /repo && npm install", + }), + env("2026-06-01T10:00:30.000Z", { + type: "subagent_result", + taskId: "bg-1", + taskType: "background", + agentType: "Explore", + status: "completed", + summary: "exited 0", + }), + ]); + + expect(rows).toHaveLength(1); + expect(rows[0]!.event.type).toBe("background_job_line"); + }); + + it("dedupes a double background result into one job line", () => { const rows = collapseChatTranscriptEvents([ env("2026-06-01T10:00:00.000Z", { type: "subagent_started", @@ -2638,18 +2782,25 @@ describe("subagent two-row rendering", () => { ]); expect(rows).toHaveLength(1); - if (rows[0]!.event.type !== "background_finish_chip") throw new Error("Expected finish chip"); + if (rows[0]!.event.type !== "background_job_line") throw new Error("Expected background job line"); expect(rows[0]!.event.status).toBe("failed"); }); - it("drops background_task scheduled_work_update from the in-thread transcript", () => { + it("renders the LIVE background_task scheduled_work stream as the job line", () => { + // This is the only shape a running app actually emits for a backgrounded + // shell: `emitClaudeBackgroundTaskUpdate` fires a background_task + // scheduled_work_update on spawn and on exit, and deliberately emits NO + // subagent lifecycle events for these tasks. It used to be dropped outright, + // so the in-thread row existed only for legacy replayed transcripts and + // never appeared for a job you actually started. const rows = collapseChatTranscriptEvents([ env("2026-06-01T10:00:00.000Z", { type: "scheduled_work_update", - id: "bg-task-1", + id: "background:bg-task-1", kind: "background_task", status: "running", title: "cd /repo && npm run dev", + sourceTaskId: "bg-task-1", }), env("2026-06-01T10:00:01.000Z", { type: "scheduled_work_update", @@ -2660,10 +2811,88 @@ describe("subagent two-row rendering", () => { }), ]); - // background_task produces no thread row; the cron survives. + expect(rows).toHaveLength(2); + const job = rows[0]!.event; + if (job.type !== "background_job_line") throw new Error("Expected background job line"); + expect(job.status).toBe("running"); + expect(job.label).toBe("npm run dev"); + // Same key space as the legacy subagent producer, so a transcript carrying + // both shapes for one task still renders exactly one row. + expect(rows[0]!.key).toBe("background-chip:bg-task-1"); + // Other scheduled kinds are untouched. + if (rows[1]!.event.type !== "scheduled_work_update") throw new Error("Expected scheduled_work_update"); + expect(rows[1]!.event.kind).toBe("cron"); + }); + + it("never renders a job line for a real subagent reported through the background stream", () => { + // `applyClaudeBackgroundTasksLevel` gates only on task_type, so an agent + // that reports none reaches the background emitter. Without the identity + // guard the agent got its spawn/result cards AND a job line wedged between + // them. Both orderings are covered — the stream is unspecified. + const scheduledFirst = collapseChatTranscriptEvents([ + env("2026-06-01T10:00:00.000Z", { + type: "scheduled_work_update", + id: "background:agent-1", + kind: "background_task", + status: "running", + title: "Investigate route tree", + sourceTaskId: "agent-1", + }), + env("2026-06-01T10:00:01.000Z", { + type: "subagent_started", + taskId: "agent-1", + agentType: "Explore", + description: "Investigate route tree", + }), + ]); + expect(scheduledFirst.map((row) => row.event.type)).toEqual(["subagent_spawn_anchor"]); + + const lifecycleFirst = collapseChatTranscriptEvents([ + env("2026-06-01T10:00:00.000Z", { + type: "subagent_started", + taskId: "agent-1", + agentType: "Explore", + description: "Investigate route tree", + }), + env("2026-06-01T10:00:01.000Z", { + type: "scheduled_work_update", + id: "background:agent-1", + kind: "background_task", + status: "running", + title: "Investigate route tree", + sourceTaskId: "agent-1", + }), + ]); + expect(lifecycleFirst.map((row) => row.event.type)).toEqual(["subagent_spawn_anchor"]); + }); + + it("settles the live background job line in place with a measured duration", () => { + const rows = collapseChatTranscriptEvents([ + env("2026-06-01T10:00:00.000Z", { + type: "scheduled_work_update", + id: "background:bg-task-1", + kind: "background_task", + status: "running", + title: "cd /repo && npm test", + sourceTaskId: "bg-task-1", + }), + env("2026-06-01T10:02:00.000Z", { + type: "scheduled_work_update", + id: "background:bg-task-1", + kind: "background_task", + status: "failed", + title: "cd /repo && npm test", + sourceTaskId: "bg-task-1", + }), + ]); + expect(rows).toHaveLength(1); - if (rows[0]!.event.type !== "scheduled_work_update") throw new Error("Expected scheduled_work_update"); - expect(rows[0]!.event.kind).toBe("cron"); + const settled = rows[0]!.event; + if (settled.type !== "background_job_line") throw new Error("Expected background job line"); + if (settled.status === "running") throw new Error("Expected a settled job line"); + expect(settled.status).toBe("failed"); + // Measured from the row's own first sighting, not from the terminal event. + expect(settled.durationMs).toBe(120_000); }); it("derives a wake divider before every unattended scheduled turn", () => { diff --git a/apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts b/apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts index 0c03dd73b..1bf745f90 100644 --- a/apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts +++ b/apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts @@ -3,9 +3,10 @@ import { mergeReasoningTextFragments, type ActivityPhaseMergeMeta, } from "../../../shared/chatActivityPhase"; -import type { AgentChatEvent, AgentChatEventEnvelope, AgentChatSpawnKind, CodexWebSearchResult } from "../../../shared/types"; +import type { AgentChatEvent, AgentChatEventEnvelope, AgentChatScheduledWorkStatus, AgentChatSpawnKind, CodexWebSearchResult } from "../../../shared/types"; import { isBackgroundShellCommand, + isRealSubagent, longerSubagentText, normalizeSubagentLifecycleEvent, preferSubagentSummary, @@ -202,19 +203,46 @@ export type SubagentStoppedGroupEvent = { }; /** - * Compact finish chip for a backgrounded shell command (no spawn/result cards). - * Row key: `background-chip:${agentKey}`. + * The whole in-thread presence of a backgrounded shell command: ONE quiet + * one-liner, pushed where the job started and mutated in place through to its + * terminal state (no spawn/result cards, and never a second row). + * + * It used to be a finish-only chip, so a job that ran for minutes left the + * thread completely silent while the sidebar flipped to a duration-less + * "Working" — the two together read as a stalled turn. Showing the line from + * the start costs no extra rows (the terminal update reuses this one) and gives + * the run somewhere to point at. + * + * TWO producers feed this one row type, and they must land on the SAME key or a + * mixed transcript renders the job twice: + * - the live Claude runtime, which reports background shells as + * `scheduled_work_update {kind:"background_task"}` (see + * `emitClaudeBackgroundTaskUpdate`) — this is the only producer a running + * app actually emits; + * - legacy `subagent_*` lifecycle events carrying `taskType: background`, + * which is all that older persisted transcripts contain. + * + * Row key: `background-chip:${agentKey}` — unchanged from the finish-chip era on + * purpose; the virtualizer's measuredHeights are keyed by it. + * + * Modelled as a union so "running ⇒ no outcome yet" is enforced by the type + * rather than by a comment on four independently-nullable fields. */ -export type BackgroundFinishChipRenderEvent = { - type: "background_finish_chip"; +export type BackgroundJobLineRenderEvent = { + type: "background_job_line"; agentKey: string; label: string; - status: SubagentCardTerminalStatus; - exitCode: number | null; - durationMs: number | null; startedAt: string | null; - endedAt: string; -}; +} & ( + | { status: "running" } + | { + status: SubagentCardTerminalStatus; + /** Null when the producer reports no exit code (the live runtime does not). */ + exitCode: number | null; + /** Wall-clock between the job's first and last row update. */ + durationMs: number | null; + } +); export type ScheduledWakeDividerRenderEvent = { type: "scheduled_wake_divider"; @@ -248,7 +276,7 @@ export type ChatTranscriptRenderEvent = | WorkLogRenderEvent | SubagentSpawnAnchorRenderEvent | SubagentResultCardRenderEvent - | BackgroundFinishChipRenderEvent + | BackgroundJobLineRenderEvent | ScheduledWakeDividerRenderEvent | SpawnWakeDividerRenderEvent; @@ -291,7 +319,14 @@ type SubagentAnchorState = { /** Index of the result-card row once the agent ends (null until then). */ resultRowIndex: number | null; /** Index of the background finish-chip row (null unless a background shell). */ - chipRowIndex: number | null; + /** + * Latched once this task has opened a background-job line. Classification is + * derived per event and can legitimately FLIP: a late `agentType` upgrades + * `background` to a real subagent type, which would otherwise strand the + * running one-liner forever AND push a full result card for the same task. + * Once the line exists, the task stays a background job. + */ + backgroundLineOpened: boolean; description: string | null; agentType: string | null; taskType: string | null; @@ -344,6 +379,12 @@ type CollapseTranscriptContext = { * the update — the same discipline as the subagent spawn anchor above. */ adeCardRowIndexById: Map; + /** + * `background_job_line` rows keyed by their row key. Keyed by row key rather + * than task id because two different producers (live scheduled-work updates + * and legacy subagent lifecycle events) upsert into the same key space. + */ + backgroundJobRowIndexByKey: Map; }; export function createCollapseTranscriptContext(): CollapseTranscriptContext { @@ -357,6 +398,7 @@ export function createCollapseTranscriptContext(): CollapseTranscriptContext { recoveryRowIndexByTurn: new Map(), stalledRowIndexByTurn: new Map(), adeCardRowIndexById: new Map(), + backgroundJobRowIndexByKey: new Map(), }; } @@ -965,7 +1007,7 @@ function backgroundChipKey(agentKey: string): string { return `background-chip:${agentKey}`; } -type SubagentRowPosition = "rowIndex" | "resultRowIndex" | "chipRowIndex"; +type SubagentRowPosition = "rowIndex" | "resultRowIndex"; function resolveSubagentRowPosition( rows: ChatTranscriptRenderEnvelope[], @@ -991,7 +1033,7 @@ function repairSubagentRowPositionsAfterSplice( removedIndex: number, ): void { for (const state of new Set(context.subagentAnchors.values())) { - for (const position of ["rowIndex", "resultRowIndex", "chipRowIndex"] as const) { + for (const position of ["rowIndex", "resultRowIndex"] as const) { const storedIndex = state[position]; if (storedIndex === removedIndex) state[position] = null; else if (storedIndex != null && storedIndex > removedIndex) state[position] = storedIndex - 1; @@ -1010,6 +1052,7 @@ function repairIndexedTranscriptRowsAfterSplice( context.recoveryRowIndexByTurn, context.stalledRowIndexByTurn, context.adeCardRowIndexById, + context.backgroundJobRowIndexByKey, ]) { for (const [key, storedIndex] of rowIndexes) { if (storedIndex === removedIndex) rowIndexes.delete(key); @@ -1025,13 +1068,13 @@ function repairIndexedTranscriptRowsAfterSplice( * appends without a context, and it guarantees a `cardId` can never mint two * rows sharing one React key. Both agree because only one row ever holds a key. */ -function resolveAdeCardRowIndex( +function resolveKeyedRowIndex( rows: ChatTranscriptRenderEnvelope[], - context: CollapseTranscriptContext | undefined, - cardId: string, + rowIndexes: Map | undefined, + lookupKey: string, expectedKey: string, ): number | null { - const stored = context?.adeCardRowIndexById.get(cardId); + const stored = rowIndexes?.get(lookupKey); if (stored != null && rows[stored]?.key === expectedKey) return stored; for (let index = rows.length - 1; index >= 0; index -= 1) { if (rows[index]?.key === expectedKey) return index; @@ -1039,6 +1082,15 @@ function resolveAdeCardRowIndex( return null; } +function resolveAdeCardRowIndex( + rows: ChatTranscriptRenderEnvelope[], + context: CollapseTranscriptContext | undefined, + cardId: string, + expectedKey: string, +): number | null { + return resolveKeyedRowIndex(rows, context?.adeCardRowIndexById, cardId, expectedKey); +} + type AdeCardEvent = Extract; /** @@ -1116,6 +1168,120 @@ function backgroundExitCode(event: NormalizedSubagentLifecycleEvent): number | n return typeof value === "number" && Number.isFinite(value) ? value : null; } +/** + * Human label for a background job's one-liner. `backgroundCommandLabel` strips + * the shell noise (`cd /repo && …`) down to the part worth reading; the raw + * command and description are the fallbacks, in that order. + */ +function backgroundJobLabel(state: SubagentAnchorState): string { + return backgroundCommandLabel(state.command ?? state.description ?? "") + || state.command + || state.description + || "Background command"; +} + +/** + * Row position of an existing background-job line, keyed by its own row key. + */ +function resolveBackgroundJobRowIndex( + rows: ChatTranscriptRenderEnvelope[], + context: CollapseTranscriptContext | undefined, + expectedKey: string, +): number | null { + return resolveKeyedRowIndex(rows, context?.backgroundJobRowIndexByKey, expectedKey, expectedKey); +} + +/** + * Drop a background-job line that a later event proved was never a background + * job at all — see the real-subagent guard in the `background_task` handler. + */ +function removeBackgroundJobLine( + rows: ChatTranscriptRenderEnvelope[], + context: CollapseTranscriptContext, + expectedKey: string, +): void { + const rowIndex = resolveBackgroundJobRowIndex(rows, context, expectedKey); + if (rowIndex == null) return; + rows.splice(rowIndex, 1); + repairIndexedTranscriptRowsAfterSplice(context, rowIndex); +} + +/** + * Push the job's one-liner, or mutate the existing one in place — a NEW object + * under the SAME row key, matching how the spawn anchor updates. Keeping one + * row for the job's whole life is what stops a busy turn from stacking a + * running row and a finished row for every background command it starts, and is + * what lets the live and legacy producers converge on one row. + * + * A terminal row is never reopened. Providers do emit a trailing progress tick + * after a job has already settled, and rewriting the row back to `running` + * would drop its exit code and duration and restart a ticker that then never + * stops. + */ +function upsertBackgroundJobLine( + rows: ChatTranscriptRenderEnvelope[], + context: CollapseTranscriptContext | undefined, + expectedKey: string, + timestamp: string, + event: BackgroundJobLineRenderEvent, +): void { + const rowIndex = resolveBackgroundJobRowIndex(rows, context, expectedKey); + if (rowIndex == null) { + context?.backgroundJobRowIndexByKey.set(expectedKey, rows.length); + rows.push({ key: expectedKey, timestamp, event }); + return; + } + const existing = rows[rowIndex]!.event; + if (existing.type === "background_job_line" && existing.status !== "running") { + if (event.status === "running") return; + } + context?.backgroundJobRowIndexByKey.set(expectedKey, rowIndex); + rows[rowIndex] = { key: expectedKey, timestamp, event }; +} + +/** + * The job's start anchor: whatever the row already recorded, so a terminal + * update computes its duration from the first sighting rather than from itself. + */ +function backgroundJobStartedAt( + rows: ChatTranscriptRenderEnvelope[], + rowIndex: number | null, + fallback: string, +): string { + if (rowIndex == null) return fallback; + const existing = rows[rowIndex]?.event; + if (existing?.type !== "background_job_line") return fallback; + return existing.startedAt ?? fallback; +} + +/** + * Scheduled-work status → the job line's status. `paused` has no meaning for a + * background shell (nothing pauses one) and is treated as still running rather + * than inventing a terminal outcome the runtime never reported. + */ +function backgroundJobStatusFromScheduledWork( + status: AgentChatScheduledWorkStatus, +): SubagentCardStatus { + // Enumerated rather than defaulted: a terminal status added to + // AgentChatScheduledWorkStatus later must fail the build here instead of + // silently rendering as a job that never finishes. + switch (status) { + case "completed": + return "completed"; + case "stopped": + case "cancelled": + return "stopped"; + case "failed": + case "missed": + return "failed"; + case "scheduled": + case "paused": + case "running": + case "fired": + return "running"; + } +} + // Best-effort parent label from the anchors map — the parent's description or // agentType. Null when the parent isn't (yet) in the map; renders nothing. function resolveParentLabel( @@ -1239,7 +1405,7 @@ function handleSubagentLifecycleEvent( renderKeyBase: agentKey, rowIndex: null, resultRowIndex: null, - chipRowIndex: null, + backgroundLineOpened: false, description: null, agentType: null, taskType: null, @@ -1267,10 +1433,38 @@ function handleSubagentLifecycleEvent( } enrichSubagentStateFromEvent(state, event); - const backgroundShell = isBackgroundShellCommand(classificationInput(state)); + const backgroundShell = state.backgroundLineOpened + || isBackgroundShellCommand(classificationInput(state)); if (event.type === "subagent_started" || event.type === "subagent_progress") { - if (backgroundShell) return true; // background shell → chip only, no cards + // Background shell → the single one-liner, never spawn/result cards. Pushed + // on the first lifecycle event so the run is visible while it runs; the + // terminal event below mutates this same row rather than adding another. + // A progress tick that arrives AFTER the job settled is ignored rather than + // reopening the finished row (`upsertBackgroundJobLine` enforces the same + // rule for the live producer). + if (backgroundShell) { + if (state.endedAt == null) { + state.backgroundLineOpened = true; + upsertBackgroundJobLine(rows, context, backgroundChipKey(state.renderKeyBase), timestamp, { + type: "background_job_line", + agentKey: state.renderKeyBase, + label: backgroundJobLabel(state), + status: "running", + startedAt: state.startedAt, + }); + } + return true; + } + // The other half of the real-subagent guard above: scheduled-work and + // lifecycle ordering is unspecified, so the job line may already exist by + // the time this task proves itself a real subagent. Drop it before the + // spawn anchor lands, or the agent renders as a job line AND a card pair. + if (context) { + for (const identity of [state.renderKeyBase, agentKey, taskId]) { + if (identity) removeBackgroundJobLine(rows, context, backgroundChipKey(identity)); + } + } if (state.rowIndex == null) { // First lifecycle → push the spawn anchor and record its index. state.status = "running"; @@ -1312,29 +1506,17 @@ function handleSubagentLifecycleEvent( const terminalStatus = event.status; if (backgroundShell) { - const label = backgroundCommandLabel(state.command ?? state.description ?? "") - || state.command - || state.description - || "Background command"; state.endedAt = timestamp; - const chipEvent: BackgroundFinishChipRenderEvent = { - type: "background_finish_chip", + state.backgroundLineOpened = true; + upsertBackgroundJobLine(rows, context, backgroundChipKey(state.renderKeyBase), timestamp, { + type: "background_job_line", agentKey: state.renderKeyBase, - label, + label: backgroundJobLabel(state), status: terminalStatus, exitCode: backgroundExitCode(event), durationMs: durationMsBetween(state.startedAt, timestamp), startedAt: state.startedAt, - endedAt: timestamp, - }; - if (state.chipRowIndex == null) { - state.chipRowIndex = rows.length; - rows.push({ key: backgroundChipKey(state.renderKeyBase), timestamp, event: chipEvent }); - } else { - const expectedKey = backgroundChipKey(state.renderKeyBase); - const rowIndex = resolveSubagentRowPosition(rows, state, "chipRowIndex", expectedKey); - if (rowIndex != null) rows[rowIndex] = { key: expectedKey, timestamp, event: chipEvent }; - } + }); return true; } @@ -1859,14 +2041,60 @@ export function appendCollapsedChatTranscriptEvent( } } - // `background_task` scheduled work is owned by the actions pane — never render - // an in-thread row for it. Other scheduled kinds keep their current behavior. + // `background_task` scheduled work IS how the live Claude runtime reports a + // backgrounded shell command — `emitClaudeBackgroundTaskUpdate` fires one of + // these on spawn and one on exit, and deliberately emits no subagent + // lifecycle events for these tasks at all. This used to be dropped outright + // ("the actions pane owns it"), which left a running job with no in-thread + // presence whatsoever while the sidebar flipped to a bare "Working" — the two + // together read as a hung turn. It now drives the same single one-liner the + // legacy subagent path produces, on the same row key, so a transcript holding + // both shapes still renders exactly one row per job. + // + // Other scheduled kinds (wakeup/cron/loop) keep their existing behavior. if (event.type === "scheduled_work_update" && event.kind === "background_task") { + const taskKey = event.sourceTaskId?.trim() || event.id; + if (!taskKey) return; + // A REAL subagent can also be reported through this stream: the level-set + // path (`applyClaudeBackgroundTasksLevel`) gates only on task_type, so an + // agent that reports none falls through to the background emitter. Without + // this guard the agent gets its spawn/result card pair AND a job line + // wedged between them. The identity filter matches the one + // `deriveBackgroundItems` and the iOS timeline already apply. + const anchor = context?.subagentAnchors.get(taskKey); + if (anchor && isRealSubagent(classificationInput(anchor))) return; + const expectedKey = backgroundChipKey(taskKey); + const rowIndex = resolveBackgroundJobRowIndex(rows, context, expectedKey); + const startedAt = backgroundJobStartedAt(rows, rowIndex, envelope.timestamp); + const status = backgroundJobStatusFromScheduledWork(event.status); + const label = backgroundCommandLabel(event.title ?? "") + || event.title + || "Background command"; + upsertBackgroundJobLine( + rows, + context, + expectedKey, + envelope.timestamp, + status === "running" + ? { type: "background_job_line", agentKey: taskKey, label, startedAt, status } + : { + type: "background_job_line", + agentKey: taskKey, + label, + startedAt, + status, + // The scheduled-work wire format carries no exit code; duration is + // measured from the row's own first sighting instead of trusting + // the free-text summary the emitter composes. + exitCode: null, + durationMs: durationMsBetween(startedAt, envelope.timestamp), + }, + ); return; } - // Subagent lifecycle → two-row spawn/result cards (or a single finish chip for - // background shell commands). Normalize canonical dotted events first, then + // Subagent lifecycle → two-row spawn/result cards (or a single live one-liner + // for background shell commands). Normalize canonical dotted events first, then // fold every lifecycle event into the anchor state (handled BEFORE the generic // passthrough so no raw subagent_* row ever reaches the activity bundler). if (event.type === "codex_image_generation" || event.type === "codex_image_view") { @@ -2278,9 +2506,10 @@ export function groupConsecutiveWorkLogRows( } function isActivityBundleSourceEvent(event: ChatTranscriptRenderEvent): event is ChatActivityBundleItem["event"] { - // Subagent lifecycle events now render as dedicated spawn/result/chip rows and - // never reach here. `background_task` scheduled work is dropped in the collapse - // pass (the actions pane owns it); other scheduled kinds keep bundling. + // Subagent lifecycle events now render as dedicated spawn/result/job-line rows + // and never reach here. `background_task` scheduled work is consumed by the + // collapse pass into a `background_job_line`; other scheduled kinds keep + // bundling. return event.type === "todo_update" || (event.type === "scheduled_work_update" && event.kind !== "background_task"); } diff --git a/apps/desktop/src/renderer/components/terminals/SessionCard.test.tsx b/apps/desktop/src/renderer/components/terminals/SessionCard.test.tsx index 5954cc4c2..f1064da08 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionCard.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionCard.test.tsx @@ -1041,13 +1041,46 @@ describe("SessionCard status vocabulary", () => { expect(status.textContent).not.toContain("12m"); }); - it("shows Working when the foreground turn is idle but background work remains", () => { + it("names background work and times it when the foreground turn is idle", () => { + // Regression: this row used to read as a bare "Working" with no duration, + // which is indistinguishable from a live turn that has stalled — it claimed + // the model was thinking when the turn had already ended, and gave no + // elapsed to judge it by. + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-05-23T10:00:45.000Z")); + try { + const { container } = render( + , + ); + + const status = container.querySelector("[data-session-status]")!; + expect(status.getAttribute("data-session-status")).toBe("Background work"); + expect(status.getAttribute("data-session-tone")).toBe("blue"); + expect(status.textContent).toContain("Background work"); + expect(status.textContent).toContain("45s"); + } finally { + vi.useRealTimers(); + } + }); + + it("counts concurrent background jobs in the status label", () => { const { container } = render( { ); const status = container.querySelector("[data-session-status]")!; - expect(status.getAttribute("data-session-status")).toBe("Working"); - expect(status.getAttribute("data-session-tone")).toBe("blue"); - expect(status.textContent).toBe("Working"); + expect(status.getAttribute("data-session-status")).toBe("Background work ×3"); }); it("shows a compact Waiting countdown only after the foreground turn is idle", () => { diff --git a/apps/desktop/src/renderer/lib/terminalAttention.test.ts b/apps/desktop/src/renderer/lib/terminalAttention.test.ts index a54afeb8f..f1bcafb3f 100644 --- a/apps/desktop/src/renderer/lib/terminalAttention.test.ts +++ b/apps/desktop/src/renderer/lib/terminalAttention.test.ts @@ -279,7 +279,7 @@ describe("terminalAttention", () => { expect(idle?.tone).toBe("emerald"); }); - it("keeps an idle chat Working while authoritative background tasks remain", () => { + it("names and times background work on an idle chat, and outranks a pending wake", () => { const presentation = sessionStatusDisplay({ status: "running", runtimeState: "idle", @@ -290,12 +290,15 @@ describe("terminalAttention", () => { nowMs: Date.parse("2026-08-01T10:00:00.000Z"), }); + // Not a bare "Working": the foreground turn has ENDED, so claiming the + // model is working — with no duration to judge it by — reads exactly like + // a turn that has hung. Name the state and show how long it has run. expect(presentation).toMatchObject({ - label: "Working", + label: "Background work ×2", tone: "blue", glyph: "working", }); - expect(presentation?.showsElapsed).toBe(false); + expect(presentation?.showsElapsed).toBe(true); }); it("shows Waiting only for an idle chat with a valid future wake", () => { diff --git a/apps/desktop/src/shared/sessionStatusPresentation.ts b/apps/desktop/src/shared/sessionStatusPresentation.ts index 89d5d6fe7..a9f386bab 100644 --- a/apps/desktop/src/shared/sessionStatusPresentation.ts +++ b/apps/desktop/src/shared/sessionStatusPresentation.ts @@ -169,15 +169,24 @@ export function sessionStatusPresentation( }; } - if ( - (phase === "ready" || phase === "idle") - && (activity.activeBackgroundTaskCount ?? 0) > 0 - ) { + // The turn is over but a backgrounded job outlives it. This previously read + // as a bare "Working" with no duration, which is indistinguishable from a + // live turn that has stalled — the row claimed the model was thinking when it + // had already finished, and offered no elapsed to judge it by. Name the state + // for what it is and let the row show its elapsed. + // + // That elapsed is time since the session's last activity (the caller supplies + // the anchor — see `SessionStatusSlot`), NOT the job's own runtime, which is + // not in the session summary. It is a proxy: a job launched early in a long + // turn reads ~0s at turn end. `showsElapsed` also re-enables the breathing + // animation, which is intended — background work genuinely is a live state. + const backgroundJobCount = activity.activeBackgroundTaskCount ?? 0; + if ((phase === "ready" || phase === "idle") && backgroundJobCount > 0) { return { - label: "Working", + label: backgroundJobCount > 1 ? `Background work ×${backgroundJobCount}` : "Background work", tone: "blue", glyph: "working", - showsElapsed: false, + showsElapsed: true, prominent: false, }; } diff --git a/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift b/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift index 0579d6a39..64f79aeba 100644 --- a/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift @@ -2842,8 +2842,19 @@ private func eventCard( return nil case .scheduledWorkUpdate(_, let kind, let status, _, let title, let summary, let prompt, let reason, let cron, let nextRunAt, _, _, _, _, _, _, _, let turnId, let error): // Background shell commands are owned by the Chat Info pane's Background - // section (and a compact timeline finish chip). Mirrors desktop, which - // stops rendering an inline scheduled-work card for background_task. + // section (and a compact timeline finish chip). + // + // KNOWN DIVERGENCE — desktop no longer drops these. It folds the + // background_task stream into a single live `background_job_line` in the + // thread (see `chatTranscriptRows.ts`), anchored at the job's first + // sighting and mutated in place at exit. Because the live Claude runtime + // reports a backgrounded shell ONLY through this stream — never as + // subagent lifecycle events — a running job currently has no mobile + // timeline presence at all; only legacy `subagent_*` transcripts produce + // the iOS finish chip. + // + // This is unported work, not a deliberate platform difference. Porting it + // needs a machine that can compile and run the iOS target. guard kind.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() != "background_task" else { return nil } diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md index 598c93f15..0ff2a6e17 100644 --- a/docs/features/chat/README.md +++ b/docs/features/chat/README.md @@ -76,7 +76,7 @@ for its separate RPC, sync, storage, and UI contracts. | `apps/desktop/src/main/services/opencode/openCodeInventory.ts` | OpenCode provider/model probe. Now classifies model variants into `reasoningTiers` + `serviceTiers` (alias map covering `minimal`/`mini`/`med`/`xhigh`/`extra-high`), reads `capabilities` (tools/vision/reasoning) into descriptor capabilities, and tracks both `modelIds` (connected providers only) and `catalogModelIds` (the full browseable catalog). Anthropic rows normalize generic `opus` to Opus 5 with its `high` default reasoning effort and Fast capability; retired Sonnet 4.6 / basic Opus 4.7 ids still resolve to Sonnet 5 / Opus 4.8 so runtime catalogs cannot reintroduce removed picker rows. `OpenCodeProviderInfo.availableModelCount` exposes the connected count separately from `modelCount`. **Cross-launch persistence:** `persistOpenCodeInventory(projectRoot, providers)` writes each successful probe's provider list (keyed by project root, with `savedAt`) to `opencode-inventory-cache.json` under Electron `userData` (override via `ADE_OPENCODE_INVENTORY_CACHE_FILE`); on a cold start the Settings page reloads that persisted list flagged stale (`opencodeProvidersStale`) so the ~160-provider chip cloud renders immediately instead of blanking until the first live probe (stale-while-revalidate). Writes are best-effort and never break the probe. | | `apps/desktop/src/main/services/opencode/openCodeAuthService.ts` | Drives the managed OpenCode server's auth API for subscription connect + API-key seeding, reusing the shared inventory server lease (never spawning its own process). `listAuthMethods` reads `GET /provider/auth`; `startOAuth` authorizes (`POST /provider/{id}/oauth/authorize`), opens the returned URL, and polls `provider.list().connected` every 2s until connected or a 5-min timeout, re-probing inventory on success; `cancelOAuth` stops the poller; `setProviderKey` does `PUT /auth/{id}` and mirrors the key into ADE's `apiKeyStore` so it is re-injected on future launches. One flow per `providerId` at a time (a new start supersedes the prior). Transitions are published through `addOpenCodeOAuthStatusListener` (`pending`/`connected`/`cancelled`/`timeout`/`failed`), a multi-sink fan-out so the same event reaches desktop windows and the remote/web runtime event buffer. Seeded credentials land in ADE's isolated managed OpenCode dir (XDG roots under `userData/opencode-runtime/xdg-v*`), never the user's `~/.local/share/opencode`. | | `apps/desktop/src/shared/chatTranscript.ts` | Pure JSON-lines parser for `AgentChatEventEnvelope` values. Used by both the main process and the renderer. | -| `apps/desktop/src/shared/chatSubagents.ts` | Cross-target subagent helpers: `normalizeSubagentLifecycleEvent` (canonicalizes legacy `subagent_*` and dotted `subagent.*` envelopes), the stable `groupPaneSectionItems` partition and pane caps, `buildSubagentPaneRows`, tagged pane click targets, `buildSubagentTranscriptEvents`, `isLifecycleEventForSnapshot`, plus the `latestPlan` derivation. The partition keeps source order, forces pinned rows into the active cap, and excludes visually cleared Completed ids. It also owns the shared subagent-vs-background classification (`isBackgroundShellCommand`, `isRealSubagent`, `isNonAgentTaskRun`, `subagentAgentKey`) — `isNonAgentTaskRun` flags a `task_type` `other` run with no agent metadata (a plain Claude Code task, not a subagent) so both the idle-turn and foreground paths keep it out of the roster. Claude's raw `local_bash` kind is normalized only after explicit background evidence (`background_tasks_changed`, `is_backgrounded`, or `run_in_background`) because foreground Bash emits the same kind. The file also owns summary-quality helpers and `deriveSubagentTimelineRows` → `SubagentTimelineRow` (`spawn` / `result` / `background_chip`). Desktop consumes the partition directly; ADE Code consumes the expanded row model; iOS mirrors the same predicates and caps. | +| `apps/desktop/src/shared/chatSubagents.ts` | Cross-target subagent helpers: `normalizeSubagentLifecycleEvent` (canonicalizes legacy `subagent_*` and dotted `subagent.*` envelopes), the stable `groupPaneSectionItems` partition and pane caps, `buildSubagentPaneRows`, tagged pane click targets, `buildSubagentTranscriptEvents`, `isLifecycleEventForSnapshot`, plus the `latestPlan` derivation. The partition keeps source order, forces pinned rows into the active cap, and excludes visually cleared Completed ids. It also owns the shared subagent-vs-background classification (`isBackgroundShellCommand`, `isRealSubagent`, `isNonAgentTaskRun`, `subagentAgentKey`) — `isNonAgentTaskRun` flags a `task_type` `other` run with no agent metadata (a plain Claude Code task, not a subagent) so both the idle-turn and foreground paths keep it out of the roster. Claude's raw `local_bash` kind is normalized only after explicit background evidence (`background_tasks_changed`, `is_backgrounded`, or `run_in_background`) because foreground Bash emits the same kind. The file also owns summary-quality helpers and `deriveSubagentTimelineRows` → `SubagentTimelineRow` (`spawn` / `result` / `background_chip`) — the portable timeline shape iOS mirrors, not what the desktop transcript renders; that pipeline is `chatTranscriptRows.ts`. Desktop consumes the partition directly; ADE Code consumes the expanded row model; iOS mirrors the same predicates and caps. | | `apps/desktop/src/shared/chatScheduledWork.ts` | Cross-target scheduled-work validation and derivation. `resolveScheduledWorkTiming` accepts exactly one timing form: five-field brain-local cron, offset-qualified absolute `runAt`, or relative `delaySeconds`; it rejects ambiguous, past, non-integer, and unrepresentable schedules before persistence. The rest of the module folds `scheduled_work_update` envelopes into stable snapshots for Claude wakeups, cron tasks, `/loop`, remote triggers, and background work, then merges the transcript projection with the KV-backed management snapshot from `AgentChatSessionSummary.scheduledWork`. The merge removes stale active durable transcript rows that no longer exist in the management store, preserves provider-only/non-durable activity for display, and marks only ADE-managed rows as cancellable. It also partitions rows by surface: `deriveScheduleItems` returns schedule kinds (`wakeup` / `cron` / `loop` / `remote_trigger`) while `deriveBackgroundItems` returns `background_task` rows that do not duplicate a real subagent with the same `sourceTaskId`. A parent turn's terminal event does not coerce surviving background work to stopped; only an explicit work terminal state or runtime teardown does. `isEarlierBackgroundItem`, `isFiredOneShotWakeup`, and `isEarlierScheduleItem` define the shared Earlier membership mirrored by ADE Code and iOS. | | `apps/desktop/src/main/services/chat/claudeWorkflowProgress.ts` | Defensive normalizer for the Claude Agent SDK's undocumented `workflow_progress` snapshot on `system:task_progress` (Workflow orchestration runs). Parses phases + per-agent entries (caps counts, clips previews, drops malformed entries, unknown states degrade to queued/running; unparseable snapshots return undefined so the generic task rendering is untouched), then `planClaudeWorkflowAgentTransitions` diffs each cumulative tick against per-task emit state to fan out `subagent_started/progress/result` events under a stable `::a` identity with the emitted agentId latched at first emission. Consumed by `agentChatService`'s `task_progress`/`task_notification` handlers and the interrupt path (which close still-running agents as `stopped`). | | `apps/desktop/src/shared/chatMosaic.ts` | Mosaic v1 — agent-emitted interactive cards. Strict versioned (`"v":1`) parser for ```` ```mosaic ```` fence bodies (`parseMosaicCard`: unknown version/element types, duplicate ids, or malformed JSON → null → callers render the plain fence), submission serializer (`serializeMosaicSubmission`: readable lines + machine JSON, sent through the normal `agentChat.send` path with `displayText`), and `summarizeMosaicCard` for the TUI's one-line summary. Data only — no expressions, no eval, no host actions. Schema documented for agents in the `ade-mosaic` Agent Skill (`apps/desktop/resources/agent-skills/ade-mosaic/SKILL.md`). | @@ -91,12 +91,12 @@ for its separate RPC, sync, storage, and UI contracts. | `apps/desktop/src/renderer/lib/draftLaunchJobs.ts` | Shared renderer helper for Work draft-launch job DTOs and pruning. Owns `NativeControlState`, `DraftLaunchSnapshot`, `PreparedDraftLaunch`, `DraftLaunchJobStatus`, `DraftLaunchJob`, `isDraftLaunchJobTerminal`, `isDraftLaunchJobStale`, and `pruneDraftLaunchJobs`; active jobs are kept ahead of terminal rows, with terminal rows filling the remaining retained slots and at least one terminal row retained alongside active jobs. Also owns the launch durability constants/helpers: `DRAFT_LAUNCH_TIMEOUT_MS` (90 s) + `withDraftLaunchTimeout(promise, label)` (rejects a launch step whose runtime call never settles; the underlying IPC is not cancellable, so on timeout it keeps running detached and the timeout only unwedges the renderer-side job) and `LAUNCH_PROJECT_CHANGED_MESSAGE` (the legacy/unpinned abort error used only when no originating project binding is available and the active project drifts mid-launch). | | `apps/desktop/src/renderer/lib/handoffLaunchJobs.ts` | Shared renderer helper for in-flight chat handoff placeholders. Defines the handoff job DTO, scope keying, mode-aware status labels (`preparing-summary` for brief, `forking-history` for fork), search matching, the stable placeholder id used by the Work session sidebar, and `handoffJobLikelyMaterialized` — the ADE-122 dedupe that hides a placeholder as soon as a matching real session row (same lane + tool type, started at/after the job began) is visible, so an in-flight handoff never reads as two new sessions with one vanishing. | | `apps/desktop/src/renderer/state/appStore.ts` | Shared renderer state store. Besides project/lane/work selection, it persists user preferences such as `launchPromptClipboardEnabled`, `launchPromptClipboardNoticeEnabled`, and the default-on `promptStashButtonEnabled`, mirrors them into per-project stores, and owns `draftLaunchJobsByScope` (+ `setDraftLaunchJobs`) for Work draft launch status strips plus `handoffLaunchJobsByScope` (+ `setHandoffLaunchJobs`) for Work sidebar handoff placeholders. These live in the **root** store (not the per-project store) on purpose: in-flight launches must survive a remote project switch that destroys the originating per-project store; `AgentChatPane` reads them via `useRootAppStore` / `rootAppStoreApi.getState()`. | -| `apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx` | Virtualized transcript renderer. Coalesces resize / measurement updates and, while sticky-to-bottom is active, follows height changes across multiple animation frames so streamed output and late row measurements do not leave the user above the newest message. Programmatic scroll writes are tracked by target scroll position, not a stale counter, so browser-coalesced scroll events do not swallow the next real user gesture. Workspace paths in Markdown links and inline code render with an explicit file glyph/click treatment and navigate with a relative path + lane id; `FilesTab` resolves that target against the active runtime's workspace roster, so the same click opens the correct file for local and remote-bound desktop projects without treating a remote path as a local OS file. Activity bundles fold todo/scheduled updates and placeholder subagent updates into compact rows, dedupe placeholder subagent parents once the concrete child id is known, and open Chat Info / subagent detail instead of duplicating the drawer roster inline; real subagents instead render as inline `SubagentSpawnCard` / `SubagentResultCard` rows anchored where they started and settled (background shell commands collapse to a single `BackgroundFinishChip`), with jump-to-result / jump-to-start affordances that reuse the stable-row scroll machinery; a run of two or more interrupt-stopped subagents folds into one calm `SubagentStoppedGroupCard` instead of a wall of identical stopped cards. Synthetic scheduled turns render an amber `Woke on schedule` divider with fire time, reason, and late marker; the existing stable-row scroll machinery accepts jump requests from the while-you-were-away strip. Completed-turn dividers bucket chat-owned proof by capture timestamp, expose a collapsed `N proof` chip, and expand the filmstrip directly beneath the producing turn; there is no proof footer pinned to the tail. Codex goal lifecycle events render as compact user-facing rows (`Goal set`, `Goal paused`, `Goal cleared`) instead of raw JSON-RPC/status wording. Codex runtime notices (`codex_safety_buffering`, `codex_moderation_metadata`, `codex_sleep`, `codex_thread_deleted`) render as small transcript chips. `codex_turn_stalled` renders a live recovery card: Wait re-arms the watchdog, Nudge sends a status steer, Retry interrupts and replays work in the same thread, and Resume restarts app-server, resumes the thread, and retries. Handoff brief user messages with `metadata.hideFullPrompt` show only their `displayText` breadcrumb and do not expose or copy the internal prompt body. History seeded into a forked chat (envelopes tagged `providerOrigin: "handoff_fork"`) renders under a single `Forked from the previous chat — full history above` divider pinned to the first live row after the seeded tail, rather than a per-row marker. Error events whose `errorInfo.agentCli.category` is `"unauthenticated"` render as the calm `AgentCliAuthCard` (raw 401 behind a `Details` disclosure) rather than the red error block, so a recoverable logout reads as a re-login prompt, not a crash. | +| `apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx` | Virtualized transcript renderer. Coalesces resize / measurement updates and, while sticky-to-bottom is active, follows height changes across multiple animation frames so streamed output and late row measurements do not leave the user above the newest message. Programmatic scroll writes are tracked by target scroll position, not a stale counter, so browser-coalesced scroll events do not swallow the next real user gesture. Workspace paths in Markdown links and inline code render with an explicit file glyph/click treatment and navigate with a relative path + lane id; `FilesTab` resolves that target against the active runtime's workspace roster, so the same click opens the correct file for local and remote-bound desktop projects without treating a remote path as a local OS file. Activity bundles fold todo/scheduled updates and placeholder subagent updates into compact rows, dedupe placeholder subagent parents once the concrete child id is known, and open Chat Info / subagent detail instead of duplicating the drawer roster inline; real subagents instead render as inline `SubagentSpawnCard` / `SubagentResultCard` rows anchored where they started and settled (a backgrounded shell command instead gets a single `BackgroundJobLine` that covers its whole life), with jump-to-result / jump-to-start affordances that reuse the stable-row scroll machinery; a run of two or more interrupt-stopped subagents folds into one calm `SubagentStoppedGroupCard` instead of a wall of identical stopped cards. Synthetic scheduled turns render an amber `Woke on schedule` divider with fire time, reason, and late marker; the existing stable-row scroll machinery accepts jump requests from the while-you-were-away strip. Completed-turn dividers bucket chat-owned proof by capture timestamp, expose a collapsed `N proof` chip, and expand the filmstrip directly beneath the producing turn; there is no proof footer pinned to the tail. Codex goal lifecycle events render as compact user-facing rows (`Goal set`, `Goal paused`, `Goal cleared`) instead of raw JSON-RPC/status wording. Codex runtime notices (`codex_safety_buffering`, `codex_moderation_metadata`, `codex_sleep`, `codex_thread_deleted`) render as small transcript chips. `codex_turn_stalled` renders a live recovery card: Wait re-arms the watchdog, Nudge sends a status steer, Retry interrupts and replays work in the same thread, and Resume restarts app-server, resumes the thread, and retries. Handoff brief user messages with `metadata.hideFullPrompt` show only their `displayText` breadcrumb and do not expose or copy the internal prompt body. History seeded into a forked chat (envelopes tagged `providerOrigin: "handoff_fork"`) renders under a single `Forked from the previous chat — full history above` divider pinned to the first live row after the seeded tail, rather than a per-row marker. Error events whose `errorInfo.agentCli.category` is `"unauthenticated"` render as the calm `AgentCliAuthCard` (raw 401 behind a `Details` disclosure) rather than the red error block, so a recoverable logout reads as a re-login prompt, not a crash. The live turn's `working for ` counter is painted imperatively through a callback ref that survives the status line's mid-turn remount (see [composer-and-ui.md](composer-and-ui.md)). It also owns `registerChatInfoHost()`, a module-level registry read through `useSyncExternalStore`: `AgentChatPane` registers because it owns the chat actions pane and listens for `ade:chat:open-info`, `PersonalChatsPage` does not, so transcript affordances that reveal that pane render only where dispatching would actually do something. | | `apps/desktop/src/renderer/components/chat/chatHistoryWindow.ts` | Shared bounded-history policy for project and personal desktop chats: canonical event identity, byte estimates and resident caps, page-seam merging, strict cursor advancement, bounded continuation through empty physical pages, and stale-request predicates. Snapshot cursor reconciliation preserves a known exhausted head only when the authoritative refresh overlaps the current window and retains its oldest event; replacement snapshots and cap eviction re-arm paging. | | `apps/desktop/src/renderer/components/chat/chatAppearance.ts` | Chat density/font geometry plus the single responsive transcript width contract. `--chat-content-width` is `min(100%, clamp(720px, 62vw, 1180px))`; `--chat-column` aliases it so prose, composer, cards, pills, plans, file changes, and floating-pane reserve math share one viewport-scaling measure. | | `apps/desktop/src/renderer/components/chat/chatCardPrimitives.tsx` | Shared transcript-card vocabulary: one `[16px glyph | flexible content | auto meta]` grid, line/inset/bordered/rail/plain skins, status tones, chips/meters/detail rows/diff stats, human-readable agent identity and schedule formatting, and the collapsible proof filmstrip. Passing one-line facts use a hairline row; live/detail-bearing rows use inset chrome; failures use an amber rail rather than a red block. | | `apps/desktop/src/renderer/components/chat/AdeCard.tsx` | Provider-independent `ade_card` renderer built only from the shared primitives. Shape follows state rather than variant: terminal success is one line, live work adds progress, failures show only warning rows, unknown variants fall back to text + deeplink, and degraded re-emits preserve prior detail as stale rather than blanking it. | -| `apps/desktop/src/renderer/components/chat/SubagentActivityCards.tsx` | Inline subagent transcript cards mounted by `AgentChatMessageList` from the render events `chatTranscriptRows.ts` derives. `SubagentSpawnCard` anchors where the agent started (identicon/colour from `chatSubagentIdentity`, task title, agent-type/background chips, a single live `running · · tools · ` line that ticks each second, and a `jump to result` link once the agent ends); `SubagentResultCard` renders at the settle position (status + duration, ~2-line report preview, View transcript, `jump to start`, warm amber tones for stopped/failed instead of red error blocks); `BackgroundFinishChip` is the one-line finish chip for backgrounded shell commands; `SubagentStoppedGroupCard` collapses a run of interrupt-stopped subagents into one amber "N agents stopped when you interrupted" line that expands to a per-agent list with `jump to start` links. All inherit `--chat-accent`. | +| `apps/desktop/src/renderer/components/chat/SubagentActivityCards.tsx` | Inline subagent transcript cards mounted by `AgentChatMessageList` from the render events `chatTranscriptRows.ts` derives. `SubagentSpawnCard` anchors where the agent started (identicon/colour from `chatSubagentIdentity`, task title, agent-type/background chips, a single live `running · · tools · ` line that ticks each second, and a `jump to result` link once the agent ends); `SubagentResultCard` renders at the settle position (status + duration, ~2-line report preview, View transcript, `jump to start`, warm amber tones for stopped/failed instead of red error blocks); `BackgroundJobLine` is the whole in-thread presence of a backgrounded shell command — one quiet centered rule-line in the scheduled-wake/spawn-return divider idiom (deliberately not a card), pushed when the job starts with a live ticking elapsed and mutated in place to `✓/✗ · exit · ` when it exits, with an `open` affordance that dispatches `ade:chat:open-info` to reveal the actions pane's agents tab where the job's full state lives — omitted entirely on a host that registers no chat-info listener, so it is never a button that does nothing. Its ticker is the file's one shared `useLiveDurationMs` hook (also driving the spawn card), anchored to the real start timestamp so scrolling the row out of the virtualizer and back keeps the true elapsed; it freezes on an ended session, and a job still marked `running` there drops its duration entirely rather than assert an elapsed nobody should read. Status glyphs are Phosphor components, not bare `⚙`/`✓`/`✗` codepoints, which Windows resolves to off-baseline emoji; `SubagentStoppedGroupCard` collapses a run of interrupt-stopped subagents into one amber "N agents stopped when you interrupted" line that expands to a per-agent list with `jump to start` links. All inherit `--chat-accent`. | | `apps/desktop/src/renderer/components/chat/spawnNavigation.ts` | One canonical `navigateToSpawnedChat(sessionId, laneId?)` helper that dispatches the `ade:work:select-session` window event (behind a try/catch, no-op on a falsy id). Every spawn surface routes through it: the inline `SubagentSpawnCard`, `spawn_wake_divider` and `spawn_completed` completion rows, spawned-chat rows in `ChatSubagentsPanel`, the `AgentChatPane` parent-thread breadcrumb, and the `SessionCard` lineage glyph. `TerminalsPage` resolves an omitted lane from the loaded session list before focusing the target, so cross-lane jumps land on the correct lane. | | `apps/desktop/src/renderer/components/chat/ChatActionsDrawerPanel.tsx`, `ChatSourcesPanel.tsx`, `chatSources.ts` | Codex Chat Actions source inventory. Sources is the first available tab and derives a deduplicated list of attachments/files, web searches/results, MCP apps/tools, and external resource URLs from the current transcript. HTTP(S) rows open in ADE's built-in browser; internal `node_repl` plumbing and unsafe protocols are excluded. | | `apps/desktop/src/renderer/components/chat/ChatGitToolbar.tsx` | Git / PR quick-action toolbar above the composer. If the lane already has a linked PR, the PR button opens or toggles that PR; otherwise it routes to the PR workspace with a create-PR handoff (`create=1&sourceLaneId=&target=primary`). When the chat PR pane or compact PR menu opens, it asks `prReadCache.refreshLinkedPrCoalesced` for a targeted `prs.refresh({ prIds })` so the badge picks up merged/closed/check transitions without broad GitHub polling. An unmapped lane PR (a `github_pr_projections`-derived summary with `pr.unmapped === true` and a synthetic `gh:` id) has no DB row to refresh or fetch checks for, so both the live refresh and `getChecks` are skipped for it. The toolbar is a **status strip only** — the manual PR-sync (↻) control lives in the PR pane's title bar, so surfaces that render the toolbar without a PR pane heal through reconcile-on-focus and `prs-updated` instead. It takes an optional `runtimePin`: a lane's PR row lives in its own machine's database, so a chat on another machine reads and subscribes through that machine's runtime rather than showing the bare create-PR button for a session that already has one. Effects key on the pin's `key`, not the object, which is rebuilt on every cross-machine merge. Under a pin the unpinned `diff.getChanges` status read is skipped and PR *creation* is withheld — see [Pull requests](../pull-requests/README.md#which-machine-answers-a-pr-read). | @@ -120,7 +120,7 @@ for its separate RPC, sync, storage, and UI contracts. | `apps/desktop/src/renderer/components/chat/ChatSurfaceShell.tsx` | Shell that wraps every chat surface (desktop pane, mobile lane, CTO chat) with a unified header/footer slot and `--chat-accent` CSS variable. Supports a `layoutVariant="mobile"` mode that the iOS companion mirrors. | | `apps/desktop/src/renderer/components/chat/chatSurfaceTheme.ts` | Chat chrome tokens. Exports `PROVIDER_CHAT_ACCENTS` (claude → amber, codex → warm white, cursor → violet, opencode → blue, etc.) and `providerChatAccent(provider)`. iOS mirrors this table in `ADEDesignSystem.swift`. | | `apps/desktop/src/renderer/components/chat/AskQuestionComposer.tsx` | The ask-question surface, anchored **in the composer** — it replaces the textarea inside the same prompt-box frame while a question blocks (there is no longer a separate `AgentQuestionModal`, no `InlineQuestionRequestCard`, and no question-kind `pendingBanner`). Header is the provider mark + a kind-derived verb (`{Provider} asks` / `{Provider} · Plan ready` via `pendingInputHeaderLabel`) plus a dot rail for paged sets, a minimize `⌄`, and a decline `×`; body shows the question's `header` kicker then the question text once; options render as a one-column ledger with radio/checkbox a11y roles and a flush-right `✓`; option previews render through `QuestionOptionPreview` — a column-preserving monospace `
` for wireframes/ASCII (detected via `looksLikeWireframe`) and the code-fence-aware `ChatMarkdown` for prose — inside a natural-height, capped option region, disclosed by an explicit click rather than hover. Only genuinely long option content scrolls; header, note row, and footer stay pinned. Chrome inherits `--chat-accent` (per-provider), used in exactly two places plus one structural hairline. Keyboard: `1-9` pick, `↵` next/send, `←→` page, `esc` decline. Selecting marks and never submits; a pick and a typed note both travel (see `shared/pendingInputAnswers.ts`). Nothing is preselected. `QuestionReceipts.tsx` renders the transcript record: an "awaiting you" row while open, a one-line expandable receipt once resolved. |
-| `apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts` | Two-layer event-to-row pipeline (render events + grouped envelopes) that powers the message list. It threads per-subagent anchor state through the collapse pass to emit identity-keyed `subagent_spawn_anchor` / `subagent_result_card` / `background_finish_chip` render events (keys `subagent-spawn:` / `subagent-result:` / `background-chip:`), mutating anchors in place as progress/result events arrive and repairing row positions when a `transcript_retraction` splices a row out — so the virtualizer's measured heights survive rebind. It derives a `scheduled_wake_divider` immediately before every synthetic `user_message` carrying `metadata.scheduledWake` and a `spawn_wake_divider` before completion deliveries carrying `metadata.spawnCompletion`; the latter renders as **Subagent returned** whether the completion steered an active turn or woke an idle chat. It also diffs `todo_update` snapshots per turn so only changed tasks render, normalizes dotted `subagent.*` lifecycle events into the legacy renderer shape while providers migrate, and falls back to a full collapse when incremental append would miss todo state. A second-layer grouping pass (`groupStoppedSubagentResultCards`) folds a run of two or more consecutive interrupt-stopped `subagent_result_card` rows into one `subagent_stopped_group` event; completed/failed cards and a lone stopped card stay individual. |
+| `apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts` | Two-layer event-to-row pipeline (render events + grouped envelopes) that powers the message list. It threads per-subagent anchor state through the collapse pass to emit identity-keyed `subagent_spawn_anchor` / `subagent_result_card` / `background_job_line` render events (keys `subagent-spawn:` / `subagent-result:` / `background-chip:`), mutating anchors in place as progress/result events arrive and repairing row positions when a `transcript_retraction` splices a row out — so the virtualizer's measured heights survive rebind. `background_job_line` is upserted by two producers on one shared key space — the live runtime's `scheduled_work_update {kind:"background_task"}` and legacy `subagent_*` events carrying `taskType: background` — so a transcript holding both shapes still renders exactly one row per job; a settled row is never reopened by a late progress tick, a task that has opened a job line stays a background job even if a late `agentType` would reclassify it, and a real subagent reported through the background stream has its job line spliced out before its spawn anchor lands. It derives a `scheduled_wake_divider` immediately before every synthetic `user_message` carrying `metadata.scheduledWake` and a `spawn_wake_divider` before completion deliveries carrying `metadata.spawnCompletion`; the latter renders as **Subagent returned** whether the completion steered an active turn or woke an idle chat. It also diffs `todo_update` snapshots per turn so only changed tasks render, normalizes dotted `subagent.*` lifecycle events into the legacy renderer shape while providers migrate, and falls back to a full collapse when incremental append would miss todo state. A second-layer grouping pass (`groupStoppedSubagentResultCards`) folds a run of two or more consecutive interrupt-stopped `subagent_result_card` rows into one `subagent_stopped_group` event; completed/failed cards and a lone stopped card stay individual. |
 | `apps/desktop/src/main/services/ai/tools/` | Tool tiers consumed by the service when it provisions a Claude/Codex/OpenCode runtime (see [Tool System](tool-system.md)). |
 | `apps/desktop/src/main/services/ipc/registerIpc.ts` | Validates chat IPC args, exposes `agentChat.*` handlers (including scheduled-work create, list, per-job cancel, and per-chat pause), persists/retrieves parallel launch recovery state in `kv`, and refreshes the runtime scheduler after the global AI config pause changes. |
 | `apps/desktop/src/shared/ipc.ts` | `ade.agentChat.*` IPC channel constants. |
diff --git a/docs/features/chat/composer-and-ui.md b/docs/features/chat/composer-and-ui.md
index 300207270..971a3d858 100644
--- a/docs/features/chat/composer-and-ui.md
+++ b/docs/features/chat/composer-and-ui.md
@@ -18,7 +18,7 @@ subagents, computer use). The pane derives all visible state from the
 | `apps/desktop/src/renderer/lib/handoffLaunchJobs.ts` | Pure helper for handoff placeholder DTOs, scope keys, stable placeholder ids, status labels, and search matching. `AgentChatPane` writes these jobs into the root store while `TerminalsPage` passes matching jobs into the Work session sidebar. The local handoff surface offers a brief summarized handoff or a full-history fork whenever the source provider is fork-capable (`providerSupportsHandoffFork`: Claude, Codex, OpenCode, Droid); Cursor is brief-only. Fork keeps the new chat on the same provider and lane while allowing the target model to change; Claude forks the SDK session pointer, Codex the app-server thread (`thread/fork`), OpenCode `session.fork`, and Droid `forkSession()`. |
 | `apps/desktop/src/renderer/lib/aiDiscoveryCache.ts` | Runtime-binding-scoped AI integration-status and provider-model cache shared across renderer surfaces. Local and remote checkouts with the same project identity cannot share model/auth state. `getAiStatusCached` uses a 10-second freshness window and deduplicates concurrent `ade.ai.getStatus` requests; cache update/invalidation events let open ModelPickers react without polling or mounting their own background refresh loops. |
 | `CrossMachineHandoffModal.tsx`, `crossMachineHandoffPresentation.tsx` | Modal state and user flow for **Send to machine**. It verifies a local source lane, follows live remote connection snapshots, lets the user pick brief or full-history fork (fork defaults on for fork-capable providers and constrains the model picker to the same provider), lets the user set the destination chat's model, reasoning effort, fast mode, and permission mode with the same shared pills the composer uses, handles existing-project versus confirmed-clone setup, offers a destination-run fast-forward when the target lane is clean and strictly behind the source commit, decodes destination responses at the renderer boundary, pins acceptance to the reviewed route kind, and exposes retryable source-marker failures after destination success. Source blockers render through `BlockedReasons` / `BlockedActionButton` instead of silently disabling Continue. The pure half — stage/mode types, `SourceCheck`, branch/route/repo-readiness copy, permission tone and icon maps, send-step labels, `CheckRow` — lives in `crossMachineHandoffPresentation.tsx` so it is assertable without mounting the stateful modal. Once destination acceptance is dispatched, a runtime timeout or connection interruption produces an amber unknown-outcome notice: the destination chat may still appear, the user should check that machine before retrying, and the modal never reports a truthful cancellation that the runtime did not perform. A fork that the destination can't accept (older ADE with no `forkHandoffSupport`, oversize history, or an unforkable provider file) surfaces a plain reason and a one-click **send as brief** that re-runs prepare + preflight; the insecure-route consent line is fork-aware (a fork discloses that the full history is sent exactly as recorded, a brief that only the summary is sent). |
-| `AgentChatMessageList.tsx` | Virtualized message list. The virtualizer is **hand-rolled**, not `@tanstack/react-virtual`: a `measuredHeights` row-key → height `Map` feeds top/bottom spacer divs around the rendered window, and each rendered row is wrapped in `MeasuredEventRow`, whose `ResizeObserver` reports its real height through `handleMeasure` → `reconcileMeasuredScrollTop` so a height correction above the viewport does not shift what the reader is looking at. Renders transcript rows and turn dividers, including a `Woke on schedule` divider before every synthetic scheduled turn and inline `SubagentSpawnCard` / `SubagentResultCard` / `BackgroundFinishChip` rows (from `SubagentActivityCards.tsx`) for real subagents and backgrounded shell commands, and accepts stable row-key jump requests from the while-you-were-away strip and the spawn/result jump affordances. Keeps sticky-bottom sessions pinned across streamed row growth and late virtual-height measurements. A Claude `queue_recovery: available` row renders one eight-second Undo card; later `restored`/`expired` rows settle the same recovery id so history replay cannot show a stale action. The last text block of a multi-block assistant turn exposes Copy turn, which joins only that turn's assistant text blocks with blank lines; legacy rows without a turn id and single-block turns keep only the normal block copy. Plan-approval rows with non-empty body text render a scrollable markdown block (capped at `360px`) beneath the header so the user can review plan content inline. Codex goal lifecycle rows use user-facing text such as `Goal set`, `Goal paused`, and `Goal cleared`. A stalled Codex turn renders a clickable Wait / Nudge / Retry / Resume recovery card wired to `agentChat.recoverCodexTurn`; terminal provider capacity/usage-limit errors render `ProviderFailureRecoveryCard` with same-thread retry and model-selection actions. User messages marked `metadata.hideFullPrompt` render and copy only their `displayText`, keeping internal handoff briefs out of the visible transcript details, and a handoff-brief user row shows a small brief chip. When a fork seeds pre-fork history into the new chat, the envelopes carry the `handoff_fork` provider origin and the list draws a single `Forked from the previous chat — full history above` divider (`computeForkHistoryDividerRowKey` pins it to the first live row after the seeded history) instead of one marker per seeded row. |
+| `AgentChatMessageList.tsx` | Virtualized message list. The virtualizer is **hand-rolled**, not `@tanstack/react-virtual`: a `measuredHeights` row-key → height `Map` feeds top/bottom spacer divs around the rendered window, and each rendered row is wrapped in `MeasuredEventRow`, whose `ResizeObserver` reports its real height through `handleMeasure` → `reconcileMeasuredScrollTop` so a height correction above the viewport does not shift what the reader is looking at. Renders transcript rows and turn dividers, including a `Woke on schedule` divider before every synthetic scheduled turn and inline `SubagentSpawnCard` / `SubagentResultCard` / `BackgroundJobLine` rows (from `SubagentActivityCards.tsx`) for real subagents and backgrounded shell commands, and accepts stable row-key jump requests from the while-you-were-away strip and the spawn/result jump affordances. Keeps sticky-bottom sessions pinned across streamed row growth and late virtual-height measurements. A Claude `queue_recovery: available` row renders one eight-second Undo card; later `restored`/`expired` rows settle the same recovery id so history replay cannot show a stale action. The last text block of a multi-block assistant turn exposes Copy turn, which joins only that turn's assistant text blocks with blank lines; legacy rows without a turn id and single-block turns keep only the normal block copy. Plan-approval rows with non-empty body text render a scrollable markdown block (capped at `360px`) beneath the header so the user can review plan content inline. Codex goal lifecycle rows use user-facing text such as `Goal set`, `Goal paused`, and `Goal cleared`. A stalled Codex turn renders a clickable Wait / Nudge / Retry / Resume recovery card wired to `agentChat.recoverCodexTurn`; terminal provider capacity/usage-limit errors render `ProviderFailureRecoveryCard` with same-thread retry and model-selection actions. User messages marked `metadata.hideFullPrompt` render and copy only their `displayText`, keeping internal handoff briefs out of the visible transcript details, and a handoff-brief user row shows a small brief chip. When a fork seeds pre-fork history into the new chat, the envelopes carry the `handoff_fork` provider origin and the list draws a single `Forked from the previous chat — full history above` divider (`computeForkHistoryDividerRowKey` pins it to the first live row after the seeded history) instead of one marker per seeded row. `WorkingIndicator` is the in-flight turn's status line — ` · working for `, plus a `taking longer than usual` marker past `LONG_RUNNING_TURN_SECONDS`. Its elapsed is written imperatively (`textContent` on a ref) rather than through state, so the once-per-second tick never commits a render on the message list. The line swaps a bare `` for an expander `