Skip to content

style(desktop): bring the conversation variant closer to berd's recipes - #6720

Open
baxen wants to merge 4 commits into
ss-dev-01/conversation-variantfrom
ss-dev-01/berd-restyle
Open

style(desktop): bring the conversation variant closer to berd's recipes#6720
baxen wants to merge 4 commits into
ss-dev-01/conversation-variantfrom
ss-dev-01/berd-restyle

Conversation

@baxen

@baxen baxen commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #6538 (Slice B), stacked on ss-dev-01/conversation-variant @ 5e3faf443. Base retargets to main once B merges.

dev-00's design-delta note measured the conversation variant against berd's shipping chat surface. This takes the three named divergences literally, on Buzz tokens rather than berd's.

Screenshots

Seeded through the real observer-frame path, transcriptVariant="conversation" temporarily pinned for capture (that pin is dev-00's slice, not in this branch). The prompt carries a fenced block so the code chrome is visible in both roles.

light dark
light-panel dark-panel

Full window: light · dark

Behaviour evidence

Measured in a real Chromium build (not from reading the CSS), so a reviewer does not have to regenerate it.

The prompt-side fence scrolls; it does not clip. In the panel-width shots above the fence looks cut at the bubble edge, because no scrollbar is painted at rest. Measured on that element:

clientWidth 286, scrollWidth 1251, overflow-x auto
scrollLeft = 9999  ->  settles at 965

Its right edge stays inside the bubble, and neither the transcript log nor the document gains horizontal scroll. Every element painting past the log's right edge is a code token whose nearest overflow-x: auto ancestor is this <pre>.

The 640px cap holds at cover width, and the context does not leak into channel rows.

fixed 640px cap at a 1600px panel channel row mounted while the transcript is open
wide-panel-640px-cap channel-row-no-leak

Left: the prompt column measures exactly 640px regardless of panel width, and a 166-character unbreakable token wraps inside the bubble instead of pushing it.

Right: the leak test. With the focus transcript open, a fenced message was posted into #agents, so a channel row mounts while the provider is live. The channel row keeps the legacy 16px frame with no header, while both transcript fences keep the 10px header recipe — visible side by side in one frame. Provider scope is exactly the transcript subtree.

Also verified: the recipe reaches only the two real markdown fences (the five tool-output <pre>s are hand-rolled, not MarkdownCodeBlock, so they are structurally unreachable); the header's copy action is keyboard reachable (tabIndex 0, reveals to full opacity on focus, Enter copies the exact fence body); and the identity row keeps its 20px height with initials when an agent has no avatar.

The identity row resolves the agent through the profiles lookup. The third commit fixes a P2 found by a bug pass: when the focus conversation is opened from a channel, ChannelAgentSessionAgent carries no avatar field at all, so the panel passed agentAvatarUrl: null and the row fell back to initials — while the panel header directly above it showed the real profile avatar. Captured through the real channel-opened flow, not the seeded one, since that is the path that was wrong:

before — initials, disagreeing with the header after — resolved, agreeing with the header
identity-avatar-before identity-avatar-after

Same probe reported hasImg: false / fallbackText: "OA" before and hasImg: true with a decoded naturalWidth after. It now resolves profiles[agentPubkey]?.avatarUrl first and keeps the prop as the fallback, matching ToolItem.tsx:45-52 and the header's own resolveUserLabel — so a locally managed agent whose avatar was never published to a relay profile still renders.

Agent identity row

The largest gap was that agent prose carried no attribution at all — a reply read as unowned body text in a full-cover view. berd labels every agent turn with a 20px round avatar plus the name at text-xs, mb-0.5, gap-1 (MessageBubble.tsx:961-981).

conversation only. A test asserts the row does not leak into default/compactPreview so a failure names the cause instead of printing a markup diff.

Prompt bubble

berd's user turn is a soft fill with no border at all, px-4 py-2, 12px radius (MessageBubble.tsx:990). berd's rounded-sm is 12px on its own scale (globals.css --radius-sm: 12px), not Tailwind's stock 2px — Buzz's rounded-xl is the exact equivalent, so the radius drops from 16px rather than collapsing to a hairline.

The cap moves from max-w-[85%] to a fixed 640px measure mirroring --chat-user-message-max-width (globals.css:615). A percentage cap re-wraps the prompt on every resize of the cover; a fixed measure holds one stable line length, which is the point of the recipe.

Fenced code

berd puts the language in a real header row above the frame with the copy action opposite it, and frames the code at a 10px radius on the page background behind a subtle border, no shadow (ai-elements/code-block.tsx:379, :395, :528-529). Buzz's rounded-lg is --radius: 0.625rem — exactly berd's rounded-[0.625rem].

The markdown renderer is shared with channel messages, so the recipe is opt-in: CodeBlockVariantContext is read at render time by MarkdownCodeBlock and provided once by AgentSessionTranscriptList, alongside the variant and turn-meta providers.

A prop would have to thread through createMarkdownComponents, whose component map must stay module-stable and whose parsed-node cache keys on a variant string — the same reason VideoReviewMarkdownContext already exists. A context provider renders no DOM, so the wrap is unconditional and default/compactPreview markup stays byte-identical.

The recipe is a property of the surface, not of a role — the second commit fixes that. It was first mounted inside MessageActivity, which only handles assistant items, so a fence inside a human prompt kept the legacy 16px muted frame nested inside the new 12px bubble. Quality caught it; the provider now sits at the transcript boundary and both roles inherit it.

Test notes

Fenced blocks need ThemeProvider and TooltipProvider to mount, so those tests use a separate renderTranscriptWithCodeChrome helper — the byte-for-byte fixture keeps rendering through the exact tree it was captured with.

Every new code-block test was proven non-vacuous by mutation:

mutation fails
boundary provider pinned to default both fenced tests
boundary provider pinned to focusProse the default-variant guard alone
role-scoped default provider re-added around only the user bubble the fenced-prompt test alone
identity-row profile resolution reverted exactly the 2 resolution tests
?? agentAvatarUrl fallback dropped the fallback test alone
LoadedImageStub removed both avatar tests
identity-row aria-hidden removed the a11y test alone
a name-bearing aria-label re-added outside the hidden subtree its escape assertion

The default-variant guard is written assert.ok(x === null) rather than assert.equal(x, null). On failure the latter serializes the matched jsdom element and its subtree to build a diff, which exhausts memory (SIGKILL after ~100s) instead of printing the assertion message — a genuine failure was unreadable. AGENTS.md gains that rule under Testing.

The LoadedImageStub in the conversation test file is load-bearing rather than masking: Radix AvatarImage renders nothing until its own preloader reports loaded, and jsdom never fetches, so both avatar assertions would otherwise pass vacuously against the initials fallback — the exact bug under test. It only affects avatars that have a url, so the byte-for-byte baseline (whose agent carries none) is untouched.

Gates at fd2e01799

  • desktop suite 5432 passing / 0 failing (full src/**/*.test.mjs, not scoped)
  • tsc --noEmit clean
  • biome check at main's baseline (2 warnings / 2 infos, unchanged)
  • px-text, pubkey-truncation, file-size ratchet clean
  • pre-push hooks green (desktop-check, desktop-typecheck, desktop-test, file-size-check)

markdown.tsx is byte-identical — the file-size ratchet forbids growth there, and it was a useful forcing function toward the context design.

Accessibility. The identity row put UserAvatar's own accessible name (<img alt="${displayName} avatar">, or its initials fallback) immediately before the same name as visible text, so assistive tech announced the agent twice per turn. The avatar is now wrapped in an aria-hidden span, leaving the visible name as the row's single accessible identity. Marked at this call site rather than in the shared UserAvatar, whose accessible name is depended on by all 45 of its call sites. Confirmed in Chromium's real accessibility tree over CDP, not from DOM attributes:

pre-fix   role "image", name "Observer Agent avatar", ignored false
post-fix  ignored true, ignoredReasons ["ariaHiddenSubtree"]

Row geometry is identical across the two (row 114x20, avatar 20x20, label 90x16, 4px gap), so moving shrink-0 to the wrapper is not a visual change.

Test file layout. The conversation suite reached 1253 lines, over the repo's hard 1000-line ceiling. It slipped past file-size-check because that script only scans .ts/.tsx under src/features, so the gate never covered it even though AGENTS.md's rule does. Split three ways — a shared conversationHarness.mjs (574) holding the jsdom setup, the order-sensitive TZ/locale pins, fixtures and render helpers, plus conversation.test.mjs (521) and conversationChrome.test.mjs (274). The harness follows the existing observedUnreadTestHarness.mjs precedent; duplicating those pins across two files is how they drift apart. Test bodies moved verbatim, and a name-by-name diff against the previous head confirms zero tests lost and one added.

Not in scope

ConversationThought stays in place per ss-core-02's ruling: dev-02's work-block PR (#6536) is the commit that replaces it, so no commit ever ships focus mode without visible reasoning. Tool items untouched. The identity row appearing on interim notes inside C's work block is C's to resolve.

dev-00's design-delta note measured the conversation variant against
berd's shipping chat surface and named three divergences that make the
focus view read as "not berd" regardless of how the rest is arranged.
This takes all three literally, on Buzz tokens rather than berd's.

**Agent identity row.** The largest gap was that agent prose carried no
attribution at all, so a reply read as unowned body text in a full-cover
view. berd labels every agent turn with a 20px round avatar plus the name
at `text-xs`, `mb-0.5`, `gap-1` (`MessageBubble.tsx:961-981`). Added for
the `conversation` variant only — the other variants' markup is pinned
byte-for-byte by the baseline fixture, and a test asserts the row does
not leak into them so a failure names the cause instead of printing a
markup diff.

**Prompt bubble.** berd's user turn is a soft fill with no border at all,
`px-4 py-2`, and a 12px radius (`MessageBubble.tsx:990`). berd's
`rounded-sm` is 12px on its own scale (`globals.css --radius-sm: 12px`),
not Tailwind's stock 2px; Buzz's `rounded-xl` is the exact equivalent, so
the radius drops from 16px rather than collapsing to a hairline. The cap
moves from `max-w-[85%]` to a fixed 640px measure mirroring berd's
`--chat-user-message-max-width` (`globals.css:615`): a percentage cap
re-wraps the prompt on every resize of the cover, while a fixed measure
holds one stable line length.

**Fenced code.** berd puts the language in a real header row above the
frame with the copy action opposite it, and frames the code at a 10px
radius on the page background behind a subtle border, with no shadow
(`ai-elements/code-block.tsx:379`, `:395`, `:528-529`). Buzz's
`rounded-lg` is `--radius: 0.625rem` — exactly berd's `rounded-[0.625rem]`.

The markdown renderer is shared with channel messages, so this recipe is
opt-in: `CodeBlockVariantContext` is read at render time by
`MarkdownCodeBlock` and provided by `MessageActivity`. A prop would have
to thread through `createMarkdownComponents`, whose component map must
stay module-stable and whose parsed-node cache keys on a variant string —
the same reason `VideoReviewMarkdownContext` already exists. A context
provider renders no DOM, so `MessageActivity` can wrap unconditionally
and `default`/`compactPreview` markup stays byte-identical. A companion
test renders the same fenced block through `default` and asserts the
original chrome (16px radius, muted fill, `pr-12`, absolutely-positioned
copy button) is untouched.

Fenced blocks need `ThemeProvider` and `TooltipProvider` to mount, so
those tests use a separate `renderTranscriptWithCodeChrome` helper; the
byte-for-byte fixture keeps rendering through the exact tree it was
captured with.

Both new code-block tests were proven non-vacuous by mutation: forcing
the provider to `default` everywhere fails only the focus-header test,
and forcing `focusProse` everywhere fails only the channel-message guard.
That guard is written as `assert.ok(x === null)` rather than
`assert.equal(x, null)` — on failure the latter serializes the matched
jsdom element and its subtree to build a diff, which exhausts memory
instead of printing the message.

Verified at this tree: full desktop suite 5427 passing / 0 failing,
`tsc --noEmit` clean, `biome check` at main's baseline, px-text /
pubkey-truncation / file-size checks clean.

Signed-off-by: ss-dev-01 <11939edb7df583f855dbef923f2358f1184538f88ca452e19e7e35e42ad6d796@buzz.block.builderlab.xyz>
Co-authored-by: Bradley Axen <baxen@squareup.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>
@baxen
baxen requested a review from a team as a code owner August 24, 2026 20:14

@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: f36b699220

ℹ️ 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 thread desktop/src/features/agents/ui/AgentSessionTranscriptList.conversation.test.mjs Outdated
Quality review caught a real bug in the previous commit. The
`CodeBlockVariantContext` provider was mounted inside `MessageActivity`,
which only handles assistant items — `UserMessageBubble` returns before
it. A fence inside a human prompt therefore kept the legacy 16px muted
frame, nested inside the new 12px prompt bubble.

The recipe is a property of the *surface*, not of a role: a fenced block
in a prompt should read the same as one in a reply. The provider moves up
to `AgentSessionTranscriptList`, alongside the variant and turn-meta
providers, so every descendant markdown render inherits it. It still
renders no DOM, so `default`/`compactPreview` markup stays byte-identical
and the baseline fixture is unchanged.

Added a fenced-prompt test, which the existing fenced test could not
catch because it only rendered an assistant item. Proven non-vacuous two
ways: pinning the boundary provider to `default` fails both fenced tests,
and re-adding a role-scoped `default` provider around only the user
bubble's markdown fails the new prompt test alone.

Also renamed `channel-message code blocks are untouched by the focus
recipe` to `the default transcript variant keeps the legacy code chrome`.
It renders the `default` transcript variant, not a channel message row, so
the old name claimed a contract it did not prove.

AGENTS.md gains the `assert.equal(el, null)` note: that form serializes
the matched element's whole subtree to build a failure diff and OOMs the
runner instead of printing the message, which made a genuine failure
unreadable. Use `assert.ok(x === null)`.

Verified at this tree: full desktop suite 5428 passing / 0 failing,
`tsc --noEmit` clean, `biome check` at main's baseline, px-text /
pubkey-truncation / file-size clean. Screenshots captured light and dark
by temporarily pinning `transcriptVariant="conversation"` in
`AgentSessionThreadPanel`; that pin is dev-00's slice and is not in this
commit.

Signed-off-by: ss-dev-01 <11939edb7df583f855dbef923f2358f1184538f88ca452e19e7e35e42ad6d796@buzz.block.builderlab.xyz>
Co-authored-by: Bradley Axen <baxen@squareup.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Carl, an automated reviewer, commenting via Wes’s GitHub account.

One accessibility issue blocks this as written:

  • P2: Hide the decorative avatar in the new assistant identity row from assistive technology. MessageActivity.tsx now places UserAvatar(displayName={agentName}) immediately before visible {agentName} text. UserAvatar exposes either an image named ${displayName} avatar or fallback initials, so the row announces the same identity twice. Please mark this avatar decorative at this call site (for example, with an aria-hidden wrapper or a supported decorative-avatar mode) and add coverage for the accessible tree/name. The visible name should remain the row’s single accessible identity.

I verified the stacked diff at 4818d1cbbb8198d34d8710dc3fc493a82460e747 against 5e3faf443a26cb9d1ca2de7a1ac96efe7701168c. The Markdown context/cache boundary is sound, default and compact variants remain isolated, the fenced-code recipe reaches both user and assistant Markdown, focused transcript tests pass 21/21 locally, and current CI is green. I am treating activation of the conversation surface as belonging to the stacked integration slice rather than this styling slice.

baxen added a commit that referenced this pull request Aug 24, 2026
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 `{ item, kind, state }` entries, and the
glyph, the body and the folded line's counts all read that projection.
`kind` is `thought | note | tool` and `state` is `running | failed |
settled`. 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 now exhaustive switches over
`kind` and neither asks `item.type` again.

That also fixes the ordering bug the split invited: a tool carrying a
stale `isError` from a retry while the new attempt executes now 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's FIELDS, 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; `item` is
  reference-stable and `kind` is a string. 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 per core-02's B → #6720 → C ordering.

Verified at this tree: full desktop suite 5469 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>
…es lookup

The conversation variant's identity row read the raw `agentAvatarUrl` and
`agentName` props. The primary channel-opened flow passes
`agentAvatarUrl: null`, because `ChannelAgentSessionAgent`
(useChannelAgentSessions.ts:21-29) carries no avatar field at all — so
every channel-opened focus session showed initials in the identity row
while the panel header directly above it showed the real profile avatar.
The two disagreed about the same agent on the same surface.

Resolve profile-first out of the `profiles` lookup the panel already
hands down, exactly as `ToolItem` (ToolItem.tsx:45-52) and the panel
header (AgentSessionThreadPanel.tsx:243-249) already do. The props stay
as the fallback rather than being replaced: a locally managed agent can
hold an avatar its relay profile never published.

Found by ss-bugs-02's bug pass on #6720.

Three tests: the profile avatar wins over a null prop (the channel path),
the profile display name wins over a stale prop name, and the caller's
avatar survives when the lookup has none. Proven non-vacuous by three
mutations — reverting the resolution fails exactly the two resolution
tests, dropping the `?? agentAvatarUrl` fallback fails only the fallback
test, and removing the `LoadedImageStub` fails both avatar tests.

That stub is load-bearing, not masking: Radix `AvatarImage` renders
nothing until its own preloader reports `loaded`, and jsdom never
fetches, so both avatar assertions would otherwise have passed
vacuously against the initials fallback — the very bug under test. It
only affects avatars that have a url, so the byte-for-byte
`default`/`compactPreview` baseline fixture stays byte-identical.

Verified empirically through the real channel-opened flow in a Chromium
build, not just jsdom. Pre-fix the row rendered `hasImg: false` with an
"OA" initials fallback while the header showed the real image; post-fix
it renders the decoded `<img>` and the resolved name. That probe harness
is untracked and not in this commit.

Signed-off-by: ss-dev-01 <11939edb7df583f855dbef923f2358f1184538f88ca452e19e7e35e42ad6d796@buzz.block.builderlab.xyz>
Co-authored-by: Bradley Axen <baxen@squareup.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>
baxen pushed a commit that referenced this pull request Aug 24, 2026
…rsation suite

Two review findings on #6720.

**P2 (Carl, CHANGES_REQUESTED): the identity row announced the agent
twice.** `UserAvatar` names itself — an `<img alt="${displayName} avatar">`
or its fallback initials — and the row puts the same name in visible text
immediately after it, so assistive tech read the identity twice for every
agent turn in the transcript. Wrap the avatar in an `aria-hidden` span so
the visible name is the row's single accessible identity.

Marked at this call site rather than by teaching the shared `UserAvatar` a
decorative mode: other rows pair an avatar with adjacent name text and
would want the same treatment, but changing the shared component's
accessible name affects all 45 of its call sites and is not this PR's
scope.

Verified in Chromium's real accessibility tree over CDP, not from the DOM
attributes, through the channel-opened flow:

  pre-fix   role "image", name "Observer Agent avatar", ignored false
  post-fix  ignored true, ignoredReasons ["ariaHiddenSubtree"]

Geometry is unchanged across the two (row 114x20, avatar 20x20, label
90x16, 4px gap), so moving `shrink-0` onto the wrapper is not a visual
change. The new test is non-vacuous by two mutations: removing the
decorative marking fails it and only it, and re-adding a name-bearing
`aria-label` outside the hidden subtree fails its escape assertion with
the offending element named.

**P1 (Codex): the conversation test file exceeded the 1000-line
ceiling.** It reached 1253 lines. It slipped past `file-size-check`
because that script only scans `.ts`/`.tsx` under `src/features`, so the
gate I reported as green never covered this file — the rule in AGENTS.md
plainly does. Split into:

  AgentSessionTranscriptList.conversationHarness.mjs      574  (shared)
  AgentSessionTranscriptList.conversation.test.mjs        521
  AgentSessionTranscriptList.conversationChrome.test.mjs  274

The shared jsdom setup, order-sensitive TZ/locale pins, fixtures and
render helpers live in a non-test harness module, following the existing
`observedUnreadTestHarness.mjs` precedent — duplicating those pins into
two files is exactly how they drift. Test bodies are moved verbatim; a
name-by-name diff against the previous head confirms zero tests lost and
exactly one added. Gate script untouched, per the routing of its `.mjs`
coverage gap to a separate follow-up.

Full desktop suite 5432 passing / 0 failing, including the byte-for-byte
`default`/`compactPreview` fixture.

Signed-off-by: ss-dev-01 <11939edb7df583f855dbef923f2358f1184538f88ca452e19e7e35e42ad6d796@buzz.block.builderlab.xyz>
Co-authored-by: Bradley Axen <baxen@squareup.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>
baxen added a commit that referenced this pull request Aug 24, 2026
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 commented Aug 24, 2026

Copy link
Copy Markdown
Contributor Author

@wesbillman — both findings are fixed at fd2e01799 (CI green: 14 SUCCESS / 0 FAILURE / 0 pending, including Desktop Core and all four Desktop Smoke E2E shards). Re-requesting review.

P2 — identity row announced the agent twice (Carl)

Confirmed the diagnosis exactly as written. Fixed at the call site in MessageActivity.tsx by wrapping the avatar in <span aria-hidden="true" className="flex shrink-0"> (moving shrink-0 off UserAvatar onto the wrapper so the layout is unchanged).

Chose the call site over a decorative mode on shared UserAvatar deliberately: UserAvatar has ~45 call sites, and changing a shared component's accessible-name behaviour is a wider blast radius than this PR should carry. The sibling with the identical avatar+adjacent-name shape (ForumPostCard.tsx:91-95) is noted on #6726 rather than touched here.

Verified in a real accessibility tree, not just the DOM. Chromium via CDP Accessibility.getPartialAXTree (page.accessibility is gone in this Playwright version):

PRE-FIX   role: "image", name: "Observer Agent avatar", ignored: false
POST-FIX  ignored: true, ignoredReasons: ["ariaHiddenSubtree"]

Geometry identical pre/post — row 114×20, wrapper 20×20 flex-shrink: 0, avatar 20×20, label 90×16, gap 4px — confirming the shrink-0 relocation is visually inert.

Coverage, proven non-vacuous by mutation — new test the conversation identity row announces the agent exactly once asserts the hidden wrapper exists, the image still renders with the resolved src, and that every name-bearing node (img[alt], [aria-label]) inside the row lives inside the hidden subtree:

mutation result
remove the aria-hidden marking exactly 1 test fails — the new one — with the avatar must be hidden from the accessible tree
let a name-bearing aria-label escape the hidden subtree fails with SPAN carrying an accessible name escapes the hidden avatar subtree

P1 — 1253-line test file (Codex)

Split three ways (harness 576 / conversation 521 / conversationChrome 274), no gate-script change, details in the resolved thread above. Test-name diff against the pre-split head: 24 before, 25 after — zero lost, one added.

Also filed the root cause of why local gates were green for a 1253-line file — check-file-sizes.mjs allowlists only .ts/.tsx, so the entire *.test.mjs corpus sits outside the AGENTS.md:608 ceiling — on #6726 with a ratchet-based fix and the current scale of the gap (12 files over, up to 2452 lines).

Gates at this exact head on a clean tree: full desktop suite 5432 passing / 0 failing (not scoped), tsc --noEmit clean, biome check at main's baseline, px-text / pubkey-truncation / file-size clean.

@baxen
baxen requested a review from wesbillman August 24, 2026 23:10
baxen added a commit that referenced this pull request Aug 25, 2026
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.

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.

- `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.

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 added a commit that referenced this pull request Aug 25, 2026
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.

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.

- `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.

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>
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.

2 participants