From ac794843a644692f313f988fc38ac9a7dfbabb05 Mon Sep 17 00:00:00 2001 From: ss-dev-02 <9e916f802f7932c38e630ac6b4726f5db7ca326c0d98e9bab495309eef13fe5a@buzz.block.builderlab.xyz> Date: Mon, 24 Aug 2026 12:36:05 -0700 Subject: [PATCH 1/6] feat(desktop): fold focus-mode agent work into one transcript block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Focus mode gave every thought and tool step its own row and its own disclosure, so a turn that did real work scrolled its answer off screen behind a stack of chrome. This adopts berd's shipping transcript model: within a turn, everything between the prompt and the answer that is thinking, a tool step, or an interim agent note becomes one "work block" — a thin rail of small glyph bullets that folds to a single "N steps" line when the work is done. Grouping is variant-aware AT THE LIST BOUNDARY rather than inside the grouping module. `TranscriptDisplayBlockView` runs the additive work-block transform only for `conversation`, so `default` and `compactPreview` walk main's segments on the identical code path they always did. That makes Slice B's byte-for-byte baseline fixture hold BY CONSTRUCTION rather than by assertion, which is the property worth having here: no future edit to the block can regress the other two variants without first moving this branch. Behaviour, mirroring berd: - Live: the block is open and the rail IS the status, so there is no header line to restate it. The last three steps show in true arrival order; older ones go behind an "N previous steps" disclosure at the top of the rail, so a long run cannot push the answer out of view. - Finished: folds to "N steps" with a chevron that rotates on open. - The fold ANIMATES. `
` cannot do this — its content is either laid out or not, with no intermediate height — so the collapse would snap. A block that was live when it mounted renders open for a paint and then settles closed, giving the height tween a start state that was actually painted; a block already finished on mount (scrollback) never had a rail on screen and closes immediately, so the animation stays meaningful instead of firing on every mount. - Reader choice wins: once the reader toggles a block, policy stops opening and closing it. One deliberate departure from berd, per core-02: a finished block holding a failure folds to "N steps · 1 failed". A bare count is the one thing a reader cannot distinguish a clean run from a broken one by, and a fold that hides a failure behind a neutral number invites them not to open it. The rail bullet itself stays muted, per berd — failure is a glyph shape, not a colour, so one bad step does not read as an alarm across the run. Items are projected ONCE into rail entries, and the glyph, the body and the folded line's counts all read that projection. Two independent classifications of the same item is precisely how the headline and the chain eligibility drifted apart on the abandoned tool-chain card, so both render sites are exhaustive switches over the entry and neither asks `item.type` again. The projection is closed in the TYPE SYSTEM, not only by convention. An earlier revision made the entry a product of independent fields (`{ item, kind, state }`) with a catch-all `return "tool"`, which left two things wrong that no test could see: a future `TranscriptItem` variant would silently wear a wrench, and impossible pairs like `{ kind: "note", item: }` stayed representable — so the body switch still had to re-check `item.type` and render `""` on a mismatch it could not otherwise handle. Meaning was therefore still derived in two places. Now: - `WorkBlockItem` is the closed union of items a block admits, and `isWorkItem` is a type guard, so the membership decision is made once and every later stage receives the narrowed type. `admittedWorkItems` returns the narrowed array rather than a boolean because an `.every()` guard cannot narrow the array it tested. - `WorkBlockEntry` is a discriminated union pairing each kind with its own item type, and fixing `state: "settled"` on the prose kinds. Both classes of impossible entry are now unrepresentable rather than defended against. - `projectWorkBlockEntry` switches exhaustively over `WorkBlockItem` with no default, so admitting a new item type without deciding how it renders is a compile error (`TS2366: Function lacks ending return statement`), not a wrench. - The body switch takes the whole entry, so narrowing on `kind` narrows `item` too. The `item.type === "thought" ? item.text : ""` fallbacks are gone because there is no longer a mismatch to fall back from. Verified by compiling three mutants, each of which now fails `tsc` where before it type-checked: admitting `plan` to `WorkBlockItem` without a projection case (TS2366), projecting a thought as a note (TS2322 on `item`), and giving a thought `state: "failed"` (TS2322 on `state`). The runtime kind/item pairing is also asserted in `agentSessionWorkBlockGrouping.test.mjs`, because types are stripped at runtime and swapping the two prose branches by hand is the easy mistake — that mutant fails the test. The projection also fixes an ordering bug the old split invited: a tool carrying a stale `isError` from a retry while the new attempt executes reads as `running`, not `failed`, so a live block cannot fold its own count to "N steps · 1 failed" while the work is still in flight. **Interim notes suppress the identity row.** #6720 gives every conversation-variant assistant message a 20px avatar + name row, which is right for the turn's answer. A rail note is the same item type, so routing it through that presenter would render a fully attributed agent turn nested inside a muted step row — the agent apparently replying twice, once inside the work it was doing. Notes render through a dedicated rail prose body instead, keeping markdown and the focus code-block recipe by providing the same `CodeBlockVariantContext` value the presenter would. Done on this side rather than by reaching into #6720, so that PR keeps one rule for what a message looks like. Notes share the thought's speech bubble, matching berd's `progress` entry: both are the agent talking. - `useControlledDisclosure` is deleted rather than reused. Its entire reason to exist was the `
` echo trap — `
` fires `toggle` for programmatic `open` changes indistinguishably from clicks, so a policy-driven open echoes back looking like reader intent. This block's trigger 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 ( +