diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem/ToolItem.tsx b/desktop/src/features/agents/ui/AgentSessionToolItem/ToolItem.tsx index e0f2ce9fbc..85a484deb5 100644 --- a/desktop/src/features/agents/ui/AgentSessionToolItem/ToolItem.tsx +++ b/desktop/src/features/agents/ui/AgentSessionToolItem/ToolItem.tsx @@ -10,6 +10,7 @@ import type { TranscriptItem } from "../agentSessionTypes"; import { getBuzzToolInfo } from "../agentSessionToolCatalog"; import { buildCompactToolSummary } from "../agentSessionToolSummary"; import type { AgentTranscriptIdentityProps } from "../activityRenderClasses/types"; +import { useIsInsideWorkBlockRail } from "../agentSessionTranscriptContext"; import { formatTranscriptTimestampTitle, getToolDurationDisplay, @@ -34,6 +35,7 @@ export function ToolItem({ profiles?: UserProfileLookup; }) { const [isExpanded, setIsExpanded] = React.useState(false); + const insideWorkBlockRail = useIsInsideWorkBlockRail(); const hasArgs = Object.keys(item.args).length > 0; const hasResult = item.result.trim().length > 0; const canonicalToolName = item.buzzToolName ?? item.toolName; @@ -57,7 +59,10 @@ export function ToolItem({ [], ); - if (compactSummary.presentation === "message") { + // On a work block's rail a posted message is a step the agent took, so it + // takes the same muted, expandable row as every other step. Everywhere else + // it keeps the bubble: see `useIsInsideWorkBlockRail`. + if (compactSummary.presentation === "message" && !insideWorkBlockRail) { return (
{ @@ -147,160 +146,6 @@ test("conversation renders agent messages as unboxed prose at full fidelity", as assert.ok(message.querySelector("code"), "markdown/code fidelity preserved"); }); -test("conversation collapses a finished thought into a Thought for Ns disclosure", async () => { - const { container } = await renderTranscript("conversation"); - const disclosure = container.querySelector( - '[data-testid="transcript-thought-item"]', - ); - assert.ok(disclosure, "thought should render"); - assert.equal(disclosure.tagName, "DETAILS"); - assert.equal(disclosure.open, false, "finished thoughts start collapsed"); - // thought at :02, next turn item (plan) at :07 → 5s. - assert.equal( - disclosure - .querySelector('[data-testid="transcript-thought-disclosure"]') - .textContent.trim(), - "Thought for 5s", - ); -}); - -test("conversation auto-opens the thought disclosure while it is streaming", async () => { - // A live turn on this channel makes the trailing item the streaming one, which - // is exactly the condition the disclosure auto-opens for. - syncAgentTurnsFromEvents(AGENT.agentPubkey, [ - { - seq: 1, - timestamp: "2026-06-14T19:00:02.000Z", - kind: "turn_started", - agentIndex: 0, - channelId: "chan-1", - sessionId: "sess-1", - turnId: "turn-1", - payload: null, - }, - ]); - const streamingThought = items().slice(0, 2); - const { container } = await renderTranscript("conversation", { - channelId: "chan-1", - items: streamingThought, - }); - - const disclosure = container.querySelector( - '[data-testid="transcript-thought-item"]', - ); - assert.equal(disclosure.open, true, "streaming thought should be open"); - const summary = disclosure.querySelector( - '[data-testid="transcript-thought-disclosure"]', - ); - // Shimmer paints a visual-only aria-hidden duplicate of the label, so match a - // prefix rather than the whole text node. - assert.match(summary.textContent, /^Thinking…/); - assert.doesNotMatch(summary.textContent, /Thought for/); -}); - -test("conversation folds the thought when the turn moves on, even after the browser echoes the auto-open toggle", async () => { - // Regression guard for the programmatic-toggle echo trap: a real browser fires - // `toggle` when React flips `open`, so the auto-open for a streaming thought - // arrives back at the component as if the reader had clicked. JSDOM does not - // fire that event itself, so the test injects it. If the handler records it as - // a reader choice, the disclosure stays pinned open forever and reasoning - // never recedes once the agent acts. - syncAgentTurnsFromEvents(AGENT.agentPubkey, [ - { - seq: 1, - timestamp: "2026-06-14T19:00:02.000Z", - kind: "turn_started", - agentIndex: 0, - channelId: "chan-1", - sessionId: "sess-1", - turnId: "turn-1", - payload: null, - }, - ]); - const streaming = { channelId: "chan-1", items: items().slice(0, 2) }; - const { container, setOverrides } = await renderRerenderableTranscript( - "conversation", - streaming, - ); - - const disclosure = container.querySelector( - '[data-testid="transcript-thought-item"]', - ); - assert.equal(disclosure.open, true, "streaming thought should auto-open"); - - // The browser echo: `open` already agrees with what React rendered, and the - // event follows rather than causes that state. - await act(async () => { - disclosure.open = true; - disclosure.dispatchEvent(new domWindow.Event("toggle")); - }); - assert.equal( - disclosure.open, - true, - "the echo must not disturb the open tail", - ); - - // The turn produces its next item, so the thought is no longer the streaming - // tail and should recede. - await setOverrides({ ...streaming, items: items().slice(0, 3) }); - - const settled = container.querySelector( - '[data-testid="transcript-thought-item"]', - ); - assert.equal( - settled.open, - false, - "a thought the agent has acted on should fold again", - ); - assert.match( - settled - .querySelector('[data-testid="transcript-thought-disclosure"]') - .textContent.trim(), - /^Thought for 5s/, - ); -}); - -test("conversation keeps a reader-opened thought open after the turn moves on", async () => { - // The other half of the guard: a toggle that DISAGREES with the rendered state - // is a genuine reader choice and must win over the stream transition. - syncAgentTurnsFromEvents(AGENT.agentPubkey, [ - { - seq: 1, - timestamp: "2026-06-14T19:00:02.000Z", - kind: "turn_started", - agentIndex: 0, - channelId: "chan-1", - sessionId: "sess-1", - turnId: "turn-1", - payload: null, - }, - ]); - // Three items so the thought is not the streaming tail: it renders collapsed. - const settledItems = { channelId: "chan-1", items: items().slice(0, 3) }; - const { container, setOverrides } = await renderRerenderableTranscript( - "conversation", - settledItems, - ); - - const disclosure = container.querySelector( - '[data-testid="transcript-thought-item"]', - ); - assert.equal(disclosure.open, false, "a settled thought starts collapsed"); - - await act(async () => { - disclosure.open = true; - disclosure.dispatchEvent(new domWindow.Event("toggle")); - }); - - await setOverrides({ ...settledItems, items: items() }); - - assert.equal( - container.querySelector('[data-testid="transcript-thought-item"]').open, - true, - "the reader's choice should survive later transcript items", - ); -}); - test("conversation renders the plan as a checklist card with in-place progress", async () => { const { container } = await renderTranscript("conversation"); const card = container.querySelector('[data-testid="transcript-plan-item"]'); diff --git a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx index 9a5135c6f0..f32dfe9dba 100644 --- a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx +++ b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx @@ -34,6 +34,11 @@ import { getTranscriptMessageLink, } from "./AgentSessionTranscriptChrome"; import { buildConversationTurnMeta } from "./agentSessionConversationMeta"; +import { AgentSessionWorkBlockSegment } from "./AgentSessionWorkBlock"; +import { + conversationSegmentsForBlock, + type TranscriptConversationSegment, +} from "./agentSessionWorkBlockGrouping"; import { useTranscriptAnimationEnabled } from "./transcriptAnimationPreference"; import { useTranscriptTimestampsEnabled } from "./transcriptTimestampPreference"; import { TranscriptActivityItem } from "./activityRenderClasses/TranscriptActivityItem"; @@ -378,6 +383,15 @@ function TranscriptDisplayBlockView({ ); } + // Variant in, presentation out: the conversation variant folds a turn's + // thinking/tools/notes into work blocks, and every other variant renders the + // shared segments untouched. Keeping the choice here — rather than inside the + // segment components — is what keeps `default`/`compactPreview` byte-identical + // to their pre-work-block markup. + const segments: TranscriptConversationSegment[] = isConversation + ? conversationSegmentsForBlock(block) + : block.segments; + return (
- {block.segments.map((segment) => ( + {segments.map((segment) => ( + ); + } + if (segment.kind === "prompt") { return ( ", { + url: "http://localhost", +}); + +class NoopObserver { + disconnect() {} + observe() {} + unobserve() {} +} + +let prefersReducedMotion = false; + +before(() => { + Object.assign(globalThis, { + document: dom.window.document, + Element: dom.window.Element, + Event: dom.window.Event, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + IntersectionObserver: NoopObserver, + MutationObserver: dom.window.MutationObserver, + Node: dom.window.Node, + ResizeObserver: NoopObserver, + getComputedStyle: (...args) => dom.window.getComputedStyle(...args), + localStorage: dom.window.localStorage, + self: dom.window, + window: dom.window, + }); + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: dom.window.navigator, + writable: true, + }); + // `motion`'s useReducedMotion reads this query, so the reduced-motion test + // drives it through the same surface the component does. + dom.window.matchMedia = (query) => ({ + matches: + prefersReducedMotion && String(query).includes("prefers-reduced-motion"), + addEventListener() {}, + removeEventListener() {}, + addListener() {}, + removeListener() {}, + }); + globalThis.matchMedia = dom.window.matchMedia; + dom.window.requestAnimationFrame = (callback) => setTimeout(callback, 0); + dom.window.cancelAnimationFrame = (id) => clearTimeout(id); + globalThis.requestAnimationFrame = dom.window.requestAnimationFrame; + globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame; +}); + +afterEach(async () => { + prefersReducedMotion = false; + const { cleanup } = await import("@testing-library/react"); + cleanup(); +}); + +after(() => dom.window.close()); + +const START = "2026-06-18T00:00:00.000Z"; +const SHARED = { channelId: "chan-1", sessionId: "sess-1", turnId: "turn-1" }; + +function step(id, overrides = {}) { + return { + ...SHARED, + id, + type: "tool", + renderClass: "shell", + descriptor: { + renderClass: "shell", + label: "Ran command", + preview: id, + source: "shell", + groupKey: "shell:command", + }, + title: id, + toolName: "shell", + buzzToolName: null, + status: "completed", + args: {}, + result: "ok", + isError: false, + timestamp: START, + startedAt: START, + completedAt: "2026-06-18T00:00:01.000Z", + ...overrides, + }; +} + +function thoughtStep(id) { + return { + ...SHARED, + id, + type: "thought", + renderClass: "thought", + title: "Thinking", + text: "weighing the options", + timestamp: START, + }; +} + +function noteStep(id, text = "posted the summary to the channel") { + return { + ...SHARED, + id, + type: "message", + renderClass: "message", + role: "assistant", + title: "Agent", + text, + timestamp: START, + }; +} + +/** + * A relay post the agent made mid-turn. + * + * The distinguishing property is `descriptor.renderClass: "message"`, which is + * what `buildCompactToolSummary` turns into `presentation: "message"` and what + * routes the item to the avatar + speech-bubble presenter outside a block. The + * result carries an `event_id` so `getSentMessageLink` resolves too — that is + * the fully-featured shape (bubble, timestamp, delivery receipt), i.e. the one + * that goes most wrong on a muted rail. + */ +function relayStep(id, overrides = {}) { + return step(id, { + renderClass: "message", + descriptor: { + renderClass: "message", + label: "Send Message", + preview: "posted the findings to the channel", + action: { verb: "Sent", object: "posted the findings to the channel" }, + source: "shell", + groupKey: "buzz-cli:messages.send", + operation: "messages.send", + }, + title: "Send Message", + toolName: "buzz_dev_mcp__shell", + args: { + command: + "buzz messages send --channel chan-1 --content 'posted the findings to the channel'", + }, + result: '{"event_id":"ev-1","accepted":true}', + ...overrides, + }); +} + +/** + * Let every pending animation finish and the DOM settle. + * + * A collapsing block keeps its rail mounted until the height animation's exit + * completes, which is the point of the fold — so "has it closed yet?" can only + * be asked after the exit runs, not on the commit that started it. Polling to a + * stable answer keeps these assertions about the end state the reader sees + * rather than about motion's internal frame schedule. + */ +async function settle(read, expected, { timeout = 2000 } = {}) { + const { act } = await import("@testing-library/react"); + const deadline = Date.now() + timeout; + let last = read(); + while (Date.now() < deadline) { + if (last === expected) return last; + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 25)); + }); + last = read(); + } + return last; +} + +/** + * Mount a work block under a router. + * + * Rail rows render agent content through `Markdown`, which resolves in-app links + * via `useAppNavigation` and therefore needs router context — the same reason + * `AgentSessionTranscriptList.conversation.test.mjs` mounts through a memory + * router. Without it a thought row throws while a tool row happens not to. + */ +async function renderBlock(items, { streamingItemId = null } = {}) { + const { createElement, useState } = await import("react"); + const { render } = await import("@testing-library/react"); + const { QueryClient, QueryClientProvider } = await import( + "@tanstack/react-query" + ); + const { createMemoryHistory, createRootRoute, createRouter, RouterProvider } = + await import("@tanstack/react-router"); + const { AgentSessionTranscriptTurnMetaProvider } = await import( + "./agentSessionTranscriptContext.ts" + ); + const { AgentSessionWorkBlockSegment } = await import( + "./AgentSessionWorkBlock.tsx" + ); + + const element = (blockItems, streaming) => + createElement( + AgentSessionTranscriptTurnMetaProvider, + { + value: { + streamingItemId: streaming, + }, + }, + createElement(AgentSessionWorkBlockSegment, { + agentAvatarUrl: null, + agentName: "Agent", + agentPubkey: "pk", + block: { + id: `work-block:${blockItems[0].id}`, + items: blockItems, + timestamp: blockItems[0].timestamp, + }, + }), + ); + + let applyState; + const Harness = () => { + const [state, setState] = useState({ items, streamingItemId }); + applyState = setState; + return element(state.items, state.streamingItemId); + }; + // The bubble presenter reads the posted message through `useQuery`. Provided + // unconditionally so that if a rail step ever DID reach that presenter, the + // relay test below would fail on its bubble assertion rather than on a + // missing provider — a test must fail for the reason it claims. + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const rootRoute = createRootRoute({ + component: () => + createElement( + QueryClientProvider, + { client: queryClient }, + createElement(Harness), + ), + }); + const router = createRouter({ + history: createMemoryHistory({ initialEntries: ["/"] }), + routeTree: rootRoute, + }); + await router.load(); + + const view = render(createElement(RouterProvider, { router })); + const q = (selector) => view.container.querySelector(selector); + const qa = (selector) => [...view.container.querySelectorAll(selector)]; + const stepCount = () => + qa('[data-testid="transcript-work-block-step"]').length; + return { + ...view, + // Re-render the SAME block id, exactly as the transcript does as work + // streams and then finishes. + stream: (nextItems, streaming = null) => + applyState({ items: nextItems, streamingItemId: streaming }), + summary: () => q('[data-testid="transcript-work-block-summary"]'), + previousSteps: () => + q('[data-testid="transcript-work-block-previous-steps"]'), + stepCount, + // A finished block renders folded, so any assertion about what is ON the + // rail has to open it first — exactly as a reader would. + expand: async () => { + const { act } = await import("@testing-library/react"); + await act(async () => { + q('[data-testid="transcript-work-block-summary"]').click(); + }); + }, + // Wait for the rail to reach `expected` rows once animations have run. + settleToStepCount: (expected) => settle(stepCount, expected), + glyphStates: () => + qa("[data-step-state]").map((node) => + node.getAttribute("data-step-state"), + ), + q, + qa, + }; +} + +// ── Live ───────────────────────────────────────────────────────────────────── + +test("a live block shows no header line — the rail is the status", async () => { + const view = await renderBlock([ + step("a"), + step("b", { status: "executing", completedAt: null }), + ]); + assert.equal( + view.summary(), + null, + "a header while live would only restate what the arriving steps show", + ); + assert.equal(view.stepCount(), 2); +}); + +test("a live block windows to the last three steps with the rest behind a disclosure", async () => { + const items = ["a", "b", "c", "d", "e"].map((id) => step(id)); + items[4] = step("e", { status: "executing", completedAt: null }); + const view = await renderBlock(items, { streamingItemId: "e" }); + + assert.equal(view.stepCount(), 3, "only the live window renders on the rail"); + const disclosure = view.previousSteps(); + assert.ok(disclosure, "older steps sit behind a disclosure"); + assert.match(disclosure.textContent, /2 previous steps/); +}); + +test("expanding previous steps reveals the older steps in place", async () => { + const { act } = await import("@testing-library/react"); + const items = ["a", "b", "c", "d", "e"].map((id) => step(id)); + items[4] = step("e", { status: "executing", completedAt: null }); + const view = await renderBlock(items, { streamingItemId: "e" }); + + assert.equal(view.stepCount(), 3); + await act(async () => { + view.previousSteps().click(); + }); + assert.equal( + await view.settleToStepCount(5), + 5, + "all five steps are now on the rail", + ); +}); + +// ── Finished ───────────────────────────────────────────────────────────────── + +test("a block that was already finished on mount folds to an N steps line", async () => { + const view = await renderBlock([step("a"), step("b"), step("c")]); + const summary = view.summary(); + assert.ok(summary, "a finished block gets its summary line"); + assert.match(summary.textContent, /3 steps/); + assert.equal(summary.getAttribute("aria-expanded"), "false"); + assert.equal(view.stepCount(), 0, "the rail is collapsed away"); +}); + +test("a finished block containing a failure names the failure in its folded line", async () => { + const view = await renderBlock([ + step("a"), + step("b", { isError: true, status: "failed" }), + step("c"), + ]); + assert.match( + view.summary().textContent, + /3 steps · 1 failed/, + "a failure must never hide behind a neutral count", + ); +}); + +test("clicking the folded line expands the whole rail", async () => { + const { act } = await import("@testing-library/react"); + const view = await renderBlock([step("a"), step("b"), step("c")]); + assert.equal(view.stepCount(), 0); + + await act(async () => { + view.summary().click(); + }); + + assert.equal(await view.settleToStepCount(3), 3); + assert.equal(view.summary().getAttribute("aria-expanded"), "true"); +}); + +test("a block expanded by the reader while live shows every step, not just the window", async () => { + const { act } = await import("@testing-library/react"); + const items = ["a", "b", "c", "d", "e"].map((id) => step(id)); + items[4] = step("e", { status: "executing", completedAt: null }); + const view = await renderBlock(items, { streamingItemId: "e" }); + + assert.equal(view.stepCount(), 3); + await act(async () => { + view.previousSteps().click(); + }); + assert.equal( + await view.settleToStepCount(5), + 5, + "a reader who asked to see the work sees all of it", + ); + + // And the window does NOT come back as more work streams in: the reader's + // choice is not re-decided on every append. + await act(async () => { + view.stream( + [...items, step("f", { status: "executing", completedAt: null })], + "f", + ); + }); + assert.equal( + await view.settleToStepCount(6), + 6, + "a reader-expanded live block keeps showing everything as it grows", + ); +}); + +// ── Fold animation ─────────────────────────────────────────────────────────── + +test("a block that finishes while mounted stays open for a paint so the collapse is visible", async () => { + const { act } = await import("@testing-library/react"); + const live = [ + step("a"), + step("b", { status: "executing", completedAt: null }), + ]; + const view = await renderBlock(live); + assert.equal(view.summary(), null, "live: no header"); + assert.equal(view.stepCount(), 2); + + // The turn finishes: the same block id re-renders with settled steps. + await act(async () => { + view.stream([step("a"), step("b")]); + }); + + // Still open on the commit right after finishing — that open state is what + // gives the height animation something to collapse FROM. A block that jumped + // straight to closed would swap a rail for a one-line summary between frames. + assert.equal( + view.stepCount(), + 2, + "the rail is still mounted for the settle frame", + ); + assert.ok( + view.summary(), + "the summary line appears as soon as work finishes", + ); + + // After the settle frames it closes — once the collapse animation has run. + assert.equal(await view.settleToStepCount(0), 0, "the block settles closed"); +}); + +test("under reduced motion a finishing block folds immediately, with no settle frames", async () => { + const { act } = await import("@testing-library/react"); + prefersReducedMotion = true; + + const view = await renderBlock([ + step("a"), + step("b", { status: "executing", completedAt: null }), + ]); + assert.equal(view.stepCount(), 2); + + await act(async () => { + view.stream([step("a"), step("b")]); + }); + + assert.equal( + view.stepCount(), + 0, + "reduced motion skips the animation, so there is nothing to hold open for", + ); + assert.ok( + view.summary(), + "it still folds to a summary line — only the animation is skipped", + ); +}); + +// ── Reader choice ──────────────────────────────────────────────────────────── + +/** + * The echo trap, and why this block is structurally immune to it. + * + * `
` fires `toggle` for programmatic `open` changes as well as for + * clicks, indistinguishably — so a policy-driven open echoes back looking like + * a reader choice and pins the row to its first policy state forever. That trap + * cost time on the tool-run card. + * + * This block cannot hit it, because its disclosure is a ` + ); +} + +function PreviousStepsDisclosure({ + agentAvatarUrl, + agentName, + agentPubkey, + entries, + motionEnabled, + profiles, +}: AgentTranscriptIdentityProps & { + entries: WorkBlockEntry[]; + motionEnabled: boolean; + profiles?: UserProfileLookup; +}) { + const [open, setOpen] = React.useState(false); + + return ( + + + + + + + ); +} + +function WorkBlockRail({ + agentAvatarUrl, + agentName, + agentPubkey, + animateEnter, + entries, + profiles, +}: AgentTranscriptIdentityProps & { + animateEnter: boolean; + entries: WorkBlockEntry[]; + profiles?: UserProfileLookup; +}) { + return ( +
+ + {entries.map((entry, index) => ( + + + + ))} + +
+ ); +} + +function WorkBlockStepRow({ + agentAvatarUrl, + agentName, + agentPubkey, + entry, + isLast, + profiles, +}: AgentTranscriptIdentityProps & { + entry: WorkBlockEntry; + isLast: boolean; + profiles?: UserProfileLookup; +}) { + return ( +
+ + +
+ ); +} + +/** + * A step's content: one exhaustive switch over the entry kind, so a new kind + * cannot silently inherit another kind's presentation. + * + * The entry arrives SPREAD into props rather than as an `entry` object, because + * this component is memoized and the projection is rebuilt whenever the block's + * item array changes — i.e. on every append — so entry objects are fresh each + * time and a memo keyed on one would never hit. Spread, the compared props are + * `item` (reference-stable: the transcript store replaces items rather than + * mutating them) plus two strings, so shallow comparison is a sound "did not + * change" test where comparison on the entry wrapper is not. + * + * Spreading keeps the discriminated union intact — `kind` and `item` stay paired + * in the props type — so the switch below narrows `item` to the kind's own item + * type. That is what removes the previous `item.type === "thought" ? ... : ""` + * re-checks: a mismatch is no longer representable, so there is no mismatch + * branch to render an empty body for. + * + * The memo boundary is the body rather than the whole row because the glyph + * depends on the row's *position* (`isLast` decides whether the spine + * continues), and that changes for the previous last row on every append. + * Passing position into the memoized part would invalidate that row's body on + * each append for a one-pixel spine segment; splitting keeps the cheap, + * position-dependent half outside and the expensive, item-dependent half in. + */ +const WorkBlockStepBody = React.memo(function WorkBlockStepBody( + props: WorkBlockEntryBodyProps, +) { + return ( +
+ +
+ ); +}); + +type WorkBlockEntryBodyProps = AgentTranscriptIdentityProps & { + profiles?: UserProfileLookup; +} & WorkBlockEntry; + +/** + * One exhaustive switch over the entry kind, so a new kind cannot silently + * inherit another kind's presentation. + * + * Because the props carry the whole discriminated entry, narrowing on `kind` + * also narrows `item` to that kind's item type. The previous version took + * `kind` and `item` as independent fields and so had to re-check + * `item.type === "thought" ? item.text : ""` — a mismatch branch that rendered + * an empty body and could only ever be reached by a projection bug. + */ +function WorkBlockEntryBody(props: WorkBlockEntryBodyProps) { + switch (props.kind) { + case "thought": + return ( + + ); + case "note": + return ( + + ); + case "tool": + return ( + + ); + } +} + +/** + * Prose on the rail: reasoning, or an interim note the agent addressed to the + * reader. No disclosure of its own — the whole block is already one, and + * nesting a second would mean two clicks to read something the reader has just + * chosen to reveal. + * + * A note deliberately does NOT go through the message presenter. That presenter + * gives every conversation-variant assistant message an avatar + name identity + * row, which is right for the turn's answer and wrong here: a fully attributed + * agent turn nested inside a muted rail step reads as a second reply rather + * than as progress. berd draws the same distinction — its `progress` entry is a + * plain rail row, not a message bubble. The focus code-block recipe is kept by + * providing the same `CodeBlockVariantContext` value the presenter would. + * + * berd brightens rail prose with `usePrimaryText={open}`. Here it is + * unconditional, because the rail only ever exists inside the open disclosure + * panel — a closed block unmounts its rows entirely rather than rendering them + * dimmed. Threading an `open` flag down to this component would be a prop whose + * false branch is unreachable, which is the same shape of dead-code-that-looks- + * load-bearing as the disclosure echo guard this block also dropped. If the + * block ever renders a peek of its rows while closed, the flag comes back with + * a test that can actually reach both branches. + */ +function WorkBlockProseBody({ + testId, + text, +}: { + testId: string; + text: string; +}) { + return ( +
+ + + +
+ ); +} + +/** + * The spine and this row's bullet. + * + * The bullet masks the spine passing behind it, which is what makes the rail + * read as a series of stops rather than a line with icons floating over it. + * The mask has to match the surface the transcript is actually drawn on — the + * cover drawer's `bg-background` — because a mask in any other colour shows up + * as a visible disc of the wrong shade around every bullet. + * + * (berd's equivalent uses `bg-card` and warns against `bg-background`; that is + * the same rule, not a different one. In berd the transcript sits on a card, so + * `bg-card` is its surface. Buzz's drawer surface is `bg-background`, and the + * two tokens are NOT interchangeable here: they share a value in the base + * themes, but in Buzz Dark the drawer sits inside `[data-buzz-content-surface]`, + * which locally overrides `--background` to `--buzz-content-dark` while + * `--card` keeps the theme value. Measured in a seeded browser: + * + * | theme | bullet | drawer surface | spine | + * | ------------ | --------------- | --------------- | --------------- | + * | github-light | rgb(255,255,255)| rgb(255,255,255)| rgb(229,229,230)| + * | buzz-dark | rgb(26,26,26) | rgb(26,26,26) | rgb(64,69,74) | + * + * `bg-card` paints the bullet `rgb(36,41,46)` over that `rgb(26,26,26)` drawer + * — a visible disc of the wrong shade, which is exactly the BOT-1599 failure. + * Light mode alone matches under either class, so light-mode evidence is not + * sufficient here. Following berd's class literally would be following the + * letter of its note against its point.) + */ +function WorkBlockRailGlyph({ + entry, + isLast, +}: { + entry: WorkBlockEntry; + isLast: boolean; +}) { + return ( +