mirror of
https://github.com/block/buzz.git
synced 2026-10-30 05:51:46 +00:00
Slice C. 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 the
same 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 —
no future edit to the block can regress the other two variants without first
moving that branch.
Behaviour, mirroring berd:
- Live: the block is open and the rail IS the status, so there is no header
line restating it. The last three steps show in arrival order; older ones go
behind an "N previous steps" disclosure, 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 — its content is either laid out or
not, with no intermediate height — so 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.
- Reader choice wins: once the reader toggles a block, policy stops opening and
closing it.
One deliberate departure from berd: 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 —
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 how a headline and chain eligibility drifted apart on an
earlier attempt. The projection is closed in the type system rather than by
convention, so a future `TranscriptItem` variant cannot silently wear a wrench
and impossible pairs are not representable.
Four defects fixed on top, each mutation-checked in isolation:
- **A reader's open block was folded shut by the agent posting again.** Work
blocks are derived and ided after their first step, and only the LAST
assistant message is exempt from the block — so a second answer demotes the
first, the runs on either side merge, the old block unmounts, and the
`useState` holding the reader's expansion goes with it. The choice now lives
above the block, keyed by the STEP ids it was taken on, since the steps
survive regrouping. An open choice beats a folded one (hiding steps a reader
asked for loses what they were reading; showing extra costs a scroll), and
the choice is recorded against every step in the block — recording only the
first leaves the absorbed block's stale `open` entry behind and, since open
wins, the merged block could never be folded again.
- **A finished block went live again between turns.** `lastTurnId` walked the
display blocks, but a turn that has only emitted setup lifecycle rows
classifies to zero segments and produces no block — so for the whole gap
between `turn_started` and the next turn's first renderable item, "newest
turn with a block" was the turn that had already ended, and its trailing item
became the streaming item: a settled 6-step block re-opened, dropped to its
last three steps, then folded back. Every turn, not an edge case. Liveness
now comes from the newest turn id in the item stream, and a trailing item is
only reported as streaming when the live turn owns it.
- **The running step's pulse ignored reduced motion.** `WorkBlockRailGlyph`
used bare `animate-pulse`, which no `prefers-reduced-motion` block in the app
matches — all 20 are scoped to `buzz-*`/`motion-*`/`t-skel-*`. Now
`motion-safe:animate-pulse`. That swap also repaired three assertions it
would otherwise have disarmed: they matched `.animate-pulse` as a substring
and, worse, all three ran on a folded rail with no rows, so "nothing pulses"
held against a build where abandoned steps pulse forever.
- **A work block whose running step had no live session never settled.**
Two test-hygiene fixes worth keeping: a gap test was pinning a sequence whose
`session/new` card moved `streamingItemId` on its own, so those frames passed
against the pre-fix code and proved nothing (the plain no-restart path now
leads); and the orphaned suite inherited React Query's default 300000ms
`gcTime`, arming a five-minute timer at teardown that node:test waited out —
`gcTime: 0` takes the full desktop suite from 307s to 102s, verified against a
negative control rather than on the passing tree alone.
Squashed from ss-dev-02/tool-chain-cards (#6536) at 91a5754ad.
Co-authored-by: ss-dev-02 <9e916f802f7932c38e630ac6b4726f5db7ca326c0d98e9bab495309eef13fe5a@buzz.block.builderlab.xyz>
Co-authored-by: Bradley Axen <baxen@squareup.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>
Signed-off-by: ss-dev-00 <a02c4e0850e5e612b4ddf95dbe2f5c56467cf27c6552203bc833ff438fb31971@buzz.block.builderlab.xyz>
102 lines
4.1 KiB
TypeScript
102 lines
4.1 KiB
TypeScript
import * as React from "react";
|
|
|
|
/**
|
|
* Presentation modes for the observer transcript.
|
|
*
|
|
* - `default` — the polished activity feed (agent panel, thread panel).
|
|
* - `compactPreview` — dense, single-column preview (profile panel cards).
|
|
* - `conversation` — full-cover focus-mode reading view: prompts as
|
|
* right-aligned bubbles, agent messages as unboxed prose,
|
|
* thoughts/plans as disclosures, lifecycle as quiet
|
|
* dividers. Tool items render exactly as `default`.
|
|
*/
|
|
export type AgentSessionTranscriptVariant =
|
|
| "default"
|
|
| "compactPreview"
|
|
| "conversation";
|
|
|
|
const AgentSessionTranscriptVariantContext =
|
|
React.createContext<AgentSessionTranscriptVariant>("default");
|
|
|
|
export const AgentSessionTranscriptVariantProvider =
|
|
AgentSessionTranscriptVariantContext.Provider;
|
|
|
|
export function useAgentSessionTranscriptVariant() {
|
|
return React.useContext(AgentSessionTranscriptVariantContext);
|
|
}
|
|
|
|
/**
|
|
* Per-render derivation the `conversation` variant needs but individual render
|
|
* classes cannot see on their own: which item is currently streaming (the
|
|
* trailing item of a live turn). Derived once by the list from the display
|
|
* blocks — see `buildConversationTurnMeta`.
|
|
*/
|
|
export type AgentSessionTranscriptTurnMeta = {
|
|
/** Trailing item of a live turn, or null when nothing is streaming. */
|
|
streamingItemId: string | null;
|
|
/**
|
|
* The turn that is actually live, or null when no turn is.
|
|
*
|
|
* A tool item's `executing`/`pending` status is not evidence that work is
|
|
* happening: an agent that dies after emitting a tool start leaves that
|
|
* status on the item permanently, so reopened history still claims to be
|
|
* mid-step. Whether a session owns that step is knowledge only the list has,
|
|
* which is why it is published here rather than re-derived per row.
|
|
*
|
|
* A turn id rather than a boolean, because "some turn is live" is not the
|
|
* question. An agent that crashed during turn 1 and is now working on turn 2
|
|
* is live, yet turn 1's abandoned step is no more running than before — a
|
|
* global flag would keep it spinning in exactly the case a restarted agent
|
|
* makes common.
|
|
*/
|
|
liveTurnId: string | null;
|
|
};
|
|
|
|
export const EMPTY_TRANSCRIPT_TURN_META: AgentSessionTranscriptTurnMeta = {
|
|
liveTurnId: null,
|
|
streamingItemId: null,
|
|
};
|
|
|
|
/**
|
|
* Whether the surrounding subtree is a work block's rail.
|
|
*
|
|
* The rail's job is to show *what the agent did*, one muted row per step. Two
|
|
* tool presentations disagree with that: a relay `messages send` classifies as
|
|
* `renderClass: "message"` and therefore renders through
|
|
* `CompactMessageSummary` — a 28px avatar, a bordered speech bubble, a
|
|
* timestamp and a delivery-receipt button. That treatment is right in the
|
|
* activity feed, where a posted message is a destination the reader may want to
|
|
* open, and wrong on the rail, where the same markup reads as the agent
|
|
* *replying* in the middle of its own work.
|
|
*
|
|
* This is the same failure the interim-note case avoids (see
|
|
* `WorkBlockProseBody`), reached by a different route: there the item is an
|
|
* assistant message, here it is a tool call that merely classifies as one. The
|
|
* signal is explicit and additive rather than inferred from 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.
|
|
*
|
|
* Defaults to `false`, so `default` and `compactPreview` cannot observe it and
|
|
* their markup stays byte-identical.
|
|
*/
|
|
const AgentSessionWorkBlockRailContext = React.createContext(false);
|
|
|
|
export const AgentSessionWorkBlockRailProvider =
|
|
AgentSessionWorkBlockRailContext.Provider;
|
|
|
|
export function useIsInsideWorkBlockRail() {
|
|
return React.useContext(AgentSessionWorkBlockRailContext);
|
|
}
|
|
|
|
const AgentSessionTranscriptTurnMetaContext =
|
|
React.createContext<AgentSessionTranscriptTurnMeta>(
|
|
EMPTY_TRANSCRIPT_TURN_META,
|
|
);
|
|
|
|
export const AgentSessionTranscriptTurnMetaProvider =
|
|
AgentSessionTranscriptTurnMetaContext.Provider;
|
|
|
|
export function useAgentSessionTranscriptTurnMeta() {
|
|
return React.useContext(AgentSessionTranscriptTurnMetaContext);
|
|
}
|