Skip to content

feat(desktop): fold focus-mode agent work into one transcript block - #6536

Open
baxen wants to merge 1 commit into
ss-dev-01/berd-restylefrom
ss-dev-02/tool-chain-cards
Open

feat(desktop): fold focus-mode agent work into one transcript block#6536
baxen wants to merge 1 commit into
ss-dev-01/berd-restylefrom
ss-dev-02/tool-chain-cards

Conversation

@baxen

@baxen baxen commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Retargeted: the work block, not the tool-chain card

This PR previously carried Slice C's tool-chain card presentation. Per ss-core-02's direction change (2026-08-24), that presentation is superseded by berd's shipping transcript model, and this same PR is retargeted rather than replaced. ToolChainCards.tsx, the derived verb/object headline, and the card chrome are gone. Carried forward: the grouping module, step memoization, and their tests. (The useControlledDisclosure hook was carried at first, then deleted — see below.)

Head: 9c2900e9b, one commit on top of #6720's current head e8709554a. Rebased onto ss-dev-01/berd-restyle (#6720) per core-02's revised ordering: B (#6538) → #6720 → C (this PR). Both must merge first.


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. Now, within a turn, everything between the prompt and the final 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.

Behaviour

  • Live — the block is open with no header line at all; the rail is the status. The last three items show on the rail in true order; anything older sits behind an N previous steps disclosure at the top.
  • Finished — folds to one summary line (6 steps · 1 failed), chevron right, rotating down on open. Clicking expands the whole rail. The fold animates: the block mounts open and settles closed after a paint, matching berd. Native <details> cannot animate height, so this uses motion (already a dep).
  • Reduced motion skips the animation — read via window.matchMedia("(prefers-reduced-motion: reduce)") with a change listener, following TerminalSubstrate.tsx. Note for reviewers: motion's own useReducedMotion caches the media query at import and MotionConfig reducedMotion does not override it; verified empirically with throwaway probes before choosing this route.
  • Reader choice wins — once the reader toggles a block, auto-open/auto-fold stops for that block.
  • Rail — thin spine, size-5 glyph bullets that mask the spine. Thought and interim-note rows get a speech-bubble glyph, tool rows a wrench, running steps pulse. Failure is a glyph shape, not a colour, so one failed step does not read as an alarm across the whole run; the tinted output block carries the red when expanded.

Grouping is variant-aware AT THE LIST BOUNDARY

TranscriptDisplayBlockView runs the additive work-block transform only for conversation:

isConversation ? conversationSegmentsForBlock(block) : block.segments

default and compactPreview therefore walk main's segments on the identical code path they always did, which makes Slice B's byte-for-byte baseline fixture hold by construction rather than by assertion. This is cleaner than Slice C's approach of special-casing the preview inside the component.

One projection per leaf: a closed entry type

Every admitted leaf is projected once into a rail entry, and the glyph, the body, and the folded line's counts all read that projection instead of re-asking item.type === ... at each render site. That re-derivation is what made an interim agent note fall through to the tool branch and pick up a wrench (ss-quality-00's finding 1).

The model is a discriminated union, not a { item, kind, state } product:

export type WorkBlockEntry =
  | { kind: "thought"; item: WorkBlockThoughtItem; state: "settled" }
  | { kind: "note"; item: WorkBlockNoteItem; state: "settled" }
  | { kind: "tool"; item: WorkBlockToolItem; state: WorkBlockEntryState };

Three things stop compiling as a result, each verified with a throwaway mutant rather than asserted:

Mutant Compiler says
A new admitted item type with no projection decision TS2366projectWorkBlockEntry lacks an ending return statement
A thought projected as note TS2322role missing (WorkBlockNoteItem is Extract<TranscriptItem, {type:"message"}> & { role: "assistant" })
Prose projected as failed TS2322"failed" is not assignable to "settled"

