diff --git a/src/App.tsx b/src/App.tsx index 4d5cfbf3..23314049 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4724,6 +4724,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/index.css b/src/index.css index fa8ba428..cbaacb1b 100644 --- a/src/index.css +++ b/src/index.css @@ -1236,6 +1236,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/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 275599db..638c6a86 100644 --- a/src/lib/session.ts +++ b/src/lib/session.ts @@ -252,6 +252,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/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.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 cd4b5641..382cc084 100644 --- a/src/surfaces/AgentTranscript.test.ts +++ b/src/surfaces/AgentTranscript.test.ts @@ -191,7 +191,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", @@ -206,11 +232,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", () => { @@ -310,26 +333,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. @@ -350,28 +376,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"); @@ -406,18 +435,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"'); @@ -425,6 +459,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 73f6509f..0aa7c54c 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 ModelTarget, type PlanBuildTarget, type ToolPreview, @@ -88,6 +89,7 @@ import { groupTurns, initialThinkingIndex, isIncompleteTool, + isSubagentBlock, isThinkingBlock, lastActivityIndex, isProseBlock, @@ -399,6 +401,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. @@ -579,6 +582,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)} @@ -1699,28 +1710,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} /> @@ -1729,6 +1726,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 @@ -2005,6 +2037,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 @@ -2712,6 +2840,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 }) { @@ -2738,26 +2899,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 (
@@ -2780,7 +2922,7 @@ function InterjectionDivider({ block }: { block: Block }) {
             {block.text}
           
diff --git a/src/surfaces/transcriptActivity.test.ts b/src/surfaces/transcriptActivity.test.ts index bb4421d6..e7743823 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, @@ -22,6 +23,8 @@ import { toolCallLabel, turnCopyText, subagentModelName, + workKind, + workSummaryLine, } from "./transcriptActivity"; function shell( @@ -90,6 +93,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([ @@ -779,6 +795,251 @@ 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") throw new Error("expected activity"); + 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); + }); + + 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"); + expect(workKind(statuses)).toBe("note"); + // A thought among the statuses still reads as thinking, not a status line. + expect(workSummaryLine([status("s1"), thought("r1")])).toBe("Thought"); + }); +}); + describe("foldableWork", () => { const items = (blocks: Block[]) => groupTurnItems(blocks); diff --git a/src/surfaces/transcriptActivity.ts b/src/surfaces/transcriptActivity.ts index 95902f20..b2a7bdfa 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,12 +108,39 @@ 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. + * + * 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. 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 && !isNoticeBlock(block); + } if (block.role !== "tool" && block.role !== "approval") return false; if ( isEditTool( @@ -207,8 +235,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 +260,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; } @@ -509,6 +552,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 @@ -554,6 +605,8 @@ type PhaseTally = { runs: number; agents: number; others: number; + /** Interjections the turn absorbed. Status rows count nowhere. */ + notes: number; }; function tallySteps(steps: Block[]): PhaseTally { @@ -565,8 +618,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; @@ -657,20 +715,38 @@ 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) { + 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 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) || + (steps.length > 0 && steps.every(isStatusStep)) + ? "note" + : "think") + ); } /** @@ -702,8 +778,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;