diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx index fbbbdd82b..250917d6e 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx @@ -1,7 +1,7 @@ /* @vitest-environment jsdom */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { MemoryRouter, useLocation } from "react-router-dom"; import type { AgentChatApprovalDecision, @@ -66,6 +66,7 @@ import { deriveTurnModelState, findAnchoredChatEventIndex, formatElapsedSeconds, + ChatInfoHostContext, getTranscriptCollapseCacheKeysForTests, reconcileMeasuredScrollTop, resetTranscriptCollapseCacheForTests, @@ -119,6 +120,7 @@ function renderMessageList( assistantLabel?: string; initialState?: Record; showStreamingIndicator?: boolean; + sessionEnded?: boolean; sessionId?: string | null; transcriptCollapseCacheKey?: string | null; laneId?: string | null; @@ -146,6 +148,7 @@ function renderMessageList( events={events} assistantLabel={options?.assistantLabel} showStreamingIndicator={options?.showStreamingIndicator} + sessionEnded={options?.sessionEnded} sessionId={options?.sessionId} transcriptCollapseCacheKey={options?.transcriptCollapseCacheKey} laneId={options?.laneId} @@ -3171,6 +3174,155 @@ describe("AgentChatMessageList transcript rendering", () => { expect(transcriptOnly.container.textContent).not.toContain("Running command"); }); + it("keeps the elapsed timer ticking when the first tool call wraps the status line in a button", () => { + // The status line renders bare while a turn has no tool activity and moves + // inside 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..008cfcced 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,115 @@ 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("drops the job line when a real subagent's ONLY lifecycle event is its result", () => { + // Reachable from a truncated or replayed transcript: history paging can + // drop `subagent_started` while the scheduled-work update survives. The + // guard used to live only on the spawn path, so this ordering left the job + // line in the transcript beside the agent's card pair. + const rows = 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:30.000Z", { + type: "subagent_result", + taskId: "agent-1", + agentType: "Explore", + status: "completed", + summary: "found it", + }), + ]); + + expect(rows.map((row) => row.event.type)).not.toContain("background_job_line"); + expect(rows.map((row) => row.event.type)).toEqual(["subagent_result_card"]); + }); + + 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..d194b5d88 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; @@ -274,8 +302,10 @@ type TodoUpdateTranscriptEvent = Extract; + /** + * `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 +400,7 @@ export function createCollapseTranscriptContext(): CollapseTranscriptContext { recoveryRowIndexByTurn: new Map(), stalledRowIndexByTurn: new Map(), adeCardRowIndexById: new Map(), + backgroundJobRowIndexByKey: new Map(), }; } @@ -965,7 +1009,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 +1035,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 +1054,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 +1070,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 +1084,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 +1170,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( @@ -1203,9 +1371,11 @@ function classificationInput(state: SubagentAnchorState) { * Handle one of the three subagent lifecycle events. Returns true if the event * was consumed (caller should stop). Mutates rows and context in place. * - * INVARIANT: this never SPLICES rows — only pushes at the tail or replaces an - * existing row by its stored index. Every replacement verifies the stable key - * and repairs a stale position before mutating. + * INVARIANT: the ONLY splice is the stale background-job-line drop below, which + * repairs every stored position through `repairIndexedTranscriptRowsAfterSplice`. + * Card rows are never spliced — only pushed at the tail or replaced by their + * stored index, and every replacement verifies the stable key and repairs a + * stale position before mutating. */ function handleSubagentLifecycleEvent( rows: ChatTranscriptRenderEnvelope[], @@ -1239,7 +1409,7 @@ function handleSubagentLifecycleEvent( renderKeyBase: agentKey, rowIndex: null, resultRowIndex: null, - chipRowIndex: null, + backgroundLineOpened: false, description: null, agentType: null, taskType: null, @@ -1267,10 +1437,51 @@ function handleSubagentLifecycleEvent( } enrichSubagentStateFromEvent(state, event); - const backgroundShell = isBackgroundShellCommand(classificationInput(state)); + const backgroundShell = state.backgroundLineOpened + || isBackgroundShellCommand(classificationInput(state)); + + // The counterpart to the real-subagent guard in the `background_task` handler: + // 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 any card lands, or the agent renders as a job line AND a card pair. + // + // Hoisted above the event-type split deliberately: a truncated or replayed + // transcript can deliver `subagent_result` as a task's ONLY lifecycle event, + // which never passes through the spawn branch. + // + // Note the two guards are counterparts, NOT complements: that one requires + // `isRealSubagent`, this one only requires "not proven a background shell". + // A task with neither `taskType` nor `agentType` satisfies neither, so it can + // still have its line re-created by a later scheduled-work update. That gap is + // unreachable today — the runtime emits no lifecycle events for background + // tasks — so both sides are deliberately left as they are rather than flipped + // blind to a predicate the tests do not pin. + if (!backgroundShell) { + for (const identity of [state.renderKeyBase, agentKey, taskId]) { + if (identity) removeBackgroundJobLine(rows, context, backgroundChipKey(identity)); + } + } 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; + } if (state.rowIndex == null) { // First lifecycle → push the spawn anchor and record its index. state.status = "running"; @@ -1312,29 +1523,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 +2058,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 +2523,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/personalChats/PersonalChatsPage.test.tsx b/apps/desktop/src/renderer/components/personalChats/PersonalChatsPage.test.tsx index 293fdda43..98464ca8f 100644 --- a/apps/desktop/src/renderer/components/personalChats/PersonalChatsPage.test.tsx +++ b/apps/desktop/src/renderer/components/personalChats/PersonalChatsPage.test.tsx @@ -47,6 +47,7 @@ vi.mock("../shared/ModelPicker/ReasoningEffortPicker", () => ({ type MessageListHarnessProps = { events: AgentChatEventEnvelope[]; sessionId?: string | null; + sessionEnded?: boolean; hasOlderHistory?: boolean; loadingOlderHistory?: boolean; olderHistoryError?: string | null; @@ -71,6 +72,7 @@ vi.mock("../chat/AgentChatMessageList", () => ({ data-has-older={props.hasOlderHistory ? "true" : "false"} data-loading-older={props.loadingOlderHistory ? "true" : "false"} data-older-error={props.olderHistoryError ?? ""} + data-session-ended={props.sessionEnded ? "true" : "false"} >