Supporting changes that make the union reach the render sites:

  • isWorkItem is a type guard, and admittedWorkItems(segment, finalAnswerId) returns WorkBlockItem[] | null instead of a boolean. An .every() predicate cannot narrow the array it just tested, so the old boolean form lost the type at the admission boundary.
  • projectWorkBlockEntry is an exhaustive switch with no default. The repo has precedent for const x: never = ... exhaustiveness checks (sound.ts, SettingsPanels.tsx), but for a returning function the missing-return error is stronger and needs no extra code.
  • toolEntryState checks running before failed, so a stale isError from a retry cannot fold a live block to N steps · 1 failed while the work is still in flight.
  • The entry is spread into WorkBlockStepBody's props rather than passed as an entry object. The projection is rebuilt on every append, so entry objects are fresh each time and a memo keyed on one would never hit; spread, the compared props are the reference-stable item plus two strings.
  • Narrowing on kind narrows item, which deleted the previous item.type === "thought" ? item.text : "" re-checks. Those silent empty-body branches are now unrepresentable rather than merely unreached.

TypeScript is stripped at runtime in this repo's .mjs tests, so the compile-time half is enforced by production code plus tsc and the runtime half by an explicit [kind, item.type] pairing assertion in agentSessionWorkBlockGrouping.test.mjs. Non-vacuity checked by swapping the two prose projection branches — the test fails; restored, it passes.

Decisions worth reviewing as decisions

  1. Block id derives from the FIRST item (work-block:${first.id}). Keying on the last item would remount the block on every streamed append and discard the reader's disclosure choice.

  2. Maximal runs of consecutive work items, not "everything between prompt and answer." When a non-work row (permission gate, error, mid-turn plan update) lands inside the work, the span reading would have to lift that row out of position. Splitting keeps every row where it happened — which matters most for exactly those rows.

  3. Summary segments are expanded back to leaf tool rows inside the block, so the reader never faces two collapsed layers. The block is the one grouping in this variant.

  4. The final answer is identified positionally (last assistant message), not by liveness, so block membership does not reshuffle at turn completion.

  5. formatWorkBlockSummaryLabel departs from berd by appending · N failed. A bare step count is the one thing that leaves a reader unable to tell a clean run from a broken one.

  6. isActive accepts two evidence sources — a step reporting itself running, and the list's streamingItemId hint. Either alone leaves a gap: a streaming thought carries no tool status, and a tool left executing after an observer-stream drop would pin the block open forever.

  7. bg-background, not berd's literal bg-card, for the bullet mask. Same rule, different surface: in berd the transcript sits on a card, in Buzz it sits on the drawer's bg-background. The two tokens are not interchangeable here — 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, bg-card paints the bullet rgb(36,41,46) over a rgb(26,26,26) drawer: a visible disc of the wrong shade, which is exactly the BOT-1599 failure berd's note warns about.

  8. Interim notes and relay posts are suppressed on THIS side, via a dedicated prose body and an explicit useIsInsideWorkBlockRail signal, rather than by reaching into the message presenter — so style(desktop): bring the conversation variant closer to berd's recipes #6720 keeps one rule for what a message looks like. Two different routes reach the same wrong result: an interim note is an assistant message (style(desktop): bring the conversation variant closer to berd's recipes #6720 would give it a 20px avatar + name identity row), and a relay messages send step is a tool call that merely classifies as renderClass: "message" (which routes it to a 28px avatar + speech bubble + delivery receipt). Either one nested in a muted rail step reads as the agent replying inside its own work. The signal defaults to false, so the other two variants cannot observe it.

  9. The signal is presentation, not variant. conversation alone is not the condition — the same relay step rendered outside a block in that variant should keep its bubble, and a test pins that half of the branch so suppressing it everywhere cannot pass.

ConversationThought removal

Per ss-core-02's sequencing ruling (b) and ss-dev-01's handoff, the ConversationThought branch of activityRenderClasses/ThoughtActivity.tsx is deleted in this PR, because this is the commit where the rail starts rendering thinking as a row — so no commit ever leaves focus mode with reasoning invisible. The default/compactPreview thought path is untouched. The four conversation-variant thought tests keyed to the old <details> are deleted rather than adapted, since the element they assert no longer exists on that path.

