From 461f55590191a45f75d15ae35f3c5e9932ffbbfe Mon Sep 17 00:00:00 2001 From: elijah Date: Mon, 14 Sep 2026 22:34:58 +0800 Subject: [PATCH 1/3] Fold settled turns' interjections, status rows, and delegated runs into the work trail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While a turn is live nothing changes: advisor notes and IRC messages land on their own labeled rows, status rows mark the stream, and delegated runs keep their pinned rows so there is somewhere to watch. Once the turn settles, all of it becomes work like any other call — the transcript reads as prompt, one fold line, and the answer, with every note and run a click away inside the trail. A run that died keeps its own row even then: it parks under the fold line and opens onto the provider's reason, exactly as it did while live. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../AgentTranscript.interjection.test.ts | 30 ++- src/surfaces/AgentTranscript.test.ts | 192 +++++++++++----- src/surfaces/AgentTranscript.tsx | 206 +++++++++++++++--- src/surfaces/transcriptActivity.test.ts | 202 +++++++++++++++++ src/surfaces/transcriptActivity.ts | 80 +++++-- 5 files changed, 601 insertions(+), 109 deletions(-) diff --git a/src/surfaces/AgentTranscript.interjection.test.ts b/src/surfaces/AgentTranscript.interjection.test.ts index 01ba5016..4afe6a8b 100644 --- a/src/surfaces/AgentTranscript.interjection.test.ts +++ b/src/surfaces/AgentTranscript.interjection.test.ts @@ -40,6 +40,9 @@ afterEach(() => { vi.unstubAllGlobals(); }); +// The labeled divider is the live rendering — the note lands while the turn +// is still going. A settled turn folds it into the work trail as one compact +// line instead, which the last test covers. function render(text: string, severity: "blocker" | "concern" = "concern") { const blocks: Block[] = [{ id: "advisor", @@ -47,7 +50,7 @@ function render(text: string, severity: "blocker" | "concern" = "concern") { text, interjection: { customType: "advisor", severity }, }]; - act(() => root.render(createElement(AgentTranscript, { blocks, busy: false }))); + act(() => root.render(createElement(AgentTranscript, { blocks, busy: true }))); } const note = "Check the fallback.\n```ts\nconst result = read();\nif (!result) throw new Error('missing');\n```"; @@ -86,4 +89,29 @@ describe("AgentTranscript interjection preview", () => { act(() => resize()); expect(container.querySelector("button")).toBeNull(); }); + + it("settles into the work trail as one compact line that opens to the full note", () => { + const blocks: Block[] = [{ + id: "advisor", + role: "system", + text: note, + interjection: { customType: "advisor", severity: "concern" }, + }]; + act(() => + root.render(createElement(AgentTranscript, { blocks, busy: false })), + ); + // No divider, no clamped body: one line in the trail, named and + // severity-marked, that opens on a click. + expect(container.querySelector('[role="separator"]')).toBeNull(); + const button = container.querySelector( + 'button[aria-expanded="false"]', + )!; + expect(button).not.toBeNull(); + expect(button.textContent).toContain("Advisor"); + expect(button.textContent).toContain("Concern"); + expect(button.textContent).toContain("Check the fallback."); + act(() => button.click()); + expect(button.getAttribute("aria-expanded")).toBe("true"); + expect(container.querySelector("pre")?.textContent).toBe(note); + }); }); diff --git a/src/surfaces/AgentTranscript.test.ts b/src/surfaces/AgentTranscript.test.ts index e3c2d88c..809606d5 100644 --- a/src/surfaces/AgentTranscript.test.ts +++ b/src/surfaces/AgentTranscript.test.ts @@ -190,7 +190,33 @@ describe("AgentTranscript collapsed work", () => { }); it("opens a failed subagent's own row on its provider reason", () => { - const markup = render([ + const markup = render( + [ + { id: "user", role: "user", text: "Delegate this", startedAt: 1_000 }, + { + id: "agent", + role: "tool", + text: "Inspect auth", + tool: { + callId: "agent-1", + kind: "agent", + status: "failed", + detail: "Child process disconnected", + }, + }, + { id: "answer", role: "assistant", text: "I could not finish." }, + ], + // Live keeps the run pinned on its own row; settled keeps it there too — + // a run that died parks under the fold line, already open on the reason. + true, + ); + + expect(markup).toContain("Inspect auth"); + expect(markup).toContain("failed"); + expect(markup).toContain("Child process disconnected"); + expect(markup).toContain("Hide Inspect auth's work"); + + const settledMarkup = render([ { id: "user", role: "user", text: "Delegate this", startedAt: 1_000 }, { id: "agent", @@ -205,11 +231,8 @@ describe("AgentTranscript collapsed work", () => { }, { id: "answer", role: "assistant", text: "I could not finish." }, ]); - - expect(markup).toContain("Inspect auth"); - expect(markup).toContain("failed"); - expect(markup).toContain("Child process disconnected"); - expect(markup).toContain("Hide Inspect auth's work"); + expect(settledMarkup).toContain("Child process disconnected"); + expect(settledMarkup).toContain("Hide Inspect auth's work"); }); it("gives each running subagent its own row above the work that folds", () => { @@ -309,26 +332,29 @@ describe("AgentTranscript collapsed work", () => { }); it("groups an opened subagent's trail the way the main transcript does", () => { - const markup = render([ - { id: "user", role: "user", text: "Review this", durationMs: 4_000 }, - { - id: "a1", - role: "tool", - // A failed run opens itself, which is the only way to see an open - // panel without a click. - text: "Correctness review", - tool: { callId: "agent-1", kind: "agent", status: "failed" }, - agentRun: { - name: "Correctness review", - steps: [ - { id: "s1", kind: "message", text: "Reading the diff first." }, - { id: "s2", kind: "tool", text: "Read src/App.tsx" }, - { id: "s3", kind: "tool", text: "Read src/lib/session.ts" }, - ], + const markup = render( + [ + { id: "user", role: "user", text: "Review this", durationMs: 4_000 }, + { + id: "a1", + role: "tool", + // A failed run opens itself, which is the only way to see an open + // panel without a click. + text: "Correctness review", + tool: { callId: "agent-1", kind: "agent", status: "failed" }, + agentRun: { + name: "Correctness review", + steps: [ + { id: "s1", kind: "message", text: "Reading the diff first." }, + { id: "s2", kind: "tool", text: "Read src/App.tsx" }, + { id: "s3", kind: "tool", text: "Read src/lib/session.ts" }, + ], + }, }, - }, - { id: "answer", role: "assistant", text: "It could not finish." }, - ]); + { id: "answer", role: "assistant", text: "It could not finish." }, + ], + true, + ); // The run's own words title a group, with the calls they introduced under // it — not one flat dump of every step it took. @@ -349,28 +375,31 @@ describe("AgentTranscript collapsed work", () => { }); it("opens a lone subagent straight into its own transcript", () => { - const markup = render([ - { id: "user", role: "user", text: "Review this", durationMs: 4_000 }, - { - id: "a1", - role: "tool", - text: "Correctness review", - tool: { callId: "agent-1", kind: "agent", status: "completed" }, - agentRun: { - name: "Correctness review", - steps: [ - { - id: "s1", - kind: "tool", - text: "Read src/App.tsx", - status: "completed", - }, - { id: "s2", kind: "message", text: "Nothing to flag." }, - ], + const markup = render( + [ + { id: "user", role: "user", text: "Review this", durationMs: 4_000 }, + { + id: "a1", + role: "tool", + text: "Correctness review", + tool: { callId: "agent-1", kind: "agent", status: "completed" }, + agentRun: { + name: "Correctness review", + steps: [ + { + id: "s1", + kind: "tool", + text: "Read src/App.tsx", + status: "completed", + }, + { id: "s2", kind: "message", text: "Nothing to flag." }, + ], + }, }, - }, - { id: "answer", role: "assistant", text: "Clean." }, - ]); + { id: "answer", role: "assistant", text: "Clean." }, + ], + true, + ); // One agent needs no stack header: its own row is the row. expect(markup).not.toContain("Show every subagent"); @@ -405,18 +434,23 @@ describe("AgentTranscript collapsed work", () => { }); it("renders an advisor interjection between answered work phases", () => { - const markup = render([ - tool("before"), - { id: "answer", role: "assistant", text: "Complete answer." }, - { - id: "advisor", - role: "system", - text: "Check the fallback.", - interjection: { customType: "advisor", severity: "concern" }, - }, - tool("after"), - { id: "ack", role: "assistant", text: "Checked." }, - ]); + // Live: the interjection lands on its own labeled row. Once the turn + // settles it folds into the work trail — covered below. + const markup = render( + [ + tool("before"), + { id: "answer", role: "assistant", text: "Complete answer." }, + { + id: "advisor", + role: "system", + text: "Check the fallback.", + interjection: { customType: "advisor", severity: "concern" }, + }, + tool("after"), + { id: "ack", role: "assistant", text: "Checked." }, + ], + true, + ); expect(markup).toContain("Complete answer."); expect(markup).toContain('aria-label="Interjection: Advisor"'); @@ -424,6 +458,48 @@ describe("AgentTranscript collapsed work", () => { expect(markup).toContain("Check the fallback."); expect(markup).toContain("Checked."); }); + + it("folds a settled turn's interjections into the work trail", () => { + const blocks: Block[] = [ + { id: "user", role: "user", text: "Keep me posted" }, + tool("t1"), + { + id: "i1", + role: "system", + text: "ping from #general", + interjection: { customType: "irc:incoming" }, + }, + { + id: "i2", + role: "system", + text: "another ping", + interjection: { customType: "irc:incoming" }, + }, + tool("t2"), + { + id: "i3", + role: "system", + text: "last ping", + interjection: { customType: "irc:incoming" }, + }, + { id: "answer", role: "assistant", text: "The investigation is complete." }, + ]; + + const settled = render(blocks); + // One fold line for the whole trail: the calls, and the notes they + // absorbed. The dividers themselves stay behind the fold until opened. + expect(settled).toContain("Ran 2 commands · 3 notes"); + expect(settled).toContain("The investigation is complete."); + expect(settled).not.toContain('aria-label="Interjection:'); + expect(settled).not.toContain("ping from #general"); + + // While the turn is live the same notes still land as their own rows. + const live = render(blocks, true); + expect( + live.match(/aria-label="Interjection: irc:incoming"/g), + ).toHaveLength(3); + expect(live).toContain("ping from #general"); + }); }); describe("worker assignment prompts", () => { diff --git a/src/surfaces/AgentTranscript.tsx b/src/surfaces/AgentTranscript.tsx index 83cbeca1..3fbe6ad2 100644 --- a/src/surfaces/AgentTranscript.tsx +++ b/src/surfaces/AgentTranscript.tsx @@ -61,6 +61,7 @@ import { type AgentStep, type Block, type HarnessId, + type InterjectionMeta, type PlanBuildTarget, type ToolPreview, type TurnMetrics, @@ -87,6 +88,7 @@ import { groupTurns, initialThinkingIndex, isIncompleteTool, + isSubagentBlock, isThinkingBlock, lastActivityIndex, isProseBlock, @@ -396,6 +398,7 @@ function AgentTranscriptComponent({ // of the live work and append them after all of the lead's output. const items = groupTurnItems( turn.filter((block) => !block.orchestration), + { settled }, ); // Earlier activity groups have already been followed by prose or // more work. Only the last one can still be the live group. @@ -1688,28 +1691,14 @@ function SubagentStack({ onOpenFile?: (path: string) => void; onOpenDiff?: (path: string) => void; }) { - // A run that died opens itself, so the provider's reason is not buried - // behind a face that looks like every other finished one. A click takes the - // row over from there and it stays where the reader puts it. - const [override, setOverride] = useState>({}); - const isOpen = (block: Block) => - override[block.id] ?? toolCallState(block) === "rejected"; - return (
{blocks.map((block) => ( - - setOverride((current) => ({ - ...current, - [block.id]: !isOpen(block), - })) - } onOpenFile={onOpenFile} onOpenDiff={onOpenDiff} /> @@ -1718,6 +1707,41 @@ function SubagentStack({ ); } +/** + * One delegated run's row, stateful about being opened. A run that died opens + * itself, so the provider's reason is not buried behind a face that looks like + * every other finished one. A click takes the row over from there and it stays + * where the reader puts it. The same row serves inside a settled turn's trail, + * where the run sits as one step of the work it was spawned from. + */ +function SubagentRow({ + block, + cwd, + live = false, + onOpenFile, + onOpenDiff, +}: { + block: Block; + cwd?: string; + live?: boolean; + onOpenFile?: (path: string) => void; + onOpenDiff?: (path: string) => void; +}) { + const [override, setOverride] = useState(null); + const open = override ?? toolCallState(block) === "rejected"; + return ( + setOverride(!open)} + onOpenFile={onOpenFile} + onOpenDiff={onOpenDiff} + /> + ); +} + /** * One delegated run: its name, what it is doing, and — once opened — the trail * it left, with the same tool rows, thinking and prose the main transcript @@ -1994,6 +2018,12 @@ function ActivityRow({ /> ); } + if (block.interjection) { + return ; + } + if (block.role === "system") { + return ; + } if (isProseBlock(block)) { return ( ); } + // Only a settled turn routes a delegated run here; live turns pin the row + // outside the trail. Either way it is the same row, so it still opens onto + // the agent's own work. + if (isSubagentBlock(block)) { + return ( + + ); + } return ( + + {block.text.trim()} + +
+ ); +} + +/** + * An interjection inside the work trail: one compact line naming where it came + * from and what it said. It opens on a click, so folding the work never costs + * you a note you wanted to read. + */ +function ActivityInterjectionRow({ block }: { block: Block }) { + const [open, setOpen] = useState(false); + const meta = block.interjection; + if (!meta) return null; + const chrome = interjectionChrome(meta); + const summary = proseSummary(block.text); + const label = ( + + {chrome.label} + {chrome.severityText ? ( + + {" "} + {chrome.severityText} + + ) : null} + {summary ? ( + + {" · "} + {summary} + + ) : null} + + ); + + if (!block.text.trim()) { + return ( +
+ {label} +
+ ); + } + + return ( +
+ + {open ? ( +
+
{block.text}
+
+ ) : null} +
+ ); +} + /** * The line that keeps a long think from reading as a stall. Opening the fold * around it does not open the thought itself — reasoning is only ever read on @@ -2701,6 +2821,39 @@ function HandoffDivider({ block }: { block: Block }) { ); } +/** The label and severity chrome an interjection wears, wherever it sits. */ +function interjectionChrome(meta: InterjectionMeta): { + label: string; + severityText?: string; + severityClass: string; +} { + const label = + meta.customType === "advisor" + ? "Advisor" + : meta.customType === "custom" + ? "Notice" + : meta.customType; + const severityText = + meta.severity === "blocker" + ? "Blocker" + : meta.severity === "concern" + ? "Concern" + : meta.severity === "nit" + ? "Nit" + : undefined; + const severityClass = + meta.severity === "blocker" + ? "text-red-400" + : meta.severity === "concern" + ? "text-amber-400" + : "text-content/55"; + return { label, severityText, severityClass }; +} + +/** The advisory body under an interjection, wherever the note is surfaced. */ +const INTERJECTION_BODY = + "min-w-0 whitespace-pre-wrap break-words font-sans text-[12.5px] leading-5 text-content/70"; + /** A mid-turn interjection, e.g. OMP advisor notes: a labeled boundary with * a collapsible advisory body below it. */ function InterjectionDivider({ block }: { block: Block }) { @@ -2727,26 +2880,7 @@ function InterjectionDivider({ block }: { block: Block }) { const meta = block.interjection; if (!meta) return null; - const label = - meta.customType === "advisor" - ? "Advisor" - : meta.customType === "custom" - ? "Notice" - : meta.customType; - const severityText = - meta.severity === "blocker" - ? "Blocker" - : meta.severity === "concern" - ? "Concern" - : meta.severity === "nit" - ? "Nit" - : undefined; - const severityClass = - meta.severity === "blocker" - ? "text-red-400" - : meta.severity === "concern" - ? "text-amber-400" - : "text-content/55"; + const { label, severityText, severityClass } = interjectionChrome(meta); return (
@@ -2769,7 +2903,7 @@ function InterjectionDivider({ block }: { block: Block }) {
             {block.text}
           
diff --git a/src/surfaces/transcriptActivity.test.ts b/src/surfaces/transcriptActivity.test.ts index ec7c49f0..f5c327bc 100644 --- a/src/surfaces/transcriptActivity.test.ts +++ b/src/surfaces/transcriptActivity.test.ts @@ -22,6 +22,8 @@ import { toolCallLabel, turnCopyText, subagentModelName, + workKind, + workSummaryLine, } from "./transcriptActivity"; function shell( @@ -90,6 +92,19 @@ function thought(id: string, text = "Weighing the options."): Block { return { id, role: "reasoning", text }; } +function status(id: string, text = "Advisor reviewed this turn"): Block { + return { id, role: "system", text }; +} + +function irc(id: string, text = "new message in #general"): Block { + return { + id, + role: "system", + text, + interjection: { customType: "irc:incoming" }, + }; +} + describe("groupTurnItems", () => { it("keeps consecutive shell calls in one activity stack", () => { const items = groupTurnItems([ @@ -755,6 +770,193 @@ describe("the subagent stack", () => { }); }); +describe("the settled work trail", () => { + const agent = (id: string, name = "Correctness review"): Block => ({ + id, + role: "tool", + text: name, + tool: { kind: "agent", title: name, status: "completed" }, + }); + + it("keeps a status row inside the surrounding work, live or settled", () => { + for (const options of [undefined, { settled: false }, { settled: true }]) { + const items = groupTurnItems( + [shell("a"), status("st"), shell("b")], + options, + ); + expect(items).toHaveLength(1); + expect(items[0]).toMatchObject({ + type: "activity", + blocks: [{ id: "a" }, { id: "st" }, { id: "b" }], + }); + } + }); + + it("keeps an interjection on its own row while live, folds it in once settled", () => { + const turn = [shell("a"), irc("i1"), shell("b")]; + expect(groupTurnItems(turn).map((item) => item.type)).toEqual([ + "activity", + "block", + "activity", + ]); + const items = groupTurnItems(turn, { settled: true }); + expect(items).toHaveLength(1); + expect(items[0]).toMatchObject({ + type: "activity", + blocks: [{ id: "a" }, { id: "i1" }, { id: "b" }], + }); + }); + + it("pins a delegated run while live, folds it into the trail once settled", () => { + const turn = [shell("a"), agent("ag"), shell("b")]; + expect(groupTurnItems(turn).map((item) => item.type)).toEqual([ + "activity", + "subagents", + "activity", + ]); + const items = groupTurnItems(turn, { settled: true }); + expect(items).toHaveLength(1); + if (items[0]?.type !== "activity") return; + expect(items[0].blocks.map((block) => block.id)).toEqual([ + "a", + "ag", + "b", + ]); + expect(workSummaryLine(items[0].blocks)).toBe( + "Ran 2 commands · Ran a subagent", + ); + }); + + it("keeps a failed run on its own row once settled, outside the fold", () => { + const items = groupTurnItems( + [ + { id: "u", role: "user", text: "go" }, + shell("t1"), + agent("ag", "Correctness review"), + { + id: "dead", + role: "tool", + text: "Quality review", + tool: { kind: "agent", title: "Quality review", status: "failed" }, + }, + shell("t2"), + { id: "done", role: "assistant", text: "It could not finish." }, + ], + { settled: true }, + ); + expect(items.map((item) => item.type)).toEqual([ + "block", + "activity", + "subagents", + "activity", + "block", + ]); + // The fold spans the failed run's row, which the transcript parks under + // the fold line rather than collapsing into it — so the reason it died + // stays one click away, exactly as it was while live. + const fold = foldableWork(items)!; + expect(fold).toEqual({ start: 1, end: 3 }); + expect(foldedBlocks(items, fold).map((block) => block.id)).toEqual([ + "t1", + "ag", + "t2", + ]); + }); + + it("spans the whole trail once settled, status rows and notes included", () => { + const items = groupTurnItems( + [ + { id: "u", role: "user", text: "go" }, + shell("t1"), + status("st"), + shell("t2"), + irc("i1"), + irc("i2"), + shell("t3"), + { id: "done", role: "assistant", text: "All set." }, + ], + { settled: true }, + ); + const fold = foldableWork(items); + expect(fold).toEqual({ start: 1, end: 1 }); + const folded = foldedBlocks(items, fold!); + expect(folded.map((block) => block.id)).toEqual([ + "t1", + "st", + "t2", + "i1", + "i2", + "t3", + ]); + const summary = workSummaryLine(folded); + expect(summary).toBe("Ran 3 commands · 2 notes"); + expect(summary).not.toContain("Advisor reviewed"); + }); + + it("leaves notes that arrive after the answer as their own trail under it", () => { + const items = groupTurnItems( + [ + { id: "u", role: "user", text: "go" }, + shell("t1"), + { id: "done", role: "assistant", text: "All set." }, + irc("i1"), + irc("i2"), + ], + { settled: true }, + ); + expect(items.map((item) => item.type)).toEqual([ + "block", + "activity", + "block", + "activity", + ]); + // The fold covers the work the answer answered for; the answer itself and + // the notes after it stay out. + expect(foldableWork(items)).toEqual({ start: 1, end: 1 }); + const trailing = items[3]; + if (trailing?.type !== "activity") throw new Error("expected activity"); + const phases = buildActivityPhases(trailing.blocks); + expect(phases).toHaveLength(1); + expect(phases[0].kind).toBe("note"); + expect(activityPhaseTitle(phases[0])).toBe("2 notes"); + expect(workKind(trailing.blocks)).toBe("note"); + }); + + it("lets the settled fold reach across an interjection that stops it live", () => { + const turn = [ + { id: "u", role: "user", text: "go" }, + shell("t1"), + note("mid", "Halfway there."), + irc("i1"), + shell("t2"), + note("done", "All set."), + ]; + // Live: the interjection stands alone and bounds the fold. + expect(foldableWork(groupTurnItems(turn))).toEqual({ start: 4, end: 4 }); + // Settled: it joins the trail and the fold spans the turn's work. + const items = groupTurnItems(turn, { settled: true }); + const fold = foldableWork(items)!; + expect(fold).toEqual({ start: 1, end: 3 }); + expect(foldedBlocks(items, fold).map((block) => block.id)).toEqual([ + "t1", + "mid", + "i1", + "t2", + ]); + }); + + it("never counts a status row as running work", () => { + expect(activityStillRunning([status("st")])).toBe(false); + expect( + activityStillRunning([ + shell("done"), + status("st"), + status("st2", "Working on it"), + ]), + ).toBe(false); + }); +}); + describe("foldableWork", () => { const items = (blocks: Block[]) => groupTurnItems(blocks); diff --git a/src/surfaces/transcriptActivity.ts b/src/surfaces/transcriptActivity.ts index 21367345..031647eb 100644 --- a/src/surfaces/transcriptActivity.ts +++ b/src/surfaces/transcriptActivity.ts @@ -110,9 +110,16 @@ export function isHiddenTool(block: Block): boolean { /** * Foldable work: tool calls, thinking, and edits. An edit still awaiting * approval stays out — you cannot judge a diff you cannot see. + * + * A status row ("Advisor reviewed this turn") is turn chrome, not transcript + * text: it joins the work around it instead of splitting the group. An + * interjection stays a standalone block while the turn is live — the reader + * should see it land — and joins the trail only once the turn settles, which + * is the caller's branch to make. */ export function isActivityBlock(block: Block): boolean { if (isThinkingBlock(block)) return true; + if (block.role === "system") return !block.interjection; if (block.role !== "tool" && block.role !== "approval") return false; if ( isEditTool( @@ -207,8 +214,17 @@ export function groupTurns(blocks: Block[], managed = false): Block[][] { * Fold contiguous runs of tool calls and reasoning into activity groups. * Assistant prose always stands on its own, including progress updates between * groups, so the readable transcript never disappears into activity chrome. + * + * A settled turn puts every kind of process into the trail: interjections and + * delegated runs, which keep their own rows while the turn is live so the + * reader sees them land and knows where to watch, fold in once there is + * nothing left to watch — what remains is the prompt, the work, and the answer. */ -export function groupTurnItems(blocks: Block[]): TurnItem[] { +export function groupTurnItems( + blocks: Block[], + options?: { settled?: boolean }, +): TurnItem[] { + const settled = options?.settled ?? false; const visible = withoutSupersededInitialThinking( blocks.filter( (block) => !isIgnoredTurnBlock(block) && !isHiddenTool(block), @@ -223,17 +239,23 @@ export function groupTurnItems(blocks: Block[]): TurnItem[] { activity = []; }; visible.forEach((block) => { - // Delegated runs never join the work trail. Their row is the one thing in - // a turn that has to stay put: it is where the user goes to watch, and the - // trail around it folds and re-folds while the subagent is still going. - if (isSubagentBlock(block)) { + // Delegated runs keep their own rows while the turn is live. Their row is + // the one thing in a turn that has to stay put: it is where the user goes + // to watch, and the trail around it folds and re-folds while the subagent + // is still going. Once the turn settles they are work like any other call + // — except one that died: a failed run keeps its own row under the fold, + // where it opens itself onto the reason rather than folding out of sight. + if ( + isSubagentBlock(block) && + (!settled || toolCallState(block) === "rejected") + ) { flush(); const last = items[items.length - 1]; if (last?.type === "subagents") last.blocks.push(block); else items.push({ type: "subagents", blocks: [block] }); return; } - if (isActivityBlock(block)) { + if (isActivityBlock(block) || (settled && !!block.interjection)) { activity.push(block); return; } @@ -506,6 +528,14 @@ export function buildActivityPhases(blocks: Block[]): ActivityPhase[] { } continue; } + // Status rows and interjections are steps, never headlines: a note the + // turn absorbed joins the group it landed in rather than titling one. + if (block.role === "system") { + if (!current) current = open("note"); + current.steps.push(block); + if (!current.id) current.id = block.id; + continue; + } if (!current) current = open(toolCategory(block)); current.steps.push(block); // Count each call once. Rescanning the growing group here makes long @@ -551,6 +581,8 @@ type PhaseTally = { runs: number; agents: number; others: number; + /** Interjections the turn absorbed. Status rows count nowhere. */ + notes: number; }; function tallySteps(steps: Block[]): PhaseTally { @@ -562,8 +594,13 @@ function tallySteps(steps: Block[]): PhaseTally { runs: 0, agents: 0, others: 0, + notes: 0, }; for (const block of steps) { + if (block.interjection) { + tally.notes += 1; + continue; + } if (!isToolBlock(block)) continue; const kind = block.tool?.kind; const title = block.text || block.tool?.title; @@ -648,20 +685,33 @@ function currentWorkKind(steps: Block[]): ActivityWorkKind | undefined { * What a run of work adds up to, one clause per kind: "Read 3 files · Edited 2 * files · Ran a command". While the run is live, the clause for the call in * flight is present tense, so "Running 2 commands" settles to "Ran 2 commands" - * when it folds. + * when it folds. Interjections the turn absorbed ride along as a trailing + * "N notes" clause; a group holding nothing but notes is just that clause. */ export function workSummaryLine(steps: Block[], live = false): string { const tally = tallySteps(steps); - if (tally.order.length === 0) return live ? "Thinking" : "Thought"; + const notes = + tally.notes === 1 + ? "1 note" + : tally.notes > 1 + ? `${tally.notes} notes` + : ""; + if (tally.order.length === 0) { + return notes || (live ? "Thinking" : "Thought"); + } const running = live ? currentWorkKind(steps) : undefined; - return tally.order - .map((kind) => workSummary(kind, tally, kind === running)) - .join(" · "); + return [ + ...tally.order.map((kind) => workSummary(kind, tally, kind === running)), + ...(notes ? [notes] : []), + ].join(" · "); } /** The icon a run of work answers to: whatever it did most of. */ export function workKind(steps: Block[]): ActivityPhaseKind { - return dominantWorkKind(steps) ?? "think"; + return ( + dominantWorkKind(steps) ?? + (steps.some((block) => block.interjection) ? "note" : "think") + ); } /** @@ -693,8 +743,10 @@ export type WorkFold = { start: number; end: number }; * reopen that boundary so its controls remain available. * * Persisted interjections (system blocks with interjection chrome) are neither - * prose nor work, so they stop the fold: an answer the harness already showed - * never folds behind an interjection that arrived after it. + * prose nor work, so while the turn is live they stand on their own and stop + * the fold: an answer the harness already showed never folds behind an + * interjection that arrived after it. A settled turn groups them into the + * trail itself, where the fold simply spans them. */ export function foldableWork(items: TurnItem[]): WorkFold | undefined { let end = -1; From 93d15ec8132e62ebec869992b6d8f2a9cdb7e720 Mon Sep 17 00:00:00 2001 From: elijah Date: Tue, 15 Sep 2026 10:17:58 +0800 Subject: [PATCH 2/3] Mute prose inside the work fold so it reads as process, not result MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A mid-turn note rendered with the same markdown chrome as the final answer, so an opened fold could pass itself off as the result it led to. Folded prose now sits one notch quieter: dimmed ink, headings demoted to bold lines, tighter rhythm — readable, but clearly the agent talking while it works. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/index.css | 58 +++++++++++++ .../AgentTranscript.foldProse.test.ts | 81 +++++++++++++++++++ src/surfaces/AgentTranscript.tsx | 8 ++ 3 files changed, 147 insertions(+) create mode 100644 src/surfaces/AgentTranscript.foldProse.test.ts diff --git a/src/index.css b/src/index.css index f73604d0..520347c8 100644 --- a/src/index.css +++ b/src/index.css @@ -1127,6 +1127,64 @@ header[data-tauri-drag-region] button { border-bottom-left-radius: 8px; } +/* + * Prose the fold holds is the agent talking while it works — process, not + * result. It stays readable at one notch quieter: muted ink, headings + * demoted to bold lines, and tighter spacing, so a mid-run note never poses + * as the answer it led to. + */ +.zen-fold-prose .agent-markdown > :not(:last-child) { + margin-block-end: 0.5rem; +} + +.zen-fold-prose .agent-markdown p, +.zen-fold-prose .agent-markdown li { + color: color-mix(in srgb, var(--color-content) 65%, transparent); +} + +.zen-fold-prose .agent-markdown p strong, +.zen-fold-prose .agent-markdown p em, +.zen-fold-prose .agent-markdown p [data-streamdown="strong"], +.zen-fold-prose .agent-markdown li strong, +.zen-fold-prose .agent-markdown li em, +.zen-fold-prose .agent-markdown li [data-streamdown="strong"] { + color: color-mix(in srgb, var(--color-content) 80%, transparent) !important; +} + +/* A heading in a work note is a bold line, not a display size. */ +.zen-fold-prose .agent-markdown [data-streamdown="heading-1"], +.zen-fold-prose .agent-markdown [data-streamdown="heading-2"], +.zen-fold-prose .agent-markdown [data-streamdown="heading-3"], +.zen-fold-prose .agent-markdown [data-streamdown="heading-4"], +.zen-fold-prose .agent-markdown [data-streamdown="heading-5"], +.zen-fold-prose .agent-markdown [data-streamdown="heading-6"] { + margin-top: 0.75rem; + margin-bottom: 0.25rem; + font-size: 14px; + font-weight: 600; + color: color-mix(in srgb, var(--color-content) 80%, transparent); +} + +.zen-fold-prose .agent-markdown [data-streamdown="blockquote"], +.zen-fold-prose .agent-markdown .markdown-code-shell { + margin-block: 0.5rem; +} + +.zen-fold-prose .agent-markdown [data-streamdown="code-block"], +.zen-fold-prose .agent-markdown [data-streamdown="table-wrapper"] { + border-color: color-mix(in srgb, var(--color-content) 8%, transparent); + background: color-mix(in srgb, var(--color-content) 4%, transparent); +} + +.zen-fold-prose .agent-markdown [data-streamdown="table-header-cell"], +.zen-fold-prose .agent-markdown [data-streamdown="table-cell"] { + color: color-mix(in srgb, var(--color-content) 65%, transparent); +} + +.zen-fold-prose .agent-markdown [data-streamdown="horizontal-rule"] { + margin-block: 0.75rem; +} + .zen-fold-item[data-fold-state="closed"] { display: grid; grid-template-rows: 0fr; diff --git a/src/surfaces/AgentTranscript.foldProse.test.ts b/src/surfaces/AgentTranscript.foldProse.test.ts new file mode 100644 index 00000000..d9bb2b51 --- /dev/null +++ b/src/surfaces/AgentTranscript.foldProse.test.ts @@ -0,0 +1,81 @@ +// @vitest-environment happy-dom +import { act, createElement } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { Block } from "../lib/session"; +import { AgentTranscript } from "./AgentTranscript"; + +let container: HTMLDivElement; +let root: Root; + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + disconnect() {} + }, + ); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); +}); + +function tool(id: string): Block { + return { + id, + role: "tool", + text: `Inspect ${id}`, + tool: { kind: "shell", status: "completed" }, + }; +} + +describe("prose folded into the work trail", () => { + it("marks a mid-turn note as process, leaving the answer at full strength", () => { + const blocks: Block[] = [ + { id: "user", role: "user", text: "Keep me posted" }, + tool("t1"), + { id: "note", role: "assistant", text: "Trying the other config." }, + tool("t2"), + { + id: "answer", + role: "assistant", + text: "The investigation is complete.", + }, + ]; + act(() => root.render(createElement(AgentTranscript, { blocks }))); + + // The trail stays collapsed until asked for, so none of what it holds — + // the note included — is in the DOM yet. + expect(container.querySelector(".zen-fold-prose")).toBeNull(); + const toggle = container.querySelector( + 'button[aria-label="Show the work"]', + )!; + expect(toggle).not.toBeNull(); + + act(() => toggle.click()); + + // Only the mid-turn note carries the marker; the tool rows it sits + // between stay ordinary rail entries. + const prose = container.querySelectorAll(".zen-fold-prose"); + expect(prose).toHaveLength(1); + expect(prose[0].textContent).toContain("Trying the other config."); + expect(prose[0].textContent).not.toContain( + "The investigation is complete.", + ); + + // The answer renders the same markdown root, outside the demoted wrapper. + const answer = Array.from( + container.querySelectorAll(".agent-markdown"), + ).find((el) => el.textContent?.includes("The investigation is complete.")); + expect(answer).not.toBeUndefined(); + expect(answer?.closest(".zen-fold-prose")).toBeNull(); + }); +}); diff --git a/src/surfaces/AgentTranscript.tsx b/src/surfaces/AgentTranscript.tsx index 3fbe6ad2..07b704a1 100644 --- a/src/surfaces/AgentTranscript.tsx +++ b/src/surfaces/AgentTranscript.tsx @@ -572,6 +572,14 @@ function AgentTranscriptComponent({ offset === foldWork.length - 1 ? "zen-fold-tail" : "" + }${ + // Prose the trail holds is the agent talking + // while it works; the marker lets it read as + // process, not result. + entry.type === "block" && + isProseBlock(entry.block) + ? " zen-fold-prose" + : "" }`} > {renderItem(entry, index)} From 2de97674cbd4bc37989f5e0b8ed1b1741cc1e62a Mon Sep 17 00:00:00 2001 From: elijah Date: Wed, 16 Sep 2026 16:20:05 +0800 Subject: [PATCH 3/3] Keep errors and interruptions out of the settled work trail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback on #237: every non-interjection system block became a foldable status step, so a provider error could fold under "Ran a command" and vanish — and a status-only group still read as "Thought". Tag the rows a reader must not miss at the producer (session.error, the quit-interrupt note, provider-not-connected) with a `notice` flag that survives persistence; isActivityBlock leaves them as standalone rows in live and settled alike. Interrupts persisted before the tag existed are still recognised by their literal text, so old sessions stay fail-visible. A group holding nothing but status rows is now labelled "Status update". Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/App.tsx | 1 + src/lib/harness/apply.test.ts | 1 + src/lib/harness/apply.ts | 1 + src/lib/inFlight.ts | 1 + src/lib/session.ts | 5 +++ src/lib/sessionStore.test.ts | 36 +++++++++++++++ src/lib/sessionStore.ts | 3 ++ src/surfaces/transcriptActivity.test.ts | 58 +++++++++++++++++++++++++ src/surfaces/transcriptActivity.ts | 29 +++++++++++-- 9 files changed, 132 insertions(+), 3 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 0526ce4c..1e3fa115 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4611,6 +4611,7 @@ export default function App({ id: crypto.randomUUID(), role: "system", text: `${next.harness} is not connected yet — install and sign in to that provider, then retry.`, + notice: "error", }, ], }; diff --git a/src/lib/harness/apply.test.ts b/src/lib/harness/apply.test.ts index dc54a079..861c2d34 100644 --- a/src/lib/harness/apply.test.ts +++ b/src/lib/harness/apply.test.ts @@ -92,6 +92,7 @@ describe("turn duration", () => { expect(session.blocks.at(-1)).toMatchObject({ role: "system", text: "Codex app-server exited", + notice: "error", }); }); }); diff --git a/src/lib/harness/apply.ts b/src/lib/harness/apply.ts index 160dd082..6721f735 100644 --- a/src/lib/harness/apply.ts +++ b/src/lib/harness/apply.ts @@ -116,6 +116,7 @@ export function applyHarnessEvent( id: crypto.randomUUID(), role: "system", text: event.message, + notice: "error", }); case "session.providerBound": return { ...session, providerSessionId: event.providerSessionId }; diff --git a/src/lib/inFlight.ts b/src/lib/inFlight.ts index 1bbdb333..afe04761 100644 --- a/src/lib/inFlight.ts +++ b/src/lib/inFlight.ts @@ -82,6 +82,7 @@ export function markTurnInterrupted(session: Session): Session { id: crypto.randomUUID(), role: "system", text: INTERRUPT_MESSAGE, + notice: "interrupt", }, ], }; diff --git a/src/lib/session.ts b/src/lib/session.ts index 947b1ceb..35d739f5 100644 --- a/src/lib/session.ts +++ b/src/lib/session.ts @@ -249,6 +249,11 @@ export type Block = { noteCard?: NoteCardMeta; /** Mid-turn interjection chrome; system blocks only. Body lives in text. */ interjection?: InterjectionMeta; + /** + * A system row the reader must not miss — an error or an interruption — + * rather than turn chrome like a status ping. Never folds into the trail. + */ + notice?: "error" | "interrupt"; }; export type RuntimeMode = diff --git a/src/lib/sessionStore.test.ts b/src/lib/sessionStore.test.ts index 29755152..cfab2a7c 100644 --- a/src/lib/sessionStore.test.ts +++ b/src/lib/sessionStore.test.ts @@ -270,6 +270,42 @@ describe("sanitizeSessionForPersist", () => { }); }); + it("keeps a notice flag on system blocks and drops anything else", () => { + const session = newSession("pi", "/tmp/project"); + session.blocks = [ + { + id: "e1", + role: "system", + text: "Provider connection lost", + notice: "error", + }, + { + id: "i1", + role: "system", + text: "Turn interrupted when MonoCode quit.", + notice: "interrupt", + }, + { + id: "b1", + role: "system", + text: "Mystery", + notice: "mystery" as Block["notice"], + }, + { + id: "a1", + role: "assistant", + text: "hi", + notice: "error" as Block["notice"], + }, + ]; + + const persisted = sanitizeSessionForPersist(session).blocks; + expect(persisted[0]?.notice).toBe("error"); + expect(persisted[1]?.notice).toBe("interrupt"); + expect(persisted[2]?.notice).toBeUndefined(); + expect(persisted[3]?.notice).toBeUndefined(); + }); + it("keeps a second-opinion card on the user turn", () => { const session = newSession("codex", "/tmp/project"); session.blocks = [ diff --git a/src/lib/sessionStore.ts b/src/lib/sessionStore.ts index 2d80f896..d34f3aa3 100644 --- a/src/lib/sessionStore.ts +++ b/src/lib/sessionStore.ts @@ -480,6 +480,9 @@ function sanitizeBlock(block: Block): Block | null { if (block.role === "system") { const interjection = sanitizeInterjection(block.interjection); if (interjection) next.interjection = interjection; + if (block.notice === "error" || block.notice === "interrupt") { + next.notice = block.notice; + } } return next; } diff --git a/src/surfaces/transcriptActivity.test.ts b/src/surfaces/transcriptActivity.test.ts index 39b60266..3cace47c 100644 --- a/src/surfaces/transcriptActivity.test.ts +++ b/src/surfaces/transcriptActivity.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import type { Block } from "../lib/session"; +import { INTERRUPT_MESSAGE } from "../lib/inFlight"; import { activityPhaseTitle, activityStillRunning, @@ -979,6 +980,63 @@ describe("the settled work trail", () => { ]), ).toBe(false); }); + + it("keeps an error outside the trail after a completed call, live or settled", () => { + const turn: Block[] = [ + { id: "u", role: "user", text: "go" }, + shell("t1"), + { + id: "e1", + role: "system", + text: "Provider connection lost", + notice: "error", + }, + { id: "done", role: "assistant", text: "It failed." }, + ]; + for (const options of [undefined, { settled: false }, { settled: true }]) { + const items = groupTurnItems(turn, options); + expect(items.map((item) => item.type)).toEqual([ + "block", + "activity", + "block", + "block", + ]); + expect(items[2]).toMatchObject({ type: "block", block: { id: "e1" } }); + // And on the bare sequence — user, done call, error — all the same. + expect( + groupTurnItems(turn.slice(0, 3), options).map((item) => item.type), + ).toEqual(["block", "activity", "block"]); + } + // And the fold the answer puts away stops short of the error's row. + const items = groupTurnItems(turn, { settled: true }); + const fold = foldableWork(items)!; + expect(foldedBlocks(items, fold).map((block) => block.id)).toEqual(["t1"]); + }); + + it("keeps a persisted interrupt outside the trail even without the tag", () => { + const items = groupTurnItems( + [ + shell("a"), + { id: "int", role: "system", text: INTERRUPT_MESSAGE }, + shell("b"), + ], + { settled: true }, + ); + expect(items.map((item) => item.type)).toEqual([ + "activity", + "block", + "activity", + ]); + }); + + it("labels a group that only reported status", () => { + const statuses = [status("s1"), status("s2", "Working on it")]; + expect(workSummaryLine(statuses)).toBe("Status update"); + const phases = buildActivityPhases(statuses); + expect(activityPhaseTitle(phases[0])).toBe("Status update"); + // A thought among the statuses still reads as thinking, not a status line. + expect(workSummaryLine([status("s1"), thought("r1")])).toBe("Thought"); + }); }); describe("foldableWork", () => { diff --git a/src/surfaces/transcriptActivity.ts b/src/surfaces/transcriptActivity.ts index 43bd1686..455b24b7 100644 --- a/src/surfaces/transcriptActivity.ts +++ b/src/surfaces/transcriptActivity.ts @@ -9,6 +9,7 @@ import { } from "../lib/harness/preview"; import { leafName } from "../lib/fileName"; import { displayPath } from "../lib/paths"; +import { INTERRUPT_MESSAGE } from "../lib/inFlight"; import type { Block } from "../lib/session"; import { allModels } from "../lib/models"; @@ -107,6 +108,23 @@ export function isHiddenTool(block: Block): boolean { return isIncompleteTool(block, toolCallLabel(block), state); } +/** + * A system row the reader must not miss — an error, or the note that a quit + * cut the turn short. Sessions persisted before the `notice` tag still carry + * the interrupt's literal text, so it is recognised by content as well. + */ +export function isNoticeBlock(block: Block): boolean { + return ( + block.role === "system" && + (!!block.notice || block.text === INTERRUPT_MESSAGE) + ); +} + +/** Turn chrome the trail absorbed: a status ping, not a notice. */ +function isStatusStep(block: Block): boolean { + return block.role === "system" && !block.interjection; +} + /** * Foldable work: tool calls, thinking, and edits. An edit still awaiting * approval stays out — you cannot judge a diff you cannot see. @@ -115,11 +133,14 @@ export function isHiddenTool(block: Block): boolean { * text: it joins the work around it instead of splitting the group. An * interjection stays a standalone block while the turn is live — the reader * should see it land — and joins the trail only once the turn settles, which - * is the caller's branch to make. + * is the caller's branch to make. A notice — an error, an interruption — is + * neither work nor chrome, so it keeps its own row live and settled alike. */ export function isActivityBlock(block: Block): boolean { if (isThinkingBlock(block)) return true; - if (block.role === "system") return !block.interjection; + if (block.role === "system") { + return !block.interjection && !isNoticeBlock(block); + } if (block.role !== "tool" && block.role !== "approval") return false; if ( isEditTool( @@ -706,7 +727,9 @@ export function workSummaryLine(steps: Block[], live = false): string { ? `${tally.notes} notes` : ""; if (tally.order.length === 0) { - return notes || (live ? "Thinking" : "Thought"); + if (notes) return notes; + if (steps.length > 0 && steps.every(isStatusStep)) return "Status update"; + return live ? "Thinking" : "Thought"; } const running = live ? currentWorkKind(steps) : undefined; return [