style(desktop): bring the conversation variant closer to berd's recipes - #6720
style(desktop): bring the conversation variant closer to berd's recipes#6720baxen wants to merge 4 commits into
Conversation
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>
There was a problem hiding this comment.
💡 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".
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
left a comment
There was a problem hiding this comment.
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.tsxnow placesUserAvatar(displayName={agentName})immediately before visible{agentName}text.UserAvatarexposes either an image named${displayName} avataror fallback initials, so the row announces the same identity twice. Please mark this avatar decorative at this call site (for example, with anaria-hiddenwrapper 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.
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>
…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>
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>
|
@wesbillman — both findings are fixed at P2 — identity row announced the agent twice (Carl)Confirmed the diagnosis exactly as written. Fixed at the call site in Chose the call site over a decorative mode on shared Verified in a real accessibility tree, not just the DOM. Chromium via CDP Geometry identical pre/post — row 114×20, wrapper 20×20 Coverage, proven non-vacuous by mutation — new test
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 — Gates at this exact head on a clean tree: full desktop suite 5432 passing / 0 failing (not scoped), |
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>
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>
Follow-up to #6538 (Slice B), stacked on
ss-dev-01/conversation-variant@5e3faf443. Base retargets tomainonce 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.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:
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: autoancestor is this<pre>.The 640px cap holds at cover width, and the context does not leak into channel rows.
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, notMarkdownCodeBlock, so they are structurally unreachable); the header's copy action is keyboard reachable (tabIndex 0, reveals to full opacity on focus,Entercopies 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,
ChannelAgentSessionAgentcarries no avatar field at all, so the panel passedagentAvatarUrl: nulland 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:Same probe reported
hasImg: false/fallbackText: "OA"before andhasImg: truewith a decodednaturalWidthafter. It now resolvesprofiles[agentPubkey]?.avatarUrlfirst and keeps the prop as the fallback, matchingToolItem.tsx:45-52and the header's ownresolveUserLabel— 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).conversationonly. A test asserts the row does not leak intodefault/compactPreviewso 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'srounded-smis 12px on its own scale (globals.css --radius-sm: 12px), not Tailwind's stock 2px — Buzz'srounded-xlis 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'srounded-lgis--radius: 0.625rem— exactly berd'srounded-[0.625rem].The markdown renderer is shared with channel messages, so the recipe is opt-in:
CodeBlockVariantContextis read at render time byMarkdownCodeBlockand provided once byAgentSessionTranscriptList, 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 reasonVideoReviewMarkdownContextalready exists. A context provider renders no DOM, so the wrap is unconditional anddefault/compactPreviewmarkup 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
ThemeProviderandTooltipProviderto mount, so those tests use a separaterenderTranscriptWithCodeChromehelper — 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:
defaultfocusProsedefault-variant guard alonedefaultprovider re-added around only the user bubble?? agentAvatarUrlfallback droppedLoadedImageStubremovedaria-hiddenremovedaria-labelre-added outside the hidden subtreeThe
default-variant guard is writtenassert.ok(x === null)rather thanassert.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.mdgains that rule under Testing.The
LoadedImageStubin the conversation test file is load-bearing rather than masking: RadixAvatarImagerenders nothing until its own preloader reportsloaded, 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
fd2e01799src/**/*.test.mjs, not scoped)tsc --noEmitcleanbiome checkat main's baseline (2 warnings / 2 infos, unchanged)desktop-check,desktop-typecheck,desktop-test,file-size-check)markdown.tsxis 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 anaria-hiddenspan, leaving the visible name as the row's single accessible identity. Marked at this call site rather than in the sharedUserAvatar, 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:Row geometry is identical across the two (row 114x20, avatar 20x20, label 90x16, 4px gap), so moving
shrink-0to 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-checkbecause that script only scans.ts/.tsxundersrc/features, so the gate never covered it even though AGENTS.md's rule does. Split three ways — a sharedconversationHarness.mjs(574) holding the jsdom setup, the order-sensitive TZ/locale pins, fixtures and render helpers, plusconversation.test.mjs(521) andconversationChrome.test.mjs(274). The harness follows the existingobservedUnreadTestHarness.mjsprecedent; 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
ConversationThoughtstays 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.