Files

Added:

  • agentSessionWorkBlockGrouping.tsgroupConversationWorkBlocks, conversationSegmentsForBlock, projectWorkBlockEntries, summarizeWorkBlock, formatWorkBlockSummaryLabel, formatPreviousStepsLabel, windowWorkBlockEntries, WORK_BLOCK_LIVE_WINDOW_SIZE = 3, and the WorkBlockItem / WorkBlockEntry types
  • AgentSessionWorkBlock.tsx — the rail UI + AgentSessionWorkBlockSegment
  • AgentSessionWorkBlock.test.mjs, agentSessionWorkBlockGrouping.test.mjs

Modified: AgentSessionTranscriptList.tsx (variant branch, work-block segment kind), ThoughtActivity.tsx, AgentSessionTranscriptList.conversation.test.mjs, agentSessionTranscriptContext.ts (the rail presentation signal), AgentSessionToolItem/ToolItem.tsx (honours it), agentSessionConversationMeta.ts.

Deleted as dead code, each with a comment or test recording why:

  • shared/hooks/useControlledDisclosure.ts + test — the block's trigger is a <button>, so there is no browser toggle echo to guard against and the hook had no remaining consumer.
  • thoughtDurationSecondsById, formatThoughtDisclosureLabel, elapsedSeconds + ~200 lines of tests that only tested themselves. ConversationThought was their only reader, and this PR deletes it. A bug had been reported in that code; fixing dead code would have been worse than removing it.

Verification

Gates at 9c2900e9b: desktop suite 5472 passing / 0 failing, tsc --noEmit clean, pnpm check at main's exact 4-finding baseline (2 warnings + 2 infos, all pre-existing, checked against main rather than assumed), px-text / pubkey-truncation / file-size gates clean. Focused suites: grouping 21/21, AgentSessionWorkBlock.test.mjs 25/25, conversation list 17/17.

Eleven runtime mutants, each run in isolation, all caught: interim note falling through to the tool kind (4 failures), toolEntryState ordering failed-before-running (1), note through the message presenter (2), note given a wrench (1), bullet tinted red (1), prose muted (1), memo keyed on the freshly-projected entry object (2 — the actual bug I hit), streaming hint forced null (4), summary segments not expanded when finding the tail (1), rail bubble suppression removed (the relay-step test stops passing), and swapping the two prose projection branches (the [kind, item.type] pairing assertion fails). Plus the three compile-time mutants in the entry-type table above. Tree restored and re-verified afterward.

The fold animation is asserted in a real browser, not just at its end states — the preview spec samples the panel height per frame while it closes and requires at least one height strictly between full and zero. Re-run at 9c2900e9b: fold heights: 245.5 -> 0 via 41 samples, passing. Non-vacuity re-confirmed at this head by setting COLLAPSE_TRANSITION.duration to 0: the run fails with "the fold must pass through intermediate heights — a details element would jump straight to 0". A <details> element cannot animate height and fails the same way. The reduced-motion endpoint is covered in the same spec.

One honest note on coverage: mutating the echo guard revealed that a block-level echo test I had written was vacuous — the block's disclosure is a <button>, not <details>, so there is no programmatic toggle to echo. The guard and its hook were deleted as dead code rather than kept with a passing-but-empty test.

Screenshots

Seeded through the real __BUZZ_E2E_SEED_OBSERVER_EVENTS__ observer-frame path. No production caller passes variant="conversation" yet — the cover drawer that pins it is ss-dev-00's separate slice — so the variant was pinned in a throwaway worktree with Slice A cherry-picked to capture these. The work block is not reachable in a build until that slice lands alongside this one. The preview spec is not committed to this branch.

Folded (7 steps · 1 failed) Open rail

The rail at review size — every step reads the same way, including the relay post (Sent Confirmed the plural/singular mismatch…), which earlier rendered as a speech bubble with an avatar and delivery receipt:

Measured bullet/surface colours, both themes:

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)

Exact match in both, and asserted (expect(bullet).toBe(drawer)) rather than eyeballed, so the BOT-1599 masking contract holds. Re-run at 9c2900e9b in buzz-dark: {"bullet":"rgb(26, 26, 26)","drawer":"rgb(26, 26, 26)","spine":"rgb(64, 69, 74)"}, passing. Non-vacuity re-confirmed at this head by swapping the bullet to bg-card: Expected: "rgb(26, 26, 26)" / Received: "rgb(36, 41, 46)" — exactly the wrong-shade disc berd's note warns about.

Screenshot hosting: these are raw.githubusercontent.com URLs from scripts/post-screenshots.sh. The previous buzz.block.builderlab.xyz/media/... links returned 401 to GitHub's anonymous camo proxy and rendered broken for anyone reading on GitHub.

@baxen
baxen requested a review from a team as a code owner August 22, 2026 01:23

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 643b310690

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +198 to +200
function isToolStepRunning(item: ToolItem): boolean {
return item.status === "executing" || item.status === "pending";
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Gate running state on live session ownership

When an agent crashes or disconnects after emitting a tool start but before its terminal update, the archived item permanently retains executing or pending. This status-only check therefore marks the card as running whenever that history is reopened, even though AgentSessionTranscriptList already knows there is no matching active turn/live session; the card displays a spinner and ToolRunLiveElapsed keeps increasing from the original timestamp indefinitely. Gate this phase on current turn/session liveness, or terminalize orphaned steps so dead work is shown as stopped or timed out rather than actively running.

AGENTS.md reference: AGENTS.md:L13-L18

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Measured at 9c2900e9b rather than reasoned from the code, with a throwaway JSDOM probe against AgentSessionWorkBlockSegment (deleted; not in the tree). The question survives the rewrite, the answer is "spins forever", and it is worse in the work block than it was on the card.

agentSessionToolRunSummary.ts and ToolRunLiveElapsed are gone with the tool-chain card, so the climbing elapsed timer half of this comment no longer applies: the block reads duration through getToolDurationDisplay, which needs both startedAt and completedAt (or a duration in the result), so an orphan shows no duration at all — no ticking counter.

The running-state half is intact and amplified, because the block's status is now derived from the same unguarded check:

desktop/src/features/agents/ui/agentSessionWorkBlockGrouping.ts:267  toolEntryState()   → status "executing"|"pending" ⇒ "running"
desktop/src/features/agents/ui/agentSessionWorkBlockGrouping.ts:332  summarizeWorkBlock → any "running" entry ⇒ isActive: true

isActive is not just a per-row glyph, it is the block's live-vs-finished policy. Reopening history that contains one orphaned executing step therefore produces, measured:

observation orphan present (no live turn) all steps settled
folded summary line (N steps) absentisActive suppresses it present
rail rows after animations settle 2 of 2, never folds 0 (folded)
data-step-state ["settled","running"] ["settled","settled"]
.animate-pulse nodes 1, indefinitely 0

So reopened history renders as work in progress: no summary line, no fold, and a pulsing bullet. On a 7-step history with step 4 orphaned, the live window also engages — visible steps: 3 of 7, with 4 previous steps behind a nested disclosure, and it never settles. A reader looking at finished history is shown three steps and a spinner, with four hidden.

For contrast I rendered the same item through TranscriptActivityItem outside a block: default-variant pulse nodes: 0, text "Ran d / Waiting for tool details." — a plain muted row. The regression is specific to this PR, because the block is the first consumer that turns tool status into a presentation mode rather than a row label.

The evidence to gate on is already threaded into this component and currently discarded: buildConversationTurnMeta (agentSessionConversationMeta.ts:47) returns EMPTY_TRANSCRIPT_TURN_META for a non-live turn, so the block cannot distinguish "turn not live" from "live but nothing streaming" — both arrive as streamingItemId: null. The minimal correction is to carry isTurnLive in the turn meta and require it before a running entry may set isActive, leaving the glyph free to show a dead step as stopped rather than executing. Flagged to the tech lead for a C-or-follow-up call; not fixed in this push.

Confirmed: not stale. Leaving the thread open.

@baxen

baxen commented Aug 22, 2026

Copy link
Copy Markdown
Contributor Author

Latest revision and validation:

  • Pushed 74b901527a8b8d8b1740693f55f64758237e89bb (origin matches).
  • Full desktop pnpm test: 5414/5414 pass.
  • pnpm typecheck: clean.
  • pnpm check: clean for this change; the four reported Biome findings are pre-existing on main.
  • pnpm check:file-sizes: clean; pre-push hooks green.
  • PR CI for 74b901527: all applicable checks pass, including Desktop Core, Desktop Smoke E2E (1–4), Desktop Build, and Desktop E2E Integration. Inapplicable lanes are skipped.

The PR description now explicitly records the intentional flat threshold change from 3 to 2 and that compactPreview uses the plain legacy Ran N tool calls rendering without chain-card chrome or disclosure policy. One independent P2 review thread remains open about orphaned executing/pending history; it needs a liveness ownership decision rather than a headline-table change.

@ss-core-02

@baxen

baxen commented Aug 22, 2026

Copy link
Copy Markdown
Contributor Author

Validation complete at 74b901527a8b8d8b1740693f55f64758237e89bb:

  • Full desktop pnpm test: 5414/5414 pass.
  • pnpm typecheck: clean.
  • pnpm check: clean for the changed files; the four repository findings are pre-existing on main.
  • pnpm check:file-sizes: clean; pre-push hooks green.
  • PR CI: all applicable checks pass, including Desktop Core, Desktop Smoke E2E (1–4), Desktop Build, Desktop E2E Relay, and Desktop E2E Integration. Inapplicable lanes are skipped.

The PR description now explicitly documents the intentional flat grouping threshold change from 3 to 2 and compactPreview's plain legacy Ran N tool calls presentation without chain-card chrome or disclosure policy.

One independent P2 review thread remains open about orphaned executing/pending history after a crashed or disconnected session. That is a separate liveness-ownership policy question; I have left it visible for review rather than silently changing the scope of the card implementation.

@ss-core-02

@baxen
baxen force-pushed the ss-dev-02/tool-chain-cards branch from 74b9015 to e875102 Compare August 24, 2026 19:52
@baxen baxen changed the title feat(desktop): collapse consecutive tool steps into one tool-chain card feat(desktop): fold focus-mode agent work into one transcript block Aug 24, 2026
@baxen
baxen changed the base branch from main to ss-dev-01/conversation-variant August 24, 2026 20:06
@baxen
baxen force-pushed the ss-dev-02/tool-chain-cards branch from e875102 to a05347d Compare August 24, 2026 21:47
@baxen

baxen commented Aug 24, 2026

Copy link
Copy Markdown
Contributor Author

Revision: rebased onto #6720, and a bug the browser caught

Head: a05347dd0. Rebased onto ss-dev-01/berd-restyle (#6720) per core-02's revised B → #6720 → C ordering; base retargeted to match.

Relay posts are plain rail steps now

Caught by looking at the rendered preview, not by a unit test — the JSDOM suite was green while the rail was visibly wrong.

A buzz messages send step classifies as renderClass: "message", so it rendered through CompactMessageSummary: 28px avatar, bordered speech bubble, timestamp, delivery-receipt button. Correct in the activity feed, where a posted message is a destination to open. On a muted rail it made the agent appear to reply in the middle of its own work.

This is the same failure core-02 flagged for interim notes, reached by a different route: there the item is an assistant message, here it is a tool call that merely classifies as one. Fixed with an explicit presentation signal (useIsInsideWorkBlockRail, default false) rather than by reading the transcript variant — conversation alone is not the condition, since the same relay step outside a block should keep its bubble. Both halves of that branch are pinned by tests, so suppressing the bubble everywhere cannot pass. Defaulting to false keeps default/compactPreview markup byte-identical.

Before / after, same seeded turn:

Before — bubble nested in the rail After — a step like any other
(see the Confirmed the plural/singular mismatch… bubble with avatar + delivery checks in the prior comment's rail shot) work-block-05-rail-crop

Fold animation now asserted in a browser

Per the quality list: the fold must animate, and a unit test can only see end states. The preview spec samples the panel height per frame while it closes and asserts at least one height strictly between full and zero — which a <details>-style jump fails:

[work-block] fold heights: 245.5 -> 0 via 41 samples

Mutation-checked: setting COLLAPSE_TRANSITION.duration to 0 makes it fail with "the fold must pass through intermediate heights". The reduced-motion endpoint is covered in the same spec — the block still ends folded, without settle frames.

Full states

Folded (7 steps · 1 failed) Open rail
work-block-03-folded work-block-04-open-rail

Plan sits as a sibling after the block; the failed step is inside it and named in the folded line; the answer's prose and fenced code are unaffected.

Verification at a05347dd0

  • Desktop suite 5469 passing / 0 failing (5467 + the two new relay tests), tsc --noEmit clean, pnpm check at main's baseline (2 warnings + 2 infos, all pre-existing), px-text / pubkey-truncation / file-size gates clean.
  • Screenshots captured from the seeded e2e session on the composed tree (Slice A cherry-picked in a throwaway worktree, since the cover drawer that pins variant="conversation" lives in dev-00's slice). The preview spec is not committed to this branch.
  • Screenshot URLs re-hosted through scripts/post-screenshots.sh; the old buzz.block.builderlab.xyz/media/... links in the PR body returned 401 to GitHub's camo proxy and are replaced.

work-block-01-opened

work-block-02-prompt-and-thinking

baxen pushed a commit that referenced this pull request Aug 24, 2026
@baxen
baxen changed the base branch from ss-dev-01/conversation-variant to ss-dev-01/berd-restyle August 24, 2026 21:49
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. `<details>` 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.

## A closed entry type, so a row's kind has one answer

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: <thought> }` 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.

## Two props that would have been dead code

- `useControlledDisclosure` is deleted rather than reused. Its entire
  reason to exist was the `<details>` echo trap — `<details>` 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 `<button>`, where the only thing that can call the
  handler is a real click. Keeping the guard would have been dead code
  masquerading as load-bearing. Nothing else in the tree imported it.
- berd brightens rail prose with `usePrimaryText={open}`; here the
  brightening is unconditional. A closed block unmounts its rows rather
  than dimming them, so there is no state in which rail prose is on
  screen and not in an open block — the flag's false branch would be
  unreachable. A test records that reasoning so the divergence is not
  mistaken for an oversight.

## The thought-duration machinery is deleted, not fixed

dev-01 flagged that `turnSegmentItems` does not know `work-block`, so a
thought followed only by block content would never settle its duration.
Tracing it: the meta is built from pre-transform display blocks, so no
`work-block` segment can reach that function — the reported bug cannot
fire. But the trace turned up something worse. `ConversationThought` was
the only reader of `thoughtDurationSecondsById` and
`formatThoughtDisclosureLabel`, and this commit deletes it, leaving ~100
lines of production code and ~200 lines of tests that only tested each
other. Fixing a bug in code with no reader would have preserved the
illusion that focus mode still shows "Thought for Ns" somewhere.

So the duration map, its label formatter and `elapsedSeconds` are gone.
`AgentSessionTranscriptTurnMeta` narrows to the one field that still has
a consumer: `streamingItemId`, which the work block needs because a
thought or note streaming in carries no status of its own. Its tests are
rewritten around what that hint must actually get right — the tail of a
live turn, skipping setup, expanding a summary segment to its last leaf,
and reporting nothing at all when the turn is idle.

Three further notes on translation rather than transcription:

- berd's bullet masks the spine with `bg-card` and its BOT-1599 note
  warns off `bg-background`. The rule is "mask with the surface the
  transcript is drawn on"; in Buzz that surface is the cover drawer,
  which is literally `bg-background`. The two tokens are NOT
  interchangeable here: `[data-buzz-content-surface]` locally overrides
  `--background` to `--buzz-content-dark` while `--card` keeps the theme
  value, so in Buzz Dark `bg-card` paints the bullet rgb(36,41,46) over an
  rgb(26,26,26) drawer — the exact BOT-1599 failure. Light mode matches
  under either class, so light-mode evidence alone would not have caught
  it. Copying the class would have followed the letter of berd's note
  against its point.
- Reduced motion is read via `matchMedia` — the way `TerminalSubstrate`
  reads it — not motion's `useReducedMotion`, which resolves the query
  once per process and caches it. That cache made the preference
  untestable (the assertion turned on module load order, not on the
  setting) and ignored a mid-session change.
- The memo takes the entry SPREAD into props, not the entry object, and
  its boundary is the step BODY rather than the whole row. The projection
  is rebuilt whenever the item array changes, so entry objects are fresh
  on every append and a memo keyed on one would never hit; spread, the
  compared props are `item` (reference-stable) plus two strings. Spreading
  also keeps the union intact, so the body switch still narrows `item`
  from `kind`. The row stays outside because the glyph depends on
  `isLast`, which changes for the previous last row on every append.

`ConversationThought` is deleted here rather than in dev-01's restyle,
per core-02's sequencing ruling: this is the commit that replaces it, so
reasoning stays visible in focus mode at every commit. Its four
disclosure-keyed tests are deleted rather than adapted — they assert a
`<details>` that no longer exists on that path. `default`/
`compactPreview` thought rendering is untouched.

Non-vacuous by mutation testing, each mutant run in isolation:

- note falls through to the tool kind: 4 failures.
- note routed through the message presenter: 2. Note given the wrench: 1.
- `entryState` checking failure before running: 1.
- rail bullet tinted on failure: 1. Prose muted instead of primary: 1.
- memo keyed on the projected entry object: 2 — this is the bug the
  projection actually introduced, caught by the pre-existing streaming
  cost tests before it shipped.
- streaming hint forced to null: 4. Summary segments not expanded when
  finding the tail: 1.
- windowing keyed off `open` instead of the reader's choice: 3. Policy
  already holds live blocks open, so this switches windowing off in
  exactly the case it exists for.
- reduced-motion preference forced false: 1.
- rail bubble suppression removed (the bug above): the relay-step test
  stops passing.

An earlier mutant also caught a test lying: the block-level "echo" test
passed with the guard removed, because the trigger is a `<button>` and
nothing was listening for `toggle` at all. It now asserts the structural
reason the trap cannot apply plus a repeated fold→reopen→fold cycle,
which is what a recorded echo would actually have disabled.

Relay posts are plain rail steps. A `buzz messages send` step classifies
as `renderClass: "message"`, so it renders through `CompactMessageSummary`
— 28px avatar, bordered speech bubble, timestamp, delivery-receipt
button. Correct in the activity feed, where a posted message is a
destination to open; on a muted rail it makes the agent appear to reply
in the middle of its own work. This is the same failure the interim-note
case avoids, reached by a different route: there the item IS an assistant
message, here it is a tool call that merely classifies as one. Caught by
looking at the seeded browser preview, not by a unit test — the JSDOM
suite was green while the rendered rail was wrong.

Suppressed with an explicit presentation signal
(`useIsInsideWorkBlockRail`, default false) rather than by reading the
transcript variant, because `conversation` alone is not the condition:
the same relay step rendered OUTSIDE a block in that variant should keep
its bubble. Both halves of that branch are now pinned by tests, so
suppressing the bubble everywhere cannot pass. Defaulting to false keeps
`default`/`compactPreview` markup byte-identical.

Rebased onto #6720 at `e8709554a` per core-02's B → #6720 → C ordering.

Verified at this tree: full desktop suite 5472 passing / 0 failing,
`tsc --noEmit` clean, `pnpm check` findings identical to main's baseline
(2 warnings + 2 infos, all pre-existing), px-text/pubkey-truncation/
file-size gates clean.

Co-authored-by: Bradley Axen <baxen@squareup.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>
@baxen
baxen force-pushed the ss-dev-02/tool-chain-cards branch from a05347d to 9c2900e Compare August 24, 2026 22:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant