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

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>
This commit is contained in:
ss-dev-00
2026-08-26 15:28:48 -07:00
parent 29412d6ad6
commit 90f526be62
15 changed files with 3960 additions and 534 deletions

View File

@@ -10,6 +10,7 @@ import type { TranscriptItem } from "../agentSessionTypes";
import { getBuzzToolInfo } from "../agentSessionToolCatalog";
import { buildCompactToolSummary } from "../agentSessionToolSummary";
import type { AgentTranscriptIdentityProps } from "../activityRenderClasses/types";
import { useIsInsideWorkBlockRail } from "../agentSessionTranscriptContext";
import {
formatTranscriptTimestampTitle,
getToolDurationDisplay,
@@ -34,6 +35,7 @@ export function ToolItem({
profiles?: UserProfileLookup;
}) {
const [isExpanded, setIsExpanded] = React.useState(false);
const insideWorkBlockRail = useIsInsideWorkBlockRail();
const hasArgs = Object.keys(item.args).length > 0;
const hasResult = item.result.trim().length > 0;
const canonicalToolName = item.buzzToolName ?? item.toolName;
@@ -57,7 +59,10 @@ export function ToolItem({
[],
);
if (compactSummary.presentation === "message") {
// On a work block's rail a posted message is a step the agent took, so it
// takes the same muted, expandable row as every other step. Everywhere else
// it keeps the bubble: see `useIsInsideWorkBlockRail`.
if (compactSummary.presentation === "message" && !insideWorkBlockRail) {
return (
<div
className="not-prose w-full"

View File

@@ -1,15 +1,18 @@
/**
* Rendering contract for the `conversation` transcript variant (focus mode):
* layout, prompt authorship, thoughts, plans, lifecycle chrome, and the
* byte-for-byte guarantee for the other variants.
* layout, prompt authorship, plans, lifecycle chrome, and the byte-for-byte
* guarantee for the other variants.
*
* Mounts the shipping AgentSessionTranscriptList so the variant plumbing
* (variant context + derived turn meta) is exercised end to end rather than
* asserting against re-implemented render classes.
*
* The identity row and code-block chrome live in
* `AgentSessionTranscriptList.conversationChrome.test.mjs`; shared jsdom setup,
* ambient-formatting pins, and render helpers live in the harness both import.
* Reasoning is no longer a per-thought disclosure on this path — thoughts,
* tool steps and interim notes fold into one work block, whose contract lives in
* `AgentSessionWorkBlock.test.mjs`. The identity row and code-block chrome live
* in `AgentSessionTranscriptList.conversationChrome.test.mjs`; shared jsdom
* setup, ambient-formatting pins, and render helpers live in the harness they
* all import.
*
* The byte-for-byte tests at the bottom are the important ones: `conversation`
* is purely additive, so `default` and `compactPreview` markup for the same
@@ -22,19 +25,16 @@ import { test } from "node:test";
import {
act,
AGENT,
AUTHOR,
AUTHOR_PROFILES,
AUTHOR_TRUNCATED,
BASELINE_MARKUP,
baselineItems,
cleanup,
domWindow,
FIXTURE_LOCALE,
items,
renderRerenderableTranscript,
renderTranscript,
syncAgentTurnsFromEvents,
} from "./AgentSessionTranscriptList.conversationHarness.mjs";
test("conversation marks the transcript container and centers a reading column", async () => {
@@ -147,160 +147,6 @@ test("conversation renders agent messages as unboxed prose at full fidelity", as
assert.ok(message.querySelector("code"), "markdown/code fidelity preserved");
});
test("conversation collapses a finished thought into a Thought for Ns disclosure", async () => {
const { container } = await renderTranscript("conversation");
const disclosure = container.querySelector(
'[data-testid="transcript-thought-item"]',
);
assert.ok(disclosure, "thought should render");
assert.equal(disclosure.tagName, "DETAILS");
assert.equal(disclosure.open, false, "finished thoughts start collapsed");
// thought at :02, next turn item (plan) at :07 → 5s.
assert.equal(
disclosure
.querySelector('[data-testid="transcript-thought-disclosure"]')
.textContent.trim(),
"Thought for 5s",
);
});
test("conversation auto-opens the thought disclosure while it is streaming", async () => {
// A live turn on this channel makes the trailing item the streaming one, which
// is exactly the condition the disclosure auto-opens for.
syncAgentTurnsFromEvents(AGENT.agentPubkey, [
{
seq: 1,
timestamp: "2026-06-14T19:00:02.000Z",
kind: "turn_started",
agentIndex: 0,
channelId: "chan-1",
sessionId: "sess-1",
turnId: "turn-1",
payload: null,
},
]);
const streamingThought = items().slice(0, 2);
const { container } = await renderTranscript("conversation", {
channelId: "chan-1",
items: streamingThought,
});
const disclosure = container.querySelector(
'[data-testid="transcript-thought-item"]',
);
assert.equal(disclosure.open, true, "streaming thought should be open");
const summary = disclosure.querySelector(
'[data-testid="transcript-thought-disclosure"]',
);
// Shimmer paints a visual-only aria-hidden duplicate of the label, so match a
// prefix rather than the whole text node.
assert.match(summary.textContent, /^Thinking…/);
assert.doesNotMatch(summary.textContent, /Thought for/);
});
test("conversation folds the thought when the turn moves on, even after the browser echoes the auto-open toggle", async () => {
// Regression guard for the programmatic-toggle echo trap: a real browser fires
// `toggle` when React flips `open`, so the auto-open for a streaming thought
// arrives back at the component as if the reader had clicked. JSDOM does not
// fire that event itself, so the test injects it. If the handler records it as
// a reader choice, the disclosure stays pinned open forever and reasoning
// never recedes once the agent acts.
syncAgentTurnsFromEvents(AGENT.agentPubkey, [
{
seq: 1,
timestamp: "2026-06-14T19:00:02.000Z",
kind: "turn_started",
agentIndex: 0,
channelId: "chan-1",
sessionId: "sess-1",
turnId: "turn-1",
payload: null,
},
]);
const streaming = { channelId: "chan-1", items: items().slice(0, 2) };
const { container, setOverrides } = await renderRerenderableTranscript(
"conversation",
streaming,
);
const disclosure = container.querySelector(
'[data-testid="transcript-thought-item"]',
);
assert.equal(disclosure.open, true, "streaming thought should auto-open");
// The browser echo: `open` already agrees with what React rendered, and the
// event follows rather than causes that state.
await act(async () => {
disclosure.open = true;
disclosure.dispatchEvent(new domWindow.Event("toggle"));
});
assert.equal(
disclosure.open,
true,
"the echo must not disturb the open tail",
);
// The turn produces its next item, so the thought is no longer the streaming
// tail and should recede.
await setOverrides({ ...streaming, items: items().slice(0, 3) });
const settled = container.querySelector(
'[data-testid="transcript-thought-item"]',
);
assert.equal(
settled.open,
false,
"a thought the agent has acted on should fold again",
);
assert.match(
settled
.querySelector('[data-testid="transcript-thought-disclosure"]')
.textContent.trim(),
/^Thought for 5s/,
);
});
test("conversation keeps a reader-opened thought open after the turn moves on", async () => {
// The other half of the guard: a toggle that DISAGREES with the rendered state
// is a genuine reader choice and must win over the stream transition.
syncAgentTurnsFromEvents(AGENT.agentPubkey, [
{
seq: 1,
timestamp: "2026-06-14T19:00:02.000Z",
kind: "turn_started",
agentIndex: 0,
channelId: "chan-1",
sessionId: "sess-1",
turnId: "turn-1",
payload: null,
},
]);
// Three items so the thought is not the streaming tail: it renders collapsed.
const settledItems = { channelId: "chan-1", items: items().slice(0, 3) };
const { container, setOverrides } = await renderRerenderableTranscript(
"conversation",
settledItems,
);
const disclosure = container.querySelector(
'[data-testid="transcript-thought-item"]',
);
assert.equal(disclosure.open, false, "a settled thought starts collapsed");
await act(async () => {
disclosure.open = true;
disclosure.dispatchEvent(new domWindow.Event("toggle"));
});
await setOverrides({ ...settledItems, items: items() });
assert.equal(
container.querySelector('[data-testid="transcript-thought-item"]').open,
true,
"the reader's choice should survive later transcript items",
);
});
test("conversation renders the plan as a checklist card with in-place progress", async () => {
const { container } = await renderTranscript("conversation");
const card = container.querySelector('[data-testid="transcript-plan-item"]');
@@ -519,3 +365,142 @@ test("the byte-for-byte baseline actually exercises every renderable item kind",
assert.match(lifecycleText, /Context compacted/);
assert.match(lifecycleText, /Turn failed/);
});
/**
* A reader's expansion survives the work blocks being regrouped underneath it.
*
* Work blocks are derived, not stored: `groupConversationWorkBlocks` rebuilds
* them every render and ids each one after its first step
* (`work-block:${items[0].id}`). `findFinalAnswerId` exempts only the LAST
* assistant message from the block, so a second assistant message demotes the
* first — the earlier answer becomes work, and the runs on either side of it
* merge into one block:
*
* frame 2 work-block:thought:1[...] msg:1 work-block:thought:2[...]
* frame 3 work-block:thought:1[thought:1,tool:1,msg:1,thought:2,tool:2] msg:2
*
* `work-block:thought:2` stops existing, so React unmounts it. With the fold
* state held in that component, a reader who had opened the second block to read
* its steps was folded shut by the agent posting a second message — an event
* they did not cause and cannot predict. This drives the real list so the actual
* regrouping runs, rather than asserting against hand-built segments.
*/
test("conversation keeps a reader's expanded work block open when a later answer regroups it", async () => {
const shared = { channelId: "chan-1", sessionId: "sess-1", turnId: "turn-1" };
const at = (seconds) =>
`2026-06-14T19:00:${String(seconds).padStart(2, "0")}.000Z`;
const thought = (id, seconds) => ({
...shared,
id,
type: "thought",
renderClass: "thought",
title: "Thinking",
text: `reasoning ${id}`,
timestamp: at(seconds),
});
const tool = (id, seconds) => ({
...shared,
id,
type: "tool",
renderClass: "shell",
title: id,
toolName: "shell",
buzzToolName: null,
status: "completed",
args: {},
result: "ok",
isError: false,
timestamp: at(seconds),
startedAt: at(seconds),
completedAt: at(seconds),
descriptor: {
renderClass: "shell",
label: "Ran command",
preview: id,
source: "shell",
groupKey: "shell:command",
},
});
const answer = (id, seconds) => ({
...shared,
id,
type: "message",
renderClass: "message",
role: "assistant",
title: "Test Agent",
text: `answer ${id}`,
timestamp: at(seconds),
});
const firstRun = [thought("thought:1", 1), tool("tool:1", 2)];
const frame1 = [...firstRun, answer("msg:1", 3)];
const frame2 = [...frame1, thought("thought:2", 4), tool("tool:2", 5)];
const frame3 = [...frame2, answer("msg:2", 6)];
const { container, setOverrides } = await renderRerenderableTranscript(
"conversation",
{ items: frame1 },
);
const blocks = () => [
...container.querySelectorAll('[data-testid="transcript-work-block"]'),
];
const summaries = () => [
...container.querySelectorAll(
'[data-testid="transcript-work-block-summary"]',
),
];
const openCount = () =>
summaries().filter((node) => node.getAttribute("aria-expanded") === "true")
.length;
await setOverrides({ items: frame2 });
assert.equal(
blocks().length,
2,
"the demoted-answer frame should show two separate work blocks",
);
assert.equal(openCount(), 0, "both blocks start folded once work finished");
// The reader opens the SECOND block — the one the merge destroys.
await act(async () => {
summaries()[1].click();
});
assert.equal(openCount(), 1, "the reader's click should open that block");
await setOverrides({ items: frame3 });
assert.equal(
blocks().length,
1,
"the second answer should merge the runs into one block",
);
assert.equal(
openCount(),
1,
"the merged block must stay open — the reader asked to see those steps, and an agent posting again is not a reason to fold them away",
);
// The steps they were reading are actually on screen, not merely a block
// reporting itself open.
const stepText = [
...container.querySelectorAll('[data-testid="transcript-work-block-step"]'),
]
.map((node) => node.textContent)
.join("\n");
assert.match(
stepText,
/tool:2/,
"the reader's steps should still be visible",
);
// ...and the merged block is still theirs to fold. Recording the choice
// against only the block's first step would leave the absorbed block's stale
// `open` entry behind, and since an open choice wins the read, the merged
// block could never be folded again — the reader's click would do nothing.
await act(async () => {
summaries()[0].click();
});
assert.equal(
openCount(),
0,
"a reader who folds the merged block must actually fold it",
);
});

View File

@@ -191,7 +191,7 @@ export const AUTHOR_TRUNCATED = `${AUTHOR.slice(0, 8)}…${AUTHOR.slice(-4)}`;
* (`agentSessionTranscriptHelpers.ts` `parsePromptText`). The author row must
* never display this as a name.
*/
export const TRIGGER_TITLE = "@Mention";
const TRIGGER_TITLE = "@Mention";
export const AUTHOR_PROFILES = {
[AUTHOR]: {
displayName: "Ada Lovelace",
@@ -569,8 +569,3 @@ afterEach(() => {
resetActiveAgentTurnsStore?.();
});
after(() => dom.window.close());
/**
* The jsdom window itself. Exported for the few tests that must construct a
* real DOM event (`new domWindow.Event("toggle")`) to simulate a browser echo.
*/
export const domWindow = dom.window;

View File

@@ -34,6 +34,12 @@ import {
getTranscriptMessageLink,
} from "./AgentSessionTranscriptChrome";
import { buildConversationTurnMeta } from "./agentSessionConversationMeta";
import { AgentSessionWorkBlockSegment } from "./AgentSessionWorkBlock";
import { AgentSessionWorkBlockDisclosureProvider } from "./agentSessionWorkBlockDisclosure";
import {
conversationSegmentsForBlock,
type TranscriptConversationSegment,
} from "./agentSessionWorkBlockGrouping";
import { useTranscriptAnimationEnabled } from "./transcriptAnimationPreference";
import { useTranscriptTimestampsEnabled } from "./transcriptTimestampPreference";
import { TranscriptActivityItem } from "./activityRenderClasses/TranscriptActivityItem";
@@ -171,8 +177,9 @@ export function AgentSessionTranscriptList({
// Returns a shared constant for the other variants, so their render output is
// untouched.
const turnMeta = React.useMemo(
() => buildConversationTurnMeta(displayBlocks, { isTurnLive, variant }),
[displayBlocks, isTurnLive, variant],
() =>
buildConversationTurnMeta(displayBlocks, { isTurnLive, items, variant }),
[displayBlocks, isTurnLive, items, variant],
);
const scrollContainerClassNames = cn(
@@ -240,40 +247,42 @@ export function AgentSessionTranscriptList({
>
<AgentSessionTranscriptVariantProvider value={variant}>
<AgentSessionTranscriptTurnMetaProvider value={turnMeta}>
{displayBlocks.map((block) => {
const blockKey = getDisplayBlockKey(block);
return (
<motion.div
animate={ROW_ENTER_TO}
data-message-id={blockKey}
initial={
animationsDisabled ||
!hasCompletedInitialRenderRef.current
? false
: ROW_ENTER_FROM
}
key={blockKey}
layout={layoutAnimationsEnabled ? "position" : false}
transition={ROW_ENTER_SPRING}
>
{/* content-visibility stays on a non-animated child: motion
<AgentSessionWorkBlockDisclosureProvider>
{displayBlocks.map((block) => {
const blockKey = getDisplayBlockKey(block);
return (
<motion.div
animate={ROW_ENTER_TO}
data-message-id={blockKey}
initial={
animationsDisabled ||
!hasCompletedInitialRenderRef.current
? false
: ROW_ENTER_FROM
}
key={blockKey}
layout={layoutAnimationsEnabled ? "position" : false}
transition={ROW_ENTER_SPRING}
>
{/* content-visibility stays on a non-animated child: motion
measures the outer wrapper for layout animations, which
would otherwise force skipped offscreen rows to render. */}
<div className="content-visibility-auto">
<TranscriptDisplayBlockView
agentAvatarUrl={agentAvatarUrl}
agentName={agentName}
agentPubkey={agentPubkey}
block={block}
profiles={profiles}
/>
</div>
</motion.div>
);
})}
{isTurnLive && !isCompactPreview ? (
<TurnLivenessIndicator />
) : null}
<div className="content-visibility-auto">
<TranscriptDisplayBlockView
agentAvatarUrl={agentAvatarUrl}
agentName={agentName}
agentPubkey={agentPubkey}
block={block}
profiles={profiles}
/>
</div>
</motion.div>
);
})}
{isTurnLive && !isCompactPreview ? (
<TurnLivenessIndicator />
) : null}
</AgentSessionWorkBlockDisclosureProvider>
</AgentSessionTranscriptTurnMetaProvider>
</AgentSessionTranscriptVariantProvider>
</CodeBlockVariantContext.Provider>
@@ -378,6 +387,15 @@ function TranscriptDisplayBlockView({
);
}
// Variant in, presentation out: the conversation variant folds a turn's
// thinking/tools/notes into work blocks, and every other variant renders the
// shared segments untouched. Keeping the choice here — rather than inside the
// segment components — is what keeps `default`/`compactPreview` byte-identical
// to their pre-work-block markup.
const segments: TranscriptConversationSegment[] = isConversation
? conversationSegmentsForBlock(block)
: block.segments;
return (
<div
className={cn(
@@ -387,7 +405,7 @@ function TranscriptDisplayBlockView({
data-testid="transcript-turn-group"
data-turn-id={block.turnId}
>
{block.segments.map((segment) => (
{segments.map((segment) => (
<motion.div
animate={ROW_ENTER_TO}
initial={
@@ -411,7 +429,13 @@ function TranscriptDisplayBlockView({
);
}
function getTurnSegmentKey(turnId: string, segment: TranscriptTurnSegment) {
function getTurnSegmentKey(
turnId: string,
segment: TranscriptConversationSegment,
) {
if (segment.kind === "work-block") {
return segment.block.id;
}
if (segment.kind === "setup") {
return `turn:${turnId}:setup`;
}
@@ -434,8 +458,20 @@ function TranscriptTurnSegmentView({
segment,
}: AgentTranscriptIdentityProps & {
profiles?: UserProfileLookup;
segment: TranscriptTurnSegment;
segment: TranscriptConversationSegment;
}) {
if (segment.kind === "work-block") {
return (
<AgentSessionWorkBlockSegment
agentAvatarUrl={agentAvatarUrl}
agentName={agentName}
agentPubkey={agentPubkey}
block={segment.block}
profiles={profiles}
/>
);
}
if (segment.kind === "prompt") {
return (
<TurnPromptBlock

View File

@@ -0,0 +1,609 @@
/**
* Work-block behaviour for work that was abandoned mid-flight, the rail's
* per-kind presentation, and the re-render cost a streaming block must not pay.
* The live/finished contracts live in `AgentSessionWorkBlock.test.mjs`; the
* shared rig is `AgentSessionWorkBlockTestRig.mjs`.
*/
import assert from "node:assert/strict";
import { test } from "node:test";
import {
noteStep,
relayStep,
renderBlock,
step,
thoughtStep,
} from "./AgentSessionWorkBlockTestRig.mjs";
// ── Orphaned work ────────────────────────────────────────────────────────────
/**
* Reopened history must not present as work in progress.
*
* A tool's `executing` status is written when the step starts and never revised
* if the agent dies first, so an abandoned step keeps it forever. In this block
* that status is not a per-row detail — one `running` entry makes the whole
* block active, which suppresses the folded summary line, keeps the rail
* expanded and pulses a bullet. Scrolling back to a crashed turn therefore
* showed the reader live work indefinitely (Codex P2 on #6536).
*/
test("a block whose running step has no live session folds like finished work", async () => {
const view = await renderBlock(
[step("a"), step("b", { status: "executing", completedAt: null })],
{ liveTurnId: null, streamingItemId: null },
);
const summary = view.summary();
assert.ok(
summary,
"history gets its folded summary line — the orphaned status must not suppress it",
);
assert.match(
summary.textContent,
/2 steps$/,
"an abandoned step is not known to have failed, so the count stays neutral",
);
assert.equal(await view.settleToStepCount(0), 0, "the rail folds away");
// Then OPEN it and look. Asserting "nothing pulses" on the folded block would
// be vacuous: the fold unmounts every row, so no glyph exists to carry a
// pulse class and the assertion would hold against a build where abandoned
// steps pulse forever — the exact Codex finding. The reader's complaint is
// about what they see when they scroll back and expand, so that is where the
// assertion belongs.
await view.expand();
assert.equal(view.stepCount(), 2, "the expanded rail has rows to inspect");
assert.deepEqual(
view.glyphStates(),
["settled", "settled"],
"the abandoned step reads as settled, not running",
);
assert.deepEqual(
view.pulseStates(),
[],
"nothing pulses when nothing is running",
);
});
test("the same items still hold the block open while the turn is live", async () => {
// The paired half: this is what makes the gate meaningful rather than a
// blanket "never trust executing". Identical items, live turn.
const view = await renderBlock(
[step("a"), step("b", { status: "executing", completedAt: null })],
{ liveTurnId: "turn-1", streamingItemId: null },
);
assert.equal(view.summary(), null, "a live block has no folded line");
assert.equal(view.stepCount(), 2, "the rail stays open");
assert.deepEqual(view.glyphStates(), ["settled", "running"]);
assert.deepEqual(view.pulseStates(), ["running"], "live work pulses");
});
test("an agent live on a later turn does not resurrect an earlier turn's abandoned step", async () => {
// The reason the signal is a turn id and not a boolean: a restarted agent is
// live, but not on this turn, and a global flag would keep this step spinning.
const view = await renderBlock(
[step("a"), step("b", { status: "executing", completedAt: null })],
{ liveTurnId: "turn-2", streamingItemId: null },
);
assert.ok(
view.summary(),
"this turn is history even though the agent is busy",
);
assert.equal(await view.settleToStepCount(0), 0);
// Expanded, for the same reason as above: a folded rail has no glyph to pulse,
// so the negative has to be taken on rows that exist.
await view.expand();
assert.deepEqual(view.glyphStates(), ["settled", "settled"]);
assert.deepEqual(view.pulseStates(), []);
});
test("a block live when the session ends folds instead of spinning forever", async () => {
const { act } = await import("@testing-library/react");
// The bug as the reader meets it live: the agent dies mid-step, so the item
// never reaches a terminal status and the only thing that changes is that no
// turn is live any more.
const live = [
step("a"),
step("b", { status: "executing", completedAt: null }),
];
const view = await renderBlock(live, {
liveTurnId: "turn-1",
streamingItemId: "b",
});
assert.equal(view.stepCount(), 2, "live: the rail is open");
assert.equal(view.summary(), null);
// Session gone. The items are BYTE-IDENTICAL — only liveness changed.
await act(async () => {
view.stream(live, null, null);
});
assert.ok(view.summary(), "the block settles when its session goes away");
assert.match(view.summary().textContent, /2 steps$/);
// Taken HERE, before the fold finishes: the rail is still mounted for the
// settle frame, so the glyph that was pulsing a moment ago still exists and
// can be asked whether it stopped. Once `settleToStepCount(0)` has run there
// are no rows left and the same assertion proves nothing.
assert.equal(view.stepCount(), 2, "the rail is still mounted to inspect");
assert.deepEqual(view.glyphStates(), ["settled", "settled"]);
assert.deepEqual(
view.pulseStates(),
[],
"the pulse stops the moment the session goes away, not when the fold finishes",
);
assert.equal(await view.settleToStepCount(0), 0);
});
// ── The gap between two turns ─────────────────────────────────────────────────
/**
* A finished block must stay folded through the gap before the next turn shows
* anything.
*
* The rendered half of the `buildConversationTurnMeta` gap contract (see
* `agentSessionConversationMeta.test.mjs`). The meta test proves the hints are
* right; this proves the reader sees the consequence, because the symptom was
* never a wrong id — it was a settled 6-step block re-opening, dropping to its
* last three steps behind a "previous steps" disclosure, and then folding back,
* on every single turn.
*
* Six steps rather than two on purpose: the live window only applies above
* three, so a smaller block would hide the loudest part of the regression.
*/
test("a finished block stays folded while the next turn has started but shown nothing", async () => {
const { act } = await import("@testing-library/react");
const items = ["a", "b", "c", "d", "e", "f"].map((id) => step(id));
// Finished: nothing live, so the block is folded to its summary line.
const view = await renderBlock(items, {
liveTurnId: null,
streamingItemId: null,
});
assert.match(view.summary().textContent, /6 steps$/);
assert.equal(await view.settleToStepCount(0), 0, "it starts folded");
// The next turn starts. It owns liveness (turn-2) and, having emitted nothing
// renderable, contributes no streaming item — which is exactly what the fixed
// `latestTurnId`/`streamingIdForTail` pair reports for this frame.
await act(async () => {
view.stream(items, null, "turn-2");
});
assert.ok(
view.summary(),
"the folded summary line survives the next turn starting",
);
assert.match(view.summary().textContent, /6 steps$/);
assert.equal(view.stepCount(), 0, "the rail does not re-open");
assert.equal(
view.previousSteps(),
null,
"and the live window's previous-steps disclosure never appears",
);
assert.deepEqual(view.pulseStates(), []);
});
test("an orphaned step keeps its own row detail rather than gaining an interrupted marker", async () => {
// Deliberate: we do not know what happened to the step, so it renders as the
// neutral step it is with whatever it recorded. A visible "interrupted"
// treatment would be a design addition, not part of this fix.
const view = await renderBlock(
[step("a"), step("b", { status: "executing", completedAt: null })],
{ liveTurnId: null, streamingItemId: null },
);
await view.expand();
await view.settleToStepCount(2);
assert.deepEqual(
view.glyphStates(),
["settled", "settled"],
"no third state was invented for an abandoned step",
);
assert.equal(
view.qa('[data-testid="transcript-tool-item"]').length,
2,
"both steps still render through the normal tool presenter",
);
});
test("the rail bullet masks the spine with the drawer surface colour", async () => {
// The bullet has to mask the spine passing behind it, and the mask must match
// the surface the transcript is drawn on. A mask in any other colour shows as
// a disc of the wrong shade around every bullet (berd's BOT-1599).
const view = await renderBlock([step("a"), step("b")]);
await view.expand();
const bullet = view.q("[data-step-state]");
assert.match(bullet.className, /\bbg-background\b/);
assert.match(bullet.className, /\bring-background\b/);
assert.match(bullet.className, /\brounded-full\b/);
});
test("the spine is drawn for every step except the last", async () => {
const view = await renderBlock([step("a"), step("b"), step("c")]);
await view.expand();
const spines = view.qa(".w-px");
assert.equal(
spines.length,
2,
"three steps means two connecting segments; a trailing spine would dangle",
);
});
test("thinking renders as a rail row with its own glyph, not a nested disclosure", async () => {
const view = await renderBlock([thoughtStep("thought:1"), step("a")]);
await view.expand();
const thought = view.q('[data-testid="transcript-work-block-thought"]');
assert.ok(thought, "reasoning renders on the rail");
assert.match(thought.textContent, /weighing the options/);
assert.equal(
view.q('[data-testid="transcript-thought-disclosure"]'),
null,
"the block is already one disclosure — a thought must not add a second",
);
});
/**
* An interim note is progress, not a second reply.
*
* #6720 gives every conversation-variant assistant message a 20px avatar + name
* identity 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 reader would see
* the agent apparently reply twice, once inside the work it was doing. berd
* draws the same line: its `progress` entry is a plain rail row.
*
* The suppression is done on this side (a dedicated prose body) rather than by
* reaching into the message presenter, so #6720 keeps one rule for what a
* message looks like.
*/
test("an interim note renders as rail prose with no identity row", async () => {
const view = await renderBlock([
step("a"),
noteStep("msg:interim", "checked the three call sites"),
]);
await view.expand();
const note = view.q('[data-testid="transcript-work-block-note"]');
assert.ok(note, "the note renders on the rail");
assert.match(note.textContent, /checked the three call sites/);
assert.ok(
view.q('[data-testid="transcript-assistant-identity"]') === null,
"an avatar + name row inside a muted step reads as a second reply",
);
assert.ok(
view.q('[data-testid="transcript-assistant-message"]') === null,
"the note must not go through the message presenter at all",
);
});
/**
* A relay post is a step, not a reply — the same rule as an interim note,
* reached by a different route.
*
* A note is an assistant *message* the block re-presents as prose. A relay post
* is a *tool call* that classifies as `renderClass: "message"`, so it renders
* through `CompactMessageSummary`: 28px avatar, bordered speech bubble,
* timestamp, delivery-receipt button. That is right in the activity feed, where
* a posted message is a destination to open; on the rail it makes the agent
* appear to reply in the middle of its own work — and it did, in the seeded
* browser preview, which is where this was caught.
*
* Suppressing it needs the presentation signal rather than the transcript
* variant: the same relay step OUTSIDE a block in this variant keeps its
* bubble, which the next test pins.
*/
test("a relay post on the rail is a plain step, with no bubble or avatar", async () => {
const view = await renderBlock([step("a"), relayStep("relay:1")]);
await view.expand();
assert.equal(
view.stepCount(),
2,
"the relay post takes its own rail row, like any other step",
);
assert.equal(
view.qa('[data-work-block-entry="tool"]').length,
2,
"a relay post is a tool step — it is something the agent did",
);
assert.equal(
view.q('[data-testid="transcript-tool-message-preview"]'),
null,
"a speech bubble inside a muted step reads as the agent replying mid-work",
);
assert.equal(
view.q('[data-testid="transcript-agent-sent-avatar"]'),
null,
"no identity avatar on the rail",
);
assert.equal(
view.q('[data-testid="transcript-sent-message-context-button"]'),
null,
"no delivery receipt on the rail",
);
// It is still a real, expandable tool row carrying its command.
const rows = view.qa('[data-testid="transcript-tool-item"]');
assert.equal(rows.length, 2, "both steps render as tool rows");
assert.ok(
rows[1].querySelector("details"),
"the relay step keeps the ordinary step disclosure so its args stay reachable",
);
assert.match(
rows[1].textContent,
/Sent|posted the findings/,
"the row still says what the step was",
);
});
/**
* The other half of the branch: outside a block the bubble is correct and must
* survive. Without this, suppressing the bubble everywhere in the conversation
* variant would pass the test above.
*/
test("the same relay post outside a work block keeps its message bubble", async () => {
const { createElement } = await import("react");
const { render } = await import("@testing-library/react");
const { QueryClient, QueryClientProvider } = await import(
"@tanstack/react-query"
);
const { createMemoryHistory, createRootRoute, createRouter, RouterProvider } =
await import("@tanstack/react-router");
const { AgentSessionTranscriptVariantProvider } = await import(
"./agentSessionTranscriptContext.ts"
);
const { TranscriptActivityItem } = await import(
"./activityRenderClasses/TranscriptActivityItem.tsx"
);
// `gcTime: 0`: React Query's default is 300000ms, and this is the one test
// that actually drives the bubble presenter's `useQuery`, so its query arms a
// five-minute gc timer at teardown. node:test waits that timer out before
// exiting — this file's tests sum to ~2s but the wall was ~303s, all passing,
// with no failing assertion to point at the cause.
const queryClient = new QueryClient({
defaultOptions: { queries: { gcTime: 0, retry: false } },
});
const rootRoute = createRootRoute({
component: () =>
createElement(
QueryClientProvider,
{ client: queryClient },
createElement(
AgentSessionTranscriptVariantProvider,
{ value: "conversation" },
createElement(TranscriptActivityItem, {
agentAvatarUrl: null,
agentName: "Agent",
agentPubkey: "pk",
item: relayStep("relay:1"),
}),
),
),
});
const router = createRouter({
history: createMemoryHistory({ initialEntries: ["/"] }),
routeTree: rootRoute,
});
await router.load();
const view = render(createElement(RouterProvider, { router }));
assert.ok(
view.container.querySelector(
'[data-testid="transcript-tool-message-preview"]',
),
"outside a block a posted message is a destination the reader can open — the bubble stays",
);
});
test("the rail glyph is chosen by kind, and prose kinds share the speech bubble", async () => {
// The exhaustive switch is the point: a note that fell through to the tool
// branch would wear a wrench and read as something the agent ran.
const view = await renderBlock([
thoughtStep("thought:1"),
noteStep("msg:interim"),
step("a"),
step("b", { isError: true, status: "failed" }),
]);
await view.expand();
const glyphClass = (kind, index = 0) => {
const rows = view.qa(`[data-work-block-entry="${kind}"]`);
const icon = rows[index].querySelector("svg");
return icon.getAttribute("class") ?? "";
};
// lucide stamps each icon with a `lucide-<kebab-name>` class, so the glyph
// identity is readable from the DOM without reaching into the icon modules.
assert.match(glyphClass("thought"), /lucide-message-circle/);
assert.match(
glyphClass("note"),
/lucide-message-circle/,
"prose is the agent talking, whether it is reasoning or a note",
);
assert.match(glyphClass("tool", 0), /lucide-wrench/);
assert.match(
glyphClass("tool", 1),
/lucide-circle/,
"a failed step is a filled dot, not a wrench",
);
});
test("the rail bullet is never red, whatever the step's outcome", async () => {
// A failure is carried by glyph shape and by the folded line's count. Tinting
// the bullet would make one bad step read as an alarm across the whole run.
//
// The running step keeps this block live, so the rail is already open — which
// is also the only state in which a running bullet can be observed at all.
const view = await renderBlock(
[
step("a"),
step("b", { isError: true, status: "failed" }),
step("c", { status: "executing", completedAt: null }),
],
{ streamingItemId: "c" },
);
assert.deepEqual(
view.glyphStates(),
["settled", "failed", "running"],
"all three outcomes are on screen",
);
for (const bullet of view.qa("[data-step-state]")) {
assert.ok(
!/\b(text|bg|ring)-(destructive|red)/.test(bullet.className),
`rail bullet for ${bullet.getAttribute("data-step-state")} must stay muted`,
);
assert.match(bullet.className, /\btext-muted-foreground\b/);
}
});
/**
* berd brightens rail prose with `usePrimaryText={open}`. Here the brightening
* is unconditional, and this test records why that is not a divergence: 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. A `primaryText` prop
* would have an unreachable false branch.
*/
test("rail prose is primary text, and a closed block has no prose on screen at all", async () => {
const { act } = await import("@testing-library/react");
const live = [
thoughtStep("thought:1"),
noteStep("msg:interim"),
step("b", { status: "executing", completedAt: null }),
];
const view = await renderBlock(live, { streamingItemId: "b" });
const prose = () =>
view.qa(
'[data-testid="transcript-work-block-thought"],[data-testid="transcript-work-block-note"]',
);
assert.equal(prose().length, 2, "both prose rows are on the live rail");
for (const node of prose()) {
assert.match(
node.className,
/\btext-foreground\b/,
"prose the reader can see is primary, not muted",
);
assert.ok(
!/\btext-muted-foreground\b/.test(node.className),
"the row must not carry both colours",
);
}
// Finish the turn: the block folds and takes its prose with it.
await act(async () => {
view.stream([thoughtStep("thought:1"), noteStep("msg:interim"), step("b")]);
});
assert.equal(await view.settleToStepCount(0), 0, "it folded");
assert.equal(
prose().length,
0,
"a folded block renders no prose, so there is no dimmed state to test",
);
// And the reader reopening it brings the same primary prose back.
await act(async () => {
view.summary().click();
});
assert.equal(await view.settleToStepCount(3), 3, "the reader reopened it");
assert.equal(prose().length, 2);
for (const node of prose()) {
assert.match(node.className, /\btext-foreground\b/);
}
});
// ── Streaming cost ───────────────────────────────────────────────────────────
/**
* A block re-renders on every append while work streams. Unchanged steps must
* not re-render with it: each step's presenter rebuilds compact tool summaries,
* parses diffs and renders markdown/images, so an unmemoized step row makes a
* long block cost O(n) of that work per appended step.
*
* Counted at the presenter boundary — `TranscriptActivityItem` looks its
* presenter up in `ACTIVITY_RENDER_CLASS_PRESENTERS` on every render, so
* swapping in a counting presenter observes exactly the work a step row
* triggers, without reaching into React internals.
*/
async function countStepRenders(initialItems, nextItems) {
const { createElement } = await import("react");
const { render } = await import("@testing-library/react");
const { ACTIVITY_RENDER_CLASS_PRESENTERS } = await import(
"./activityRenderClasses/TranscriptActivityItem.tsx"
);
const { AgentSessionTranscriptTurnMetaProvider } = await import(
"./agentSessionTranscriptContext.ts"
);
const { AgentSessionWorkBlockSegment } = await import(
"./AgentSessionWorkBlock.tsx"
);
const renders = [];
const original = ACTIVITY_RENDER_CLASS_PRESENTERS.shell;
ACTIVITY_RENDER_CLASS_PRESENTERS.shell = function CountingPresenter(props) {
renders.push(props.item.id);
return createElement("div", null, props.item.id);
};
try {
const element = (items) =>
createElement(
AgentSessionTranscriptTurnMetaProvider,
{
value: {
liveTurnId: items[items.length - 1].turnId,
streamingItemId: items[items.length - 1].id,
},
},
createElement(AgentSessionWorkBlockSegment, {
agentAvatarUrl: null,
agentName: "Agent",
agentPubkey: "pk",
block: {
id: "work-block:a",
items,
timestamp: items[0].timestamp,
},
}),
);
const view = render(element(initialItems));
renders.length = 0;
view.rerender(element(nextItems));
return renders;
} finally {
ACTIVITY_RENDER_CLASS_PRESENTERS.shell = original;
}
}
test("appending a step does not re-render the steps already on the rail", async () => {
// The block is expanded (a live block with ≤3 steps shows them all), and the
// prior steps are the SAME objects across both renders, as the transcript
// store replaces items rather than mutating them.
const settled = [step("a"), step("b")];
const appended = [
...settled,
step("c", { status: "executing", completedAt: null }),
];
const rendered = await countStepRenders(settled, appended);
assert.deepEqual(rendered, ["c"]);
});
test("a step that actually changed does re-render", async () => {
// Guards the memo from being too aggressive: an executing step settling is a
// new object for that id, and it must re-render to drop its running glyph.
const a = step("a");
const executing = step("b", { status: "executing", completedAt: null });
const settled = step("b");
const rendered = await countStepRenders([a, executing], [a, settled]);
assert.deepEqual(rendered, ["b"]);
});

View File

@@ -0,0 +1,402 @@
/**
* Work-block rendering while a turn is live and when it finishes: the rail, the
* live window, the fold animation, and the reader's disclosure choice.
* Orphaned work and streaming cost live in
* `AgentSessionWorkBlock.orphaned.test.mjs`; the shared rig is
* `AgentSessionWorkBlockTestRig.mjs`.
*/
import assert from "node:assert/strict";
import { test } from "node:test";
import {
renderBlock,
setPrefersReducedMotion,
step,
} from "./AgentSessionWorkBlockTestRig.mjs";
// ── Live ─────────────────────────────────────────────────────────────────────
test("a live block shows no header line — the rail is the status", async () => {
const view = await renderBlock([
step("a"),
step("b", { status: "executing", completedAt: null }),
]);
assert.equal(
view.summary(),
null,
"a header while live would only restate what the arriving steps show",
);
assert.equal(view.stepCount(), 2);
});
test("a live block windows to the last three steps with the rest behind a disclosure", async () => {
const items = ["a", "b", "c", "d", "e"].map((id) => step(id));
items[4] = step("e", { status: "executing", completedAt: null });
const view = await renderBlock(items, { streamingItemId: "e" });
assert.equal(view.stepCount(), 3, "only the live window renders on the rail");
const disclosure = view.previousSteps();
assert.ok(disclosure, "older steps sit behind a disclosure");
assert.match(disclosure.textContent, /2 previous steps/);
});
test("expanding previous steps reveals the older steps in place", async () => {
const { act } = await import("@testing-library/react");
const items = ["a", "b", "c", "d", "e"].map((id) => step(id));
items[4] = step("e", { status: "executing", completedAt: null });
const view = await renderBlock(items, { streamingItemId: "e" });
assert.equal(view.stepCount(), 3);
await act(async () => {
view.previousSteps().click();
});
assert.equal(
await view.settleToStepCount(5),
5,
"all five steps are now on the rail",
);
});
// ── Finished ─────────────────────────────────────────────────────────────────
test("a block that was already finished on mount folds to an N steps line", async () => {
const view = await renderBlock([step("a"), step("b"), step("c")]);
const summary = view.summary();
assert.ok(summary, "a finished block gets its summary line");
assert.match(summary.textContent, /3 steps/);
assert.equal(summary.getAttribute("aria-expanded"), "false");
assert.equal(view.stepCount(), 0, "the rail is collapsed away");
});
test("a finished block containing a failure names the failure in its folded line", async () => {
const view = await renderBlock([
step("a"),
step("b", { isError: true, status: "failed" }),
step("c"),
]);
assert.match(
view.summary().textContent,
/3 steps · 1 failed/,
"a failure must never hide behind a neutral count",
);
});
test("clicking the folded line expands the whole rail", async () => {
const { act } = await import("@testing-library/react");
const view = await renderBlock([step("a"), step("b"), step("c")]);
assert.equal(view.stepCount(), 0);
await act(async () => {
view.summary().click();
});
assert.equal(await view.settleToStepCount(3), 3);
assert.equal(view.summary().getAttribute("aria-expanded"), "true");
});
test("a block expanded by the reader while live shows every step, not just the window", async () => {
const { act } = await import("@testing-library/react");
const items = ["a", "b", "c", "d", "e"].map((id) => step(id));
items[4] = step("e", { status: "executing", completedAt: null });
const view = await renderBlock(items, { streamingItemId: "e" });
assert.equal(view.stepCount(), 3);
await act(async () => {
view.previousSteps().click();
});
assert.equal(
await view.settleToStepCount(5),
5,
"a reader who asked to see the work sees all of it",
);
// And the window does NOT come back as more work streams in: the reader's
// choice is not re-decided on every append.
await act(async () => {
view.stream(
[...items, step("f", { status: "executing", completedAt: null })],
"f",
);
});
assert.equal(
await view.settleToStepCount(6),
6,
"a reader-expanded live block keeps showing everything as it grows",
);
});
// ── Fold animation ───────────────────────────────────────────────────────────
test("a block that finishes while mounted stays open for a paint so the collapse is visible", async () => {
const { act } = await import("@testing-library/react");
const live = [
step("a"),
step("b", { status: "executing", completedAt: null }),
];
const view = await renderBlock(live);
assert.equal(view.summary(), null, "live: no header");
assert.equal(view.stepCount(), 2);
// The turn finishes: the same block id re-renders with settled steps.
await act(async () => {
view.stream([step("a"), step("b")]);
});
// Still open on the commit right after finishing — that open state is what
// gives the height animation something to collapse FROM. A block that jumped
// straight to closed would swap a rail for a one-line summary between frames.
assert.equal(
view.stepCount(),
2,
"the rail is still mounted for the settle frame",
);
assert.ok(
view.summary(),
"the summary line appears as soon as work finishes",
);
// After the settle frames it closes — once the collapse animation has run.
assert.equal(await view.settleToStepCount(0), 0, "the block settles closed");
});
test("under reduced motion a finishing block folds immediately, with no settle frames", async () => {
const { act } = await import("@testing-library/react");
setPrefersReducedMotion(true);
const view = await renderBlock([
step("a"),
step("b", { status: "executing", completedAt: null }),
]);
assert.equal(view.stepCount(), 2);
await act(async () => {
view.stream([step("a"), step("b")]);
});
assert.equal(
view.stepCount(),
0,
"reduced motion skips the animation, so there is nothing to hold open for",
);
assert.ok(
view.summary(),
"it still folds to a summary line — only the animation is skipped",
);
});
// ── Reader choice ────────────────────────────────────────────────────────────
/**
* The echo trap, and why this block is structurally immune to it.
*
* `<details>` fires `toggle` for programmatic `open` changes as well as for
* clicks, indistinguishably — so a policy-driven open echoes back looking like
* a reader choice and pins the row to its first policy state forever. That trap
* cost time on the tool-run card.
*
* This block cannot hit it, because its disclosure is a `<button>` whose only
* state change is the click handler firing: there is no browser-generated echo
* to mistake for intent. That is a real invariant and not an accident of the
* current markup — switching the trigger to `<details>` would reintroduce the
* trap — so it is asserted rather than assumed. An earlier version of this test
* dispatched a synthetic `toggle` at the block and asserted it changed nothing,
* which passed for the wrong reason: nothing was listening for `toggle` at all.
*/
test("the block's disclosure has no toggle echo to mistake for a reader choice", async () => {
const { act } = await import("@testing-library/react");
const live = [
step("a"),
step("b", { status: "executing", completedAt: null }),
];
const view = await renderBlock(live);
await act(async () => {
view.stream([step("a"), step("b")]);
});
assert.equal(await view.settleToStepCount(0), 0, "policy folds it");
const trigger = view.summary();
assert.equal(
trigger.tagName,
"BUTTON",
"a button has no programmatic-toggle echo; <details> would need the hook's guard",
);
assert.equal(
view.q("details"),
null,
"no <details> anywhere in the block, so no echo can be generated",
);
// And the fold is genuinely repeatable: policy opens it for the next live
// phase and folds it again, which is the exact behaviour a recorded echo
// would have disabled.
await act(async () => {
view.stream(
[
step("a"),
step("b"),
step("c", { status: "executing", completedAt: null }),
],
"c",
);
});
assert.equal(
await view.settleToStepCount(3),
3,
"policy re-opens for new work",
);
await act(async () => {
view.stream([step("a"), step("b"), step("c")]);
});
assert.equal(
await view.settleToStepCount(0),
0,
"and folds again — policy transitions still work",
);
});
test("a reader who opens a finished block keeps it open as the transcript moves on", async () => {
const { act } = await import("@testing-library/react");
const view = await renderBlock([step("a"), step("b")]);
assert.equal(view.stepCount(), 0);
await act(async () => {
view.summary().click();
});
assert.equal(await view.settleToStepCount(2), 2);
// A later append re-renders the block; the reader's choice must survive it.
await act(async () => {
view.stream([step("a"), step("b"), step("c")]);
});
assert.equal(
await view.settleToStepCount(3),
3,
"the reader's choice outlives later policy transitions",
);
});
test("a reader who folds a live block keeps it folded while work continues", async () => {
const { act } = await import("@testing-library/react");
const live = [
step("a"),
step("b", { status: "executing", completedAt: null }),
];
const view = await renderBlock(live);
assert.equal(view.stepCount(), 2, "live blocks open themselves");
// While live there is no summary trigger, so the reader's route to folding is
// the block's own disclosure change handler. Drive it the way a click would.
await act(async () => {
view.stream([step("a"), step("b")]);
});
assert.equal(await view.settleToStepCount(0), 0, "policy folded it");
await act(async () => {
view.summary().click();
});
assert.equal(await view.settleToStepCount(2), 2, "reader opened it");
await act(async () => {
view.stream(
[
step("a"),
step("b"),
step("c", { status: "executing", completedAt: null }),
],
"c",
);
});
assert.equal(
await view.settleToStepCount(3),
3,
"the reader's open choice persists into the next live phase",
);
});
// ── Rail ─────────────────────────────────────────────────────────────────────
test("the rail marks running, failed and settled steps with distinct glyph states", async () => {
const view = await renderBlock(
[
step("a"),
step("b", { isError: true, status: "failed" }),
step("c", { status: "executing", completedAt: null }),
],
{ streamingItemId: "c" },
);
assert.deepEqual(view.glyphStates(), ["settled", "failed", "running"]);
assert.equal(view.summary(), null, "a running step keeps the block live");
});
test("a running step pulses and a failed one does not", async () => {
const view = await renderBlock(
[
step("a", { isError: true, status: "failed" }),
step("b", { status: "executing", completedAt: null }),
],
{ streamingItemId: "b" },
);
const [failed, running] = view.qa("[data-step-state]");
assert.deepEqual(
view.pulseStates(),
["running"],
"work in flight pulses and a settled failure does not",
);
assert.equal(
failed.classList.contains("motion-safe:animate-pulse"),
false,
"a settled failure is not in flight",
);
assert.equal(
running.classList.contains("motion-safe:animate-pulse"),
true,
"the running step is the one that pulses",
);
});
/**
* Reduced motion has to reach this pulse through the CLASS, not the hook.
*
* `useWorkBlockMotionEnabled` skips the fold's height animation, but the running
* glyph's pulse is a CSS keyframe animation applied by a Tailwind utility, which
* no hook can switch off — so the guard has to be `motion-safe:animate-pulse`.
* Compiled with this repo's Tailwind (4.3.0), `animate-pulse` is
* `animation: var(--animate-pulse)` with no guard, while
* `motion-safe:animate-pulse` wraps exactly that declaration in
* `@media (prefers-reduced-motion: no-preference)`. jsdom does not evaluate
* media queries, so the assertion is on the class: the bare form would keep
* pulsing forever for a reader who asked for no motion, and none of the app's 20
* `prefers-reduced-motion: reduce` blocks matches `.animate-pulse` (all are
* scoped to `buzz-*`/`motion-*`/`t-skel-*` classes), so nothing else would catch
* it.
*
* The preference is irrelevant to which class is emitted, and that is the point:
* asserting under BOTH settings is what proves the guard is in the class rather
* than in a conditional that reduced motion could flip.
*/
test("a running glyph's pulse is guarded so reduced motion silences it", async () => {
for (const reduced of [false, true]) {
setPrefersReducedMotion(reduced);
const view = await renderBlock(
[step("a"), step("b", { status: "executing", completedAt: null })],
{ streamingItemId: "b" },
);
const [, running] = view.qa("[data-step-state]");
assert.equal(
running.classList.contains("motion-safe:animate-pulse"),
true,
`the pulse is reduced-motion-safe (prefersReducedMotion=${reduced})`,
);
assert.deepEqual(
view.unguardedPulseStates(),
[],
`no glyph pulses unconditionally (prefersReducedMotion=${reduced})`,
);
const { cleanup } = await import("@testing-library/react");
cleanup();
}
});

View File

@@ -0,0 +1,677 @@
import * as React from "react";
import { AnimatePresence, motion } from "motion/react";
import { ChevronRight, Circle, MessageCircle, Wrench } from "lucide-react";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { cn } from "@/shared/lib/cn";
import { Markdown } from "@/shared/ui/markdown";
import { CodeBlockVariantContext } from "@/shared/ui/markdown/CodeBlock";
import {
AgentSessionWorkBlockRailProvider,
useAgentSessionTranscriptTurnMeta,
} from "./agentSessionTranscriptContext";
import { useTranscriptAnimationEnabled } from "./transcriptAnimationPreference";
import {
readWorkBlockChoice,
useWorkBlockDisclosureState,
useWorkBlockDisclosureStore,
} from "./agentSessionWorkBlockDisclosure";
import { formatTranscriptTimestampTitle } from "./agentSessionUtils";
import { TranscriptActivityItem } from "./activityRenderClasses/TranscriptActivityItem";
import type { AgentTranscriptIdentityProps } from "./activityRenderClasses/types";
import {
formatPreviousStepsLabel,
formatWorkBlockSummaryLabel,
projectWorkBlockEntries,
summarizeWorkBlock,
windowWorkBlockEntries,
type TranscriptWorkBlock,
type WorkBlockEntry,
type WorkBlockStatus,
} from "./agentSessionWorkBlockGrouping";
const FADE_TRANSITION = {
duration: 0.18,
ease: [0.215, 0.61, 0.355, 1],
} as const;
const COLLAPSE_TRANSITION = {
duration: 0.22,
ease: [0.215, 0.61, 0.355, 1],
} as const;
const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
/**
* The OS reduced-motion preference, read through `matchMedia` the way the rest
* of the app reads it (see `TerminalSubstrate`) rather than through motion's
* `useReducedMotion`.
*
* That helper resolves the query once per process and caches the answer, so it
* reports whatever the first render happened to see for the lifetime of the
* process. Here that would mean the reduced-motion path could never be
* exercised under test — the assertion would pass or fail on module load order
* rather than on the preference — and a preference change mid-session would be
* ignored. Subscribing to the query keeps both honest.
*/
function usePrefersReducedMotion() {
const [reduced, setReduced] = React.useState(
() => window.matchMedia(REDUCED_MOTION_QUERY).matches,
);
React.useEffect(() => {
const preference = window.matchMedia(REDUCED_MOTION_QUERY);
const update = () => setReduced(preference.matches);
update();
preference.addEventListener("change", update);
return () => preference.removeEventListener("change", update);
}, []);
return reduced;
}
/**
* Whether the reader wants motion at all: the transcript's own animation
* preference AND the OS reduced-motion setting. Both are respected, so a
* height animation is skipped rather than shortened when either says no.
*/
function useWorkBlockMotionEnabled() {
const animationPreferenceEnabled = useTranscriptAnimationEnabled();
const prefersReducedMotion = usePrefersReducedMotion();
return animationPreferenceEnabled && !prefersReducedMotion;
}
/**
* Animate a disclosure's height between 0 and its natural height.
*
* `<details>` cannot do this — its content is either laid out or not, with no
* intermediate height to tween — so the collapse would simply snap. Height is
* animated to and from `auto`, which motion resolves by measuring, so the block
* never needs a hard-coded height that could disagree with its content.
*/
function DisclosurePanel({
children,
motionEnabled,
open,
}: {
children: React.ReactNode;
motionEnabled: boolean;
open: boolean;
}) {
if (!motionEnabled) {
return open ? <div>{children}</div> : null;
}
return (
<AnimatePresence initial={false}>
{open ? (
<motion.div
animate={{ height: "auto", opacity: 1 }}
className="overflow-hidden"
exit={{ height: 0, opacity: 0 }}
initial={{ height: 0, opacity: 0 }}
transition={COLLAPSE_TRANSITION}
>
{children}
</motion.div>
) : null}
</AnimatePresence>
);
}
/**
* Whether a finished block should play its collapse on first paint.
*
* A turn that finishes while the reader is watching should be *seen* to fold —
* otherwise the rail they were reading is simply replaced by a one-line summary
* between frames, and it reads as content disappearing rather than as work
* settling. So a block that was live when it mounted renders open once, then
* closes after a paint, giving the height animation a real open→closed
* transition to play.
*
* A block that was already finished when it mounted (history load, scrollback)
* never had a rail on screen, so there is nothing to fold: it renders closed
* immediately. Distinguishing the two is what keeps the animation meaningful
* instead of a spurious flourish on every mount.
*/
function useSettleOnFinish(isActive: boolean, motionEnabled: boolean) {
const [settling, setSettling] = React.useState(false);
const wasActiveRef = React.useRef(isActive);
React.useEffect(() => {
if (isActive) {
wasActiveRef.current = true;
setSettling(false);
return;
}
if (!wasActiveRef.current) return;
wasActiveRef.current = false;
if (!motionEnabled) {
setSettling(false);
return;
}
// Two frames: the first commits the still-open render, the second flips it
// closed, so the animation has a start state that was actually painted.
setSettling(true);
let second: number | null = null;
const first = requestAnimationFrame(() => {
second = requestAnimationFrame(() => setSettling(false));
});
return () => {
cancelAnimationFrame(first);
if (second !== null) cancelAnimationFrame(second);
};
}, [isActive, motionEnabled]);
return settling;
}
/**
* Whether the block renders open, and how the reader takes that decision over.
*
* The policy is "open while work is in flight, folded once it finishes". The
* reader's own click wins from then on.
*
* The reader's choice is held ABOVE this component, keyed by the step ids it was
* taken on, because a work block does not outlive its own membership: blocks are
* regrouped from scratch each render and merge when a second assistant message
* demotes the first from final answer, so a `useState` here is destroyed by the
* agent posting again. See `agentSessionWorkBlockDisclosure`.
*
* The trigger is deliberately button-native. An earlier version used a shared
* `useControlledDisclosure` hook whose whole reason to exist was the
* `<details>` echo trap: `<details>` fires `toggle` for programmatic `open`
* changes as well as for clicks, indistinguishably, so a policy-driven open
* echoes back looking like reader intent and pins the row to its first policy
* state forever. This block's trigger is a `<button>`, so the only thing that
* can call `onToggle` is a real click — there is no echo to discriminate, and a
* guard against it would be dead code that reads as though it were load-bearing.
*/
function useWorkBlockDisclosure(
policyOpen: boolean,
itemIds: readonly string[],
) {
// Local state is the fallback for a block mounted outside a transcript, where
// nothing regroups it and per-component state is correct. Both hooks always
// run; only one of them is read.
const localStore = useWorkBlockDisclosureState();
const { choices, choose } = useWorkBlockDisclosureStore() ?? localStore;
const readerChoice = readWorkBlockChoice(choices, itemIds);
const open = readerChoice ?? policyOpen;
return {
chosenByReader: readerChoice !== null,
open,
toggle: React.useCallback(
() => choose(itemIds, !open),
[choose, itemIds, open],
),
};
}
export function AgentSessionWorkBlockSegment({
agentAvatarUrl,
agentName,
agentPubkey,
block,
profiles,
}: AgentTranscriptIdentityProps & {
block: TranscriptWorkBlock;
profiles?: UserProfileLookup;
}) {
const turnMeta = useAgentSessionTranscriptTurnMeta();
// One projection per render, shared by the summary counts and the rail, so
// "is this step failed?" has exactly one answer in this component.
const entries = React.useMemo(
() =>
projectWorkBlockEntries(block.items, {
liveTurnId: turnMeta.liveTurnId,
}),
[block.items, turnMeta.liveTurnId],
);
const status = React.useMemo(
() =>
summarizeWorkBlock(entries, {
streamingItemId: turnMeta.streamingItemId,
}),
[entries, turnMeta.streamingItemId],
);
const motionEnabled = useWorkBlockMotionEnabled();
const settling = useSettleOnFinish(status.isActive, motionEnabled);
// The steps the reader's fold choice is recorded against. Memoized on the
// items so `toggle`'s identity is stable between renders that did not change
// membership.
const itemIds = React.useMemo(
() => block.items.map((item) => item.id),
[block.items],
);
const disclosure = useWorkBlockDisclosure(
status.isActive || settling,
itemIds,
);
const isLive = status.isActive;
// A reader who expanded the block asked to see all of it, so windowing stops
// applying. The test is the reader's *choice*, not `open`: policy already
// holds every live block open, so keying this off `open` would switch
// windowing off in exactly the case it exists for.
const readerExpanded = disclosure.chosenByReader && disclosure.open;
const { hiddenEntries, visibleEntries } = React.useMemo(
() =>
windowWorkBlockEntries(entries, {
isActive: isLive && !readerExpanded,
}),
[entries, isLive, readerExpanded],
);
return (
// Declared once at the block root rather than per row: every tool item
// rendered anywhere inside a block — windowed rail, previous-steps
// disclosure — is a rail step, and the value is constant so it adds no
// re-render of its own.
<AgentSessionWorkBlockRailProvider value={true}>
<div
className="w-full min-w-0"
data-role="agent-work-block"
data-testid="transcript-work-block"
title={formatTranscriptTimestampTitle(block.timestamp)}
>
{/* While live the rail IS the status: a header would only restate what
the arriving steps already show. */}
{isLive ? null : (
<WorkBlockSummaryTrigger
onToggle={disclosure.toggle}
open={disclosure.open}
status={status}
/>
)}
<DisclosurePanel
motionEnabled={motionEnabled}
open={isLive || disclosure.open}
>
<div className="min-h-0">
{hiddenEntries.length > 0 ? (
<PreviousStepsDisclosure
agentAvatarUrl={agentAvatarUrl}
agentName={agentName}
agentPubkey={agentPubkey}
entries={hiddenEntries}
motionEnabled={motionEnabled}
profiles={profiles}
/>
) : null}
<WorkBlockRail
agentAvatarUrl={agentAvatarUrl}
agentName={agentName}
agentPubkey={agentPubkey}
animateEnter={motionEnabled && isLive}
entries={visibleEntries}
profiles={profiles}
/>
</div>
</DisclosurePanel>
</div>
</AgentSessionWorkBlockRailProvider>
);
}
function WorkBlockSummaryTrigger({
onToggle,
open,
status,
}: {
onToggle: () => void;
open: boolean;
status: WorkBlockStatus;
}) {
return (
<button
aria-expanded={open}
className="group/row flex min-h-6 w-full min-w-0 cursor-pointer items-center gap-1.5 rounded-md py-1 text-left text-muted-foreground transition-colors hover:text-foreground"
data-testid="transcript-work-block-summary"
onClick={onToggle}
type="button"
>
<span className="min-w-0 truncate text-sm tabular-nums">
{formatWorkBlockSummaryLabel(status)}
</span>
<ChevronRight
aria-hidden="true"
className={cn(
"h-3.5 w-3.5 shrink-0 text-muted-foreground/60 transition-transform group-hover/row:text-foreground",
open && "rotate-90",
)}
/>
</button>
);
}
function PreviousStepsDisclosure({
agentAvatarUrl,
agentName,
agentPubkey,
entries,
motionEnabled,
profiles,
}: AgentTranscriptIdentityProps & {
entries: WorkBlockEntry[];
motionEnabled: boolean;
profiles?: UserProfileLookup;
}) {
const [open, setOpen] = React.useState(false);
return (
<motion.div
animate={{ opacity: 1 }}
initial={motionEnabled ? { opacity: 0 } : false}
transition={motionEnabled ? FADE_TRANSITION : { duration: 0 }}
>
<button
aria-expanded={open}
className="group/row mb-1 flex min-h-6 w-full min-w-0 cursor-pointer items-center gap-1.5 rounded-md py-1 text-left text-muted-foreground transition-colors hover:text-foreground"
data-testid="transcript-work-block-previous-steps"
onClick={() => setOpen((previous) => !previous)}
type="button"
>
<span className="min-w-0 truncate text-sm tabular-nums">
{formatPreviousStepsLabel(entries.length)}
</span>
<ChevronRight
aria-hidden="true"
className={cn(
"h-3.5 w-3.5 shrink-0 text-muted-foreground/60 transition-transform group-hover/row:text-foreground",
open && "rotate-90",
)}
/>
</button>
<DisclosurePanel motionEnabled={motionEnabled} open={open}>
<WorkBlockRail
agentAvatarUrl={agentAvatarUrl}
agentName={agentName}
agentPubkey={agentPubkey}
animateEnter={false}
entries={entries}
profiles={profiles}
/>
</DisclosurePanel>
</motion.div>
);
}
function WorkBlockRail({
agentAvatarUrl,
agentName,
agentPubkey,
animateEnter,
entries,
profiles,
}: AgentTranscriptIdentityProps & {
animateEnter: boolean;
entries: WorkBlockEntry[];
profiles?: UserProfileLookup;
}) {
return (
<div data-role="agent-work-block-rail">
<AnimatePresence initial={false} mode="popLayout">
{entries.map((entry, index) => (
<motion.div
animate={{ opacity: 1, y: 0 }}
exit={animateEnter ? { opacity: 0, y: -4 } : undefined}
initial={animateEnter ? { opacity: 0, y: 4 } : false}
key={entry.item.id}
transition={animateEnter ? FADE_TRANSITION : { duration: 0 }}
>
<WorkBlockStepRow
agentAvatarUrl={agentAvatarUrl}
agentName={agentName}
agentPubkey={agentPubkey}
entry={entry}
isLast={index === entries.length - 1}
profiles={profiles}
/>
</motion.div>
))}
</AnimatePresence>
</div>
);
}
function WorkBlockStepRow({
agentAvatarUrl,
agentName,
agentPubkey,
entry,
isLast,
profiles,
}: AgentTranscriptIdentityProps & {
entry: WorkBlockEntry;
isLast: boolean;
profiles?: UserProfileLookup;
}) {
return (
<div
className="flex gap-2.5"
data-testid="transcript-work-block-step"
data-work-block-entry={entry.kind}
>
<WorkBlockRailGlyph entry={entry} isLast={isLast} />
<WorkBlockStepBody
agentAvatarUrl={agentAvatarUrl}
agentName={agentName}
agentPubkey={agentPubkey}
profiles={profiles}
{...entry}
/>
</div>
);
}
/**
* A step's content: one exhaustive switch over the entry kind, so a new kind
* cannot silently inherit another kind's presentation.
*
* The entry arrives SPREAD into props rather than as an `entry` object, because
* this component is memoized and the projection is rebuilt whenever the block's
* item array changes — i.e. on every append — so entry objects are fresh each
* time and a memo keyed on one would never hit. Spread, the compared props are
* `item` (reference-stable: the transcript store replaces items rather than
* mutating them) plus two strings, so shallow comparison is a sound "did not
* change" test where comparison on the entry wrapper is not.
*
* Spreading keeps the discriminated union intact — `kind` and `item` stay paired
* in the props type — so the switch below narrows `item` to the kind's own item
* type. That is what removes the previous `item.type === "thought" ? ... : ""`
* re-checks: a mismatch is no longer representable, so there is no mismatch
* branch to render an empty body for.
*
* The memo boundary is the body rather than the whole row because the glyph
* depends on the row's *position* (`isLast` decides whether the spine
* continues), and that changes for the previous last row on every append.
* Passing position into the memoized part would invalidate that row's body on
* each append for a one-pixel spine segment; splitting keeps the cheap,
* position-dependent half outside and the expensive, item-dependent half in.
*/
const WorkBlockStepBody = React.memo(function WorkBlockStepBody(
props: WorkBlockEntryBodyProps,
) {
return (
<div className="min-w-0 flex-1 pb-2">
<WorkBlockEntryBody {...props} />
</div>
);
});
type WorkBlockEntryBodyProps = AgentTranscriptIdentityProps & {
profiles?: UserProfileLookup;
} & WorkBlockEntry;
/**
* One exhaustive switch over the entry kind, so a new kind cannot silently
* inherit another kind's presentation.
*
* Because the props carry the whole discriminated entry, narrowing on `kind`
* also narrows `item` to that kind's item type. The previous version took
* `kind` and `item` as independent fields and so had to re-check
* `item.type === "thought" ? item.text : ""` — a mismatch branch that rendered
* an empty body and could only ever be reached by a projection bug.
*/
function WorkBlockEntryBody(props: WorkBlockEntryBodyProps) {
switch (props.kind) {
case "thought":
return (
<WorkBlockProseBody
testId="transcript-work-block-thought"
text={props.item.text}
/>
);
case "note":
return (
<WorkBlockProseBody
testId="transcript-work-block-note"
text={props.item.text}
/>
);
case "tool":
return (
<TranscriptActivityItem
agentAvatarUrl={props.agentAvatarUrl}
agentName={props.agentName}
agentPubkey={props.agentPubkey}
item={props.item}
profiles={props.profiles}
/>
);
}
}
/**
* Prose on the rail: reasoning, or an interim note the agent addressed to the
* reader. No disclosure of its own — the whole block is already one, and
* nesting a second would mean two clicks to read something the reader has just
* chosen to reveal.
*
* A note deliberately does NOT go through the message presenter. That presenter
* gives every conversation-variant assistant message an avatar + name identity
* row, which is right for the turn's answer and wrong here: a fully attributed
* agent turn nested inside a muted rail step reads as a second reply rather
* than as progress. berd draws the same distinction — its `progress` entry is a
* plain rail row, not a message bubble. The focus code-block recipe is kept by
* providing the same `CodeBlockVariantContext` value the presenter would.
*
* berd brightens rail prose with `usePrimaryText={open}`. Here it is
* unconditional, because the rail only ever exists inside the open disclosure
* panel — a closed block unmounts its rows entirely rather than rendering them
* dimmed. Threading an `open` flag down to this component would be a prop whose
* false branch is unreachable, which is the same shape of dead-code-that-looks-
* load-bearing as the disclosure echo guard this block also dropped. If the
* block ever renders a peek of its rows while closed, the flag comes back with
* a test that can actually reach both branches.
*/
function WorkBlockProseBody({
testId,
text,
}: {
testId: string;
text: string;
}) {
return (
<div
className="text-foreground text-sm leading-relaxed"
data-testid={testId}
>
<CodeBlockVariantContext.Provider value="focusProse">
<Markdown className="leading-relaxed" content={text.trim() || " "} />
</CodeBlockVariantContext.Provider>
</div>
);
}
/**
* The spine and this row's bullet.
*
* The bullet masks the spine passing behind it, which is what makes the rail
* read as a series of stops rather than a line with icons floating over it.
* The mask has to match the surface the transcript is actually drawn on — the
* cover drawer's `bg-background` — because a mask in any other colour shows up
* as a visible disc of the wrong shade around every bullet.
*
* (berd's equivalent uses `bg-card` and warns against `bg-background`; that is
* the same rule, not a different one. In berd the transcript sits on a card, so
* `bg-card` is its surface. Buzz's drawer surface is `bg-background`, and the
* two tokens are NOT interchangeable here: they share a value in the base
* themes, but in Buzz Dark the drawer sits inside `[data-buzz-content-surface]`,
* which locally overrides `--background` to `--buzz-content-dark` while
* `--card` keeps the theme value. Measured in a seeded browser:
*
* | theme | bullet | drawer surface | spine |
* | ------------ | --------------- | --------------- | --------------- |
* | github-light | rgb(255,255,255)| rgb(255,255,255)| rgb(229,229,230)|
* | buzz-dark | rgb(26,26,26) | rgb(26,26,26) | rgb(64,69,74) |
*
* `bg-card` paints the bullet `rgb(36,41,46)` over that `rgb(26,26,26)` drawer
* — a visible disc of the wrong shade, which is exactly the BOT-1599 failure.
* Light mode alone matches under either class, so light-mode evidence is not
* sufficient here. Following berd's class literally would be following the
* letter of its note against its point.)
*/
function WorkBlockRailGlyph({
entry,
isLast,
}: {
entry: WorkBlockEntry;
isLast: boolean;
}) {
return (
<div
aria-hidden="true"
className="relative flex w-5 shrink-0 justify-center self-stretch"
>
{isLast ? null : (
<div className="absolute top-5 bottom-0 left-1/2 w-px -translate-x-1/2 bg-border" />
)}
<div
className={cn(
"relative z-10 mt-0.5 flex size-5 items-center justify-center rounded-full bg-background text-muted-foreground ring-2 ring-background",
// `motion-safe:`, not bare `animate-pulse`: the guard has to be in the
// class because this animation is CSS, not motion. The block's height
// animation is skipped through `useWorkBlockMotionEnabled`, but that
// hook cannot reach a keyframe animation applied by a utility — and no
// reduced-motion rule in the app's CSS matches `.animate-pulse` (every
// one of them is scoped to a `buzz-*`/`motion-*` class), so an
// unguarded pulse would keep pulsing forever for a reader who asked
// for no motion. Tailwind compiles `motion-safe:` to
// `@media (prefers-reduced-motion: no-preference)`, which is the only
// thing that actually stops it. Matches every other pulse in this
// feature (`AgentStatusBadge`, `ManagedAgentRow`).
entry.state === "running" && "motion-safe:animate-pulse",
)}
data-step-state={entry.state}
>
<WorkBlockRailGlyphIcon entry={entry} />
</div>
</div>
);
}
/** The glyph itself: shape carries kind, and for a tool step, outcome. */
function WorkBlockRailGlyphIcon({ entry }: { entry: WorkBlockEntry }) {
switch (entry.kind) {
// Prose — reasoning or an interim note — is the agent talking, so both get
// the speech bubble. berd gives its `progress` entry the same glyph as a
// thought for the same reason.
case "thought":
case "note":
return <MessageCircle className="size-3.5" />;
case "tool":
return entry.state === "failed" ? (
// Failure is a glyph shape, not a colour: the rail stays uniformly
// muted so one failed step does not read as an alarm across the whole
// run. The tinted output block carries the red when the step is
// expanded.
<Circle className="size-2.5 fill-current" />
) : (
<Wrench className="size-3.5" />
);
}
}

View File

@@ -0,0 +1,343 @@
/**
* Shared jsdom lifecycle, item fixtures and the `renderBlock` view helper for
* the work-block suites. One owner for the order-sensitive globals: the two
* suites run in separate processes, so a second copy would not collide -- it
* would drift, which is the failure this rig exists to prevent.
*/
import { after, afterEach, before } from "node:test";
import { JSDOM } from "jsdom";
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
url: "http://localhost",
});
class NoopObserver {
disconnect() {}
observe() {}
unobserve() {}
}
let prefersReducedMotion = false;
/**
* ESM bindings are read-only in importers, so the reduced-motion test cannot
* assign this directly -- it goes through the setter, and `afterEach` below
* resets it for every file that imports this rig.
*/
export function setPrefersReducedMotion(value) {
prefersReducedMotion = value;
}
before(() => {
Object.assign(globalThis, {
document: dom.window.document,
Element: dom.window.Element,
Event: dom.window.Event,
HTMLElement: dom.window.HTMLElement,
IS_REACT_ACT_ENVIRONMENT: true,
IntersectionObserver: NoopObserver,
MutationObserver: dom.window.MutationObserver,
Node: dom.window.Node,
ResizeObserver: NoopObserver,
getComputedStyle: (...args) => dom.window.getComputedStyle(...args),
localStorage: dom.window.localStorage,
self: dom.window,
window: dom.window,
});
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: dom.window.navigator,
writable: true,
});
// `motion`'s useReducedMotion reads this query, so the reduced-motion test
// drives it through the same surface the component does.
dom.window.matchMedia = (query) => ({
matches:
prefersReducedMotion && String(query).includes("prefers-reduced-motion"),
addEventListener() {},
removeEventListener() {},
addListener() {},
removeListener() {},
});
globalThis.matchMedia = dom.window.matchMedia;
dom.window.requestAnimationFrame = (callback) => setTimeout(callback, 0);
dom.window.cancelAnimationFrame = (id) => clearTimeout(id);
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame;
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame;
});
afterEach(async () => {
prefersReducedMotion = false;
const { cleanup } = await import("@testing-library/react");
cleanup();
});
after(() => dom.window.close());
export const START = "2026-06-18T00:00:00.000Z";
export const SHARED = {
channelId: "chan-1",
sessionId: "sess-1",
turnId: "turn-1",
};
export function step(id, overrides = {}) {
return {
...SHARED,
id,
type: "tool",
renderClass: "shell",
descriptor: {
renderClass: "shell",
label: "Ran command",
preview: id,
source: "shell",
groupKey: "shell:command",
},
title: id,
toolName: "shell",
buzzToolName: null,
status: "completed",
args: {},
result: "ok",
isError: false,
timestamp: START,
startedAt: START,
completedAt: "2026-06-18T00:00:01.000Z",
...overrides,
};
}
export function thoughtStep(id) {
return {
...SHARED,
id,
type: "thought",
renderClass: "thought",
title: "Thinking",
text: "weighing the options",
timestamp: START,
};
}
export function noteStep(id, text = "posted the summary to the channel") {
return {
...SHARED,
id,
type: "message",
renderClass: "message",
role: "assistant",
title: "Agent",
text,
timestamp: START,
};
}
/**
* A relay post the agent made mid-turn.
*
* The distinguishing property is `descriptor.renderClass: "message"`, which is
* what `buildCompactToolSummary` turns into `presentation: "message"` and what
* routes the item to the avatar + speech-bubble presenter outside a block. The
* result carries an `event_id` so `getSentMessageLink` resolves too — that is
* the fully-featured shape (bubble, timestamp, delivery receipt), i.e. the one
* that goes most wrong on a muted rail.
*/
export function relayStep(id, overrides = {}) {
return step(id, {
renderClass: "message",
descriptor: {
renderClass: "message",
label: "Send Message",
preview: "posted the findings to the channel",
action: { verb: "Sent", object: "posted the findings to the channel" },
source: "shell",
groupKey: "buzz-cli:messages.send",
operation: "messages.send",
},
title: "Send Message",
toolName: "buzz_dev_mcp__shell",
args: {
command:
"buzz messages send --channel chan-1 --content 'posted the findings to the channel'",
},
result: '{"event_id":"ev-1","accepted":true}',
...overrides,
});
}
/**
* Let every pending animation finish and the DOM settle.
*
* A collapsing block keeps its rail mounted until the height animation's exit
* completes, which is the point of the fold — so "has it closed yet?" can only
* be asked after the exit runs, not on the commit that started it. Polling to a
* stable answer keeps these assertions about the end state the reader sees
* rather than about motion's internal frame schedule.
*/
export async function settle(read, expected, { timeout = 2000 } = {}) {
const { act } = await import("@testing-library/react");
const deadline = Date.now() + timeout;
let last = read();
while (Date.now() < deadline) {
if (last === expected) return last;
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 25));
});
last = read();
}
return last;
}
/**
* Mount a work block under a router.
*
* Rail rows render agent content through `Markdown`, which resolves in-app links
* via `useAppNavigation` and therefore needs router context — the same reason
* `AgentSessionTranscriptList.conversation.test.mjs` mounts through a memory
* router. Without it a thought row throws while a tool row happens not to.
*
* `liveTurnId` defaults to the fixtures' own turn, i.e. "the agent is working on
* this turn right now", because that is the situation nearly every case here is
* about. Pass `liveTurnId: null` for reopened history — see the orphaned-step
* tests, where the same items must read as finished.
*/
export async function renderBlock(
items,
{ liveTurnId = SHARED.turnId, streamingItemId = null } = {},
) {
const { createElement, useState } = await import("react");
const { render } = await import("@testing-library/react");
const { QueryClient, QueryClientProvider } = await import(
"@tanstack/react-query"
);
const { createMemoryHistory, createRootRoute, createRouter, RouterProvider } =
await import("@tanstack/react-router");
const { AgentSessionTranscriptTurnMetaProvider } = await import(
"./agentSessionTranscriptContext.ts"
);
const { AgentSessionWorkBlockSegment } = await import(
"./AgentSessionWorkBlock.tsx"
);
const element = (blockItems, streaming, live) =>
createElement(
AgentSessionTranscriptTurnMetaProvider,
{
value: {
liveTurnId: live,
streamingItemId: streaming,
},
},
createElement(AgentSessionWorkBlockSegment, {
agentAvatarUrl: null,
agentName: "Agent",
agentPubkey: "pk",
block: {
id: `work-block:${blockItems[0].id}`,
items: blockItems,
timestamp: blockItems[0].timestamp,
},
}),
);
let applyState;
const Harness = () => {
const [state, setState] = useState({ items, liveTurnId, streamingItemId });
applyState = setState;
return element(state.items, state.streamingItemId, state.liveTurnId);
};
// The bubble presenter reads the posted message through `useQuery`. Provided
// unconditionally so that if a rail step ever DID reach that presenter, the
// relay test below would fail on its bubble assertion rather than on a
// missing provider — a test must fail for the reason it claims.
// `gcTime: 0` because React Query's default is 300000ms, and an un-collected
// query arms a timer that long at teardown — node:test then waits it out
// before exiting, turning a 2s suite into a 5-minute wall with no failing
// assertion. Dormant here today (only the relay-bubble test reaches a
// `useQuery`, and it builds its own client), but a silent five-minute hang is
// an expensive thing to leave armed for the next rig-based test that renders
// a bubble.
const queryClient = new QueryClient({
defaultOptions: { queries: { gcTime: 0, retry: false } },
});
const rootRoute = createRootRoute({
component: () =>
createElement(
QueryClientProvider,
{ client: queryClient },
createElement(Harness),
),
});
const router = createRouter({
history: createMemoryHistory({ initialEntries: ["/"] }),
routeTree: rootRoute,
});
await router.load();
const view = render(createElement(RouterProvider, { router }));
const q = (selector) => view.container.querySelector(selector);
const qa = (selector) => [...view.container.querySelectorAll(selector)];
const stepCount = () =>
qa('[data-testid="transcript-work-block-step"]').length;
return {
...view,
// Re-render the SAME block id, exactly as the transcript does as work
// streams and then finishes. `liveTurnId` is carried through so a test can
// model the session going away under a still-running step (agent death),
// not just the step reaching a terminal status.
stream: (nextItems, streaming = null, live = liveTurnId) =>
applyState({
items: nextItems,
liveTurnId: live,
streamingItemId: streaming,
}),
summary: () => q('[data-testid="transcript-work-block-summary"]'),
previousSteps: () =>
q('[data-testid="transcript-work-block-previous-steps"]'),
stepCount,
// A finished block renders folded, so any assertion about what is ON the
// rail has to open it first — exactly as a reader would.
expand: async () => {
const { act } = await import("@testing-library/react");
await act(async () => {
q('[data-testid="transcript-work-block-summary"]').click();
});
},
// Wait for the rail to reach `expected` rows once animations have run.
settleToStepCount: (expected) => settle(stepCount, expected),
glyphStates: () =>
qa("[data-step-state]").map((node) =>
node.getAttribute("data-step-state"),
),
// Which rail glyphs pulse, reported as their step states, and the same
// narrowed to pulses that are NOT reduced-motion-safe.
//
// Both read `classList.contains`, which matches a whole class token, rather
// than a `.animate-pulse` CSS selector or `className.includes` —
// `animate-pulse` is a SUBSTRING of `motion-safe:animate-pulse`, so a
// substring check cannot tell the guarded class from the bare one and would
// report the reduced-motion regression as fixed.
//
// They return step-state STRINGS rather than the elements: a failing
// `deepEqual` over jsdom nodes makes node:test serialize the whole DOM tree
// and the runner dies on SIGKILL instead of printing an assertion, which
// silently turns "the mutant was caught" into an unreadable crash.
pulseStates: () =>
qa("[data-step-state]")
.filter(
(node) =>
node.classList.contains("motion-safe:animate-pulse") ||
node.classList.contains("animate-pulse"),
)
.map((node) => node.getAttribute("data-step-state")),
unguardedPulseStates: () =>
qa("[data-step-state]")
.filter((node) => node.classList.contains("animate-pulse"))
.map((node) => node.getAttribute("data-step-state")),
q,
qa,
};
}

View File

@@ -1,20 +1,10 @@
import * as React from "react";
import { Brain, ChevronDown } from "lucide-react";
import { cn } from "@/shared/lib/cn";
import { Markdown } from "@/shared/ui/markdown";
import { Shimmer } from "@/shared/ui/Shimmer";
import {
ActivityRow,
ActivityRowContent,
ActivityRowLabel,
} from "./ActivityRow";
import { ToolActivity } from "./ToolActivity";
import { formatThoughtDisclosureLabel } from "../agentSessionConversationMeta";
import {
useAgentSessionTranscriptTurnMeta,
useAgentSessionTranscriptVariant,
} from "../agentSessionTranscriptContext";
import { formatTranscriptTimestampTitle } from "../agentSessionUtils";
import type { ActivityRenderClassItemProps } from "./types";
@@ -29,17 +19,19 @@ export function ThoughtActivity(props: ActivityRenderClassItemProps) {
return <ThoughtItem item={props.item} />;
}
/**
* The `default`/`compactPreview` thought row.
*
* The `conversation` variant does not reach this presenter: focus mode renders
* reasoning as a row on the work block's rail (`AgentSessionWorkBlock`), so the
* separate per-thought disclosure that used to live here is gone rather than
* duplicated. Its "Thought for Ns" label logic moved with it.
*/
function ThoughtItem({
item,
}: {
item: Extract<ActivityRenderClassItemProps["item"], { type: "thought" }>;
}) {
const variant = useAgentSessionTranscriptVariant();
if (variant === "conversation") {
return <ConversationThought item={item} />;
}
return (
<ActivityRow
testId="transcript-thought-item"
@@ -52,82 +44,3 @@ function ThoughtItem({
</ActivityRow>
);
}
/**
* Focus-mode reasoning disclosure: collapsed by default so the reader sees the
* prompt/response rhythm, open while the thought is the streaming tail of a
* live turn so reasoning can be watched as it arrives, and collapsed again once
* the turn produces its next item (that is exactly when the item stops being
* the streaming tail — no timers involved).
*
* A reader who opens or closes it takes over: `userOpen` pins the state so the
* next stream transition cannot yank the panel out from under them.
*/
function ConversationThought({
item,
}: {
item: Extract<ActivityRenderClassItemProps["item"], { type: "thought" }>;
}) {
const turnMeta = useAgentSessionTranscriptTurnMeta();
const isStreaming = turnMeta.streamingItemId === item.id;
const [userOpen, setUserOpen] = React.useState<boolean | null>(null);
const isOpen = userOpen ?? isStreaming;
const label = formatThoughtDisclosureLabel({
durationSeconds: turnMeta.thoughtDurationSecondsById[item.id],
isStreaming,
});
// `<details>` fires `toggle` for programmatic `open` changes as well as
// clicks. Without this guard the auto-open for a streaming thought would be
// recorded as a reader choice and would then pin the disclosure open forever,
// so reasoning would never fold once the agent acted on it. Only a toggle
// that DISAGREES with the state we last rendered can have come from the
// reader.
//
// Same trap, same guard as the tool-run card's disclosure in the tool-chain
// slice; once that lands and extracts a shared controlled-details hook this
// should consume it rather than keeping a second copy.
const renderedOpenRef = React.useRef(isOpen);
React.useLayoutEffect(() => {
renderedOpenRef.current = isOpen;
}, [isOpen]);
const handleToggle = React.useCallback(
(event: React.SyntheticEvent<HTMLDetailsElement>) => {
const open = event.currentTarget.open;
if (open === renderedOpenRef.current) return;
setUserOpen(open);
},
[],
);
return (
<details
className="not-prose group w-full"
data-testid="transcript-thought-item"
onToggle={handleToggle}
open={isOpen}
title={formatTranscriptTimestampTitle(item.timestamp)}
>
<summary
className="flex w-full cursor-pointer list-none items-center gap-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground"
data-testid="transcript-thought-disclosure"
>
<Brain aria-hidden="true" className="h-3.5 w-3.5 shrink-0" />
{isStreaming ? <Shimmer>{label}</Shimmer> : <span>{label}</span>}
<ChevronDown
aria-hidden="true"
className={cn(
"h-3.5 w-3.5 shrink-0 text-muted-foreground/60 transition-transform",
"group-open:rotate-180",
)}
/>
</summary>
<div className="mt-2 border-l border-border/60 pl-3 text-sm leading-relaxed text-muted-foreground">
<Markdown
className="leading-relaxed"
content={item.text.trim() || " "}
/>
</div>
</details>
);
}

View File

@@ -1,11 +1,9 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
buildConversationTurnMeta,
formatThoughtDisclosureLabel,
} from "./agentSessionConversationMeta.ts";
import { buildConversationTurnMeta } from "./agentSessionConversationMeta.ts";
import { EMPTY_TRANSCRIPT_TURN_META } from "./agentSessionTranscriptContext.ts";
import { buildTranscriptDisplayBlocks } from "./agentSessionTranscriptGrouping.ts";
import {
formatPlanChecklistProgress,
parsePlanChecklist,
@@ -20,16 +18,160 @@ function item(overrides) {
};
}
const RAW_TIMESTAMP = "2026-06-14T19:00:00.000Z";
/**
* Raw transcript items, for the tests that go through the real grouping rather
* than asserting on hand-written blocks. `acpSource` is what decides whether an
* item is setup lifecycle, a prompt, or work, so these carry it.
*/
function lifecycleItem(id, acpSource, turnId) {
return item({
id,
type: "lifecycle",
renderClass: "status",
title: acpSource,
text: "",
timestamp: RAW_TIMESTAMP,
acpSource,
turnId,
});
}
function promptItem(id, turnId) {
return item({
id,
type: "message",
role: "user",
renderClass: "message",
title: "Buzz event",
text: "please look into this",
timestamp: RAW_TIMESTAMP,
acpSource: "session/prompt:user",
turnId,
});
}
function toolItem(id, turnId) {
return item({
id,
type: "tool",
renderClass: "shell",
title: id,
toolName: "shell",
buzzToolName: null,
status: "completed",
args: {},
result: "ok",
isError: false,
timestamp: RAW_TIMESTAMP,
startedAt: RAW_TIMESTAMP,
completedAt: RAW_TIMESTAMP,
descriptor: {
renderClass: "shell",
label: "Ran command",
preview: id,
source: "shell",
groupKey: "shell:command",
},
turnId,
});
}
function systemPromptItem(id) {
return item({
id,
type: "metadata",
renderClass: "metadata",
title: "System prompt",
sections: [],
timestamp: RAW_TIMESTAMP,
acpSource: "session/new",
turnId: null,
});
}
function turnBlock(segments) {
return { kind: "turn", turnId: "turn-1", segments };
}
/**
* The leaf items a block is built from, in wire order — including setup
* lifecycle items, which carry a turn id in the real stream even though they
* contribute nothing to the streaming tail.
*
* `buildConversationTurnMeta` reads the item stream as well as the blocks (it
* has to: a turn that has only emitted setup rows produces no block at all), so
* a test that passed blocks alone would be describing a transcript that cannot
* exist. This keeps the two arguments consistent by construction.
*/
function blockItems(blocks) {
return blocks.flatMap((block) => {
if (block.kind === "single") return [block.item];
if (block.kind !== "turn") return [];
return block.segments.flatMap((segment) => {
if (segment.kind === "prompt") return [segment.user];
if (segment.kind === "setup") return segment.items;
if (segment.kind === "summary") return segment.summary.items;
return [segment.item];
});
});
}
/** `buildConversationTurnMeta` with the item stream derived from the blocks. */
function metaFor(blocks, { items, ...options }) {
return buildConversationTurnMeta(blocks, {
...options,
items: items ?? blockItems(blocks),
});
}
function itemSegment(id, overrides) {
return {
kind: "item",
item: item({
id,
timestamp: "2026-06-14T19:00:02.000Z",
...overrides,
}),
};
}
const thoughtSegment = (id, timestamp) =>
itemSegment(id, {
type: "thought",
renderClass: "thought",
title: "Thinking",
text: "…",
timestamp,
});
const messageSegment = (id, timestamp, role = "assistant") =>
itemSegment(id, {
type: "message",
renderClass: "message",
role,
title: role === "user" ? "Ada" : "Agent",
text: "done",
timestamp,
});
const toolSegment = (id, timestamp) =>
itemSegment(id, {
type: "tool",
renderClass: "shell",
title: "Ran a command",
text: "",
descriptor: { label: "Ran a command", preview: "cargo test" },
timestamp,
});
// ---- buildConversationTurnMeta ----
test("buildConversationTurnMeta returns the shared empty value for other variants", () => {
for (const variant of ["default", "compactPreview"]) {
assert.equal(
buildConversationTurnMeta([turnBlock([])], {
metaFor([turnBlock([])], {
isTurnLive: true,
variant,
}),
@@ -39,77 +181,45 @@ test("buildConversationTurnMeta returns the shared empty value for other variant
}
});
test("buildConversationTurnMeta times a thought against the turn's next item", () => {
const meta = buildConversationTurnMeta(
[
turnBlock([
{
kind: "item",
item: item({
id: "thought:1",
type: "thought",
renderClass: "thought",
title: "Thinking",
text: "…",
timestamp: "2026-06-14T19:00:02.000Z",
}),
},
{
kind: "item",
item: item({
id: "msg:1",
type: "message",
renderClass: "message",
role: "assistant",
title: "Agent",
text: "done",
timestamp: "2026-06-14T19:00:14.000Z",
}),
},
]),
],
{ isTurnLive: false, variant: "conversation" },
test("buildConversationTurnMeta reports nothing streaming when the turn is idle", () => {
// The hint exists to tell the work block it is still working. A finished turn
// has no tail, and reporting one would pin the block open forever.
assert.equal(
metaFor(
[
turnBlock([
thoughtSegment("thought:1", "2026-06-14T19:00:02.000Z"),
messageSegment("msg:1", "2026-06-14T19:00:14.000Z"),
]),
],
{ isTurnLive: false, variant: "conversation" },
),
EMPTY_TRANSCRIPT_TURN_META,
);
assert.deepEqual(meta.thoughtDurationSecondsById, { "thought:1": 12 });
assert.equal(meta.streamingItemId, null);
});
test("buildConversationTurnMeta leaves a trailing thought untimed and marks it streaming when live", () => {
test("buildConversationTurnMeta names the trailing item of a live turn", () => {
const blocks = [
turnBlock([
{
kind: "item",
item: item({
id: "thought:tail",
type: "thought",
renderClass: "thought",
title: "Thinking",
text: "…",
timestamp: "2026-06-14T19:00:02.000Z",
}),
},
]),
turnBlock([thoughtSegment("thought:tail", "2026-06-14T19:00:02.000Z")]),
];
const live = buildConversationTurnMeta(blocks, {
isTurnLive: true,
variant: "conversation",
});
assert.deepEqual(live.thoughtDurationSecondsById, {});
assert.equal(live.streamingItemId, "thought:tail");
const idle = buildConversationTurnMeta(blocks, {
isTurnLive: false,
variant: "conversation",
});
assert.equal(idle.streamingItemId, null);
assert.equal(
metaFor(blocks, {
isTurnLive: true,
variant: "conversation",
}).streamingItemId,
"thought:tail",
"a thought carries no status of its own, so the hint is how the block knows it is live",
);
});
test("buildConversationTurnMeta counts a prompt as an item and skips setup segments", () => {
const meta = buildConversationTurnMeta(
test("buildConversationTurnMeta skips setup segments when finding the tail", () => {
// Setup renders as a quiet divider, not as work, so it must never be reported
// as the streaming item — a lifecycle row would hold the block open.
const meta = metaFor(
[
turnBlock([
thoughtSegment("thought:1", "2026-06-14T19:00:01.000Z"),
{
kind: "setup",
items: [
@@ -123,121 +233,293 @@ test("buildConversationTurnMeta counts a prompt as an item and skips setup segme
}),
],
},
{
kind: "item",
item: item({
id: "thought:1",
type: "thought",
renderClass: "thought",
title: "Thinking",
text: "…",
timestamp: "2026-06-14T19:00:01.000Z",
}),
},
{
kind: "prompt",
context: null,
setup: [],
user: item({
id: "msg:steer",
type: "message",
renderClass: "message",
role: "user",
title: "Ada",
text: "actually…",
timestamp: "2026-06-14T19:00:04.000Z",
}),
},
]),
],
{ isTurnLive: true, variant: "conversation" },
);
// Setup contributes nothing, so the thought is timed against the steer prompt.
assert.deepEqual(meta.thoughtDurationSecondsById, { "thought:1": 3 });
assert.equal(meta.streamingItemId, "thought:1");
});
test("buildConversationTurnMeta counts a mid-turn steer prompt as the tail", () => {
const meta = metaFor(
[
turnBlock([
thoughtSegment("thought:1", "2026-06-14T19:00:01.000Z"),
messageSegment("msg:steer", "2026-06-14T19:00:04.000Z", "user"),
]),
],
{ isTurnLive: true, variant: "conversation" },
);
assert.equal(meta.streamingItemId, "msg:steer");
});
// ---- formatThoughtDisclosureLabel ----
/**
* Product ruling (ss-core-02, Slice B review): reasoning folds as soon as the
* agent ACTS on it — the next leaf item of any kind — not only when the next
* assistant message arrives. A thought pinned open across a long tool run is
* exactly the noise the focus view removes. This test exists so the rule is
* explicit rather than an accident of `items[index + 1]`.
*/
test("buildConversationTurnMeta ends a thought at the next tool call, not the next message", () => {
const meta = buildConversationTurnMeta(
test("buildConversationTurnMeta expands a summary segment to reach its last tool", () => {
// A summary segment holds several leaf items; the tail is the last of them,
// not the summary itself, which has no item id the work block could match.
const meta = metaFor(
[
turnBlock([
{
kind: "item",
item: item({
id: "thought:1",
type: "thought",
renderClass: "thought",
title: "Thinking",
text: "…",
timestamp: "2026-06-14T19:00:02.000Z",
}),
},
{
kind: "item",
item: item({
id: "tool:1",
type: "tool",
kind: "summary",
summary: {
id: "summary:shell:tool:1",
label: "Ran 2 commands",
count: 2,
items: [
toolSegment("tool:1", "2026-06-14T19:00:04.000Z").item,
toolSegment("tool:2", "2026-06-14T19:00:05.000Z").item,
],
renderClass: "shell",
title: "Ran a command",
text: "",
descriptor: { label: "Ran a command", preview: "cargo test" },
timestamp: "2026-06-14T19:00:06.000Z",
}),
},
{
kind: "item",
item: item({
id: "msg:1",
type: "message",
renderClass: "message",
role: "assistant",
title: "Agent",
text: "tests pass",
timestamp: "2026-06-14T19:01:30.000Z",
}),
variant: "same-kind",
timestamp: "2026-06-14T19:00:04.000Z",
},
},
]),
],
{ isTurnLive: true, variant: "conversation" },
);
// 4s to the tool call, NOT 88s to the assistant message: the thought is done
// the moment the agent acts, so it folds instead of staying open for the run.
assert.deepEqual(meta.thoughtDurationSecondsById, { "thought:1": 4 });
// The trailing message is the streaming tail, so the thought is not streaming.
assert.equal(meta.streamingItemId, "msg:1");
assert.equal(meta.streamingItemId, "tool:2");
});
test("formatThoughtDisclosureLabel says Thinking while streaming", () => {
test("buildConversationTurnMeta reads a trailing single block directly", () => {
const meta = metaFor(
[
turnBlock([thoughtSegment("thought:1", "2026-06-14T19:00:01.000Z")]),
{
kind: "single",
item: item({
id: "life:orphan",
type: "lifecycle",
renderClass: "status",
title: "Context compacted",
text: "",
timestamp: "2026-06-14T19:00:20.000Z",
}),
},
],
{ isTurnLive: true, variant: "conversation" },
);
assert.equal(meta.streamingItemId, "life:orphan");
});
// ---- buildConversationTurnMeta: liveTurnId ----
/**
* The work block cannot tell an abandoned step from a running one on its own: a
* tool keeps its `executing` status forever if the agent dies mid-step. Turn
* ownership is knowledge only the list has, so it is published here.
*/
test("buildConversationTurnMeta names the live turn so the block can gate running steps", () => {
const meta = metaFor(
[turnBlock([thoughtSegment("thought:1", "2026-06-14T19:00:01.000Z")])],
{ isTurnLive: true, variant: "conversation" },
);
assert.equal(meta.liveTurnId, "turn-1");
});
test("buildConversationTurnMeta reports no live turn when the turn is idle", () => {
// This is the case the orphaned-step bug lived in: history reopened, nothing
// live, but an item still claiming `executing`.
const meta = metaFor(
[turnBlock([thoughtSegment("thought:1", "2026-06-14T19:00:01.000Z")])],
{ isTurnLive: false, variant: "conversation" },
);
assert.equal(meta.liveTurnId, null);
assert.equal(
formatThoughtDisclosureLabel({ durationSeconds: 4, isStreaming: true }),
"Thinking…",
meta,
EMPTY_TRANSCRIPT_TURN_META,
"an idle turn still allocates nothing",
);
});
test("formatThoughtDisclosureLabel reports a duration once the turn moved on", () => {
assert.equal(
formatThoughtDisclosureLabel({ durationSeconds: 9, isStreaming: false }),
"Thought for 9s",
test("buildConversationTurnMeta names the live turn even when a lifecycle row trails it", () => {
// A compaction notice arrives as a `single` block AFTER the turn it belongs
// to, so "the last block" is not always the live turn — but the last turn is.
// Reading the last block's kind alone would report no live turn and gate every
// running step off, which is the mirror image of the bug being fixed.
const meta = metaFor(
[
turnBlock([thoughtSegment("thought:1", "2026-06-14T19:00:01.000Z")]),
{
kind: "single",
item: item({
id: "life:compact",
type: "lifecycle",
renderClass: "status",
title: "Context compacted",
text: "",
timestamp: "2026-06-14T19:00:20.000Z",
}),
},
],
{ isTurnLive: true, variant: "conversation" },
);
assert.equal(meta.liveTurnId, "turn-1");
});
test("formatThoughtDisclosureLabel avoids a misleading 0s", () => {
assert.equal(
formatThoughtDisclosureLabel({ durationSeconds: 0, isStreaming: false }),
"Thought for a moment",
test("buildConversationTurnMeta reports no live turn when there is no turn at all", () => {
// `turnId: null` explicitly: a session-started row arrives before any turn
// exists, so it belongs to nobody. The shared `item()` helper defaults to
// `turn-1`, which would have made this fixture describe a transcript that
// does have a turn — and the assertion would then be checking that a turn
// with no BLOCK reports no live turn, which is the opposite of the rule.
const meta = metaFor(
[
{
kind: "single",
item: item({
id: "life:1",
type: "lifecycle",
renderClass: "status",
title: "Session started",
text: "",
timestamp: "2026-06-14T19:00:00.000Z",
turnId: null,
}),
},
],
{ isTurnLive: true, variant: "conversation" },
);
assert.equal(formatThoughtDisclosureLabel({ isStreaming: false }), "Thought");
assert.equal(meta.liveTurnId, null);
});
// ---- buildConversationTurnMeta: the gap between two turns ----
/**
* The next turn owns liveness the moment it appears, even before it has anything
* to show.
*
* A turn that has only emitted setup lifecycle rows (`turn_started`,
* `session_resolved`) classifies to zero segments and therefore produces NO
* block — `agentSessionTranscriptGrouping` only pushes a turn block
* `if (segments.length > 0)`. So "the newest turn with a block" is the turn that
* ALREADY ENDED for the whole gap between `turn_started` and the new turn's
* first prompt or thought, and the ended turn's own trailing item then gets
* reported as the streaming item. Its finished work block reads as live again:
* the folded summary is replaced by the open rail, a 6-step block drops to its
* last three with a previous-steps disclosure, and then it all folds back.
*
* That gap is real observer-stream latency, and `turn_started` fires on every
* turn (`activeAgentTurnsStore`), so this was not an edge case — it was every
* turn, for as long as the agent took to emit its first renderable item.
*
* Built from a raw item stream through the real grouping rather than from
* hand-written blocks, because the whole bug is which turns DO and DO NOT
* produce a block: hand-built blocks would assume the answer away.
*
* The frames pin the PLAIN next turn (`turn_started`, then `session_resolved`,
* with no `session/new` card) because that is the common path and the only one
* that pins the bug. On a session restart the `session/new` card lands as a
* trailing `single` block, which moves `streamingItemId` off turn-1's work block
* on its own — so a restart-only fixture would pass against the old
* block-walking code. Both sequences are covered; only the plain one is
* load-bearing.
*/
test("buildConversationTurnMeta hands liveness to a next turn that has no block yet", () => {
const stream = (...extra) => [
lifecycleItem("life:start:1", "turn_started", "turn-1"),
promptItem("prompt:1", "turn-1"),
toolItem("tool:1", "turn-1"),
// turn-1's last act is a completed tool: the agent answered by posting
// through buzz-cli, so no assistant message trails its work.
toolItem("tool:2", "turn-1"),
...extra,
];
const started = lifecycleItem("life:start:2", "turn_started", "turn-2");
const resolved = lifecycleItem(
"life:resolved:2",
"session_resolved",
"turn-2",
);
const sysPrompt = systemPromptItem("meta:sysprompt");
const frames = [
// The plain next turn — no session restart, so no `session/new` card. This
// is both the common path and the only one that pins the bug: a
// `session/new` card renders as a trailing `single` block, which moves the
// streaming item off turn-1's work block by itself and so lets the old
// block-walking code pass for the wrong reason.
{
label: "turn-2 has only started",
items: stream(started),
},
{
label: "the session has resolved with no restart card",
items: stream(started, resolved),
},
// The restarting next turn, where a system-prompt card lands between the
// lifecycle rows. A different route through the grouping, kept because it
// has to settle too.
{
label: "the restarting turn's system prompt has landed",
items: stream(started, sysPrompt),
},
{
label: "the restarted session has resolved",
items: stream(started, sysPrompt, resolved),
},
];
for (const frame of frames) {
const meta = buildConversationTurnMeta(
buildTranscriptDisplayBlocks(frame.items),
{ isTurnLive: true, items: frame.items, variant: "conversation" },
);
assert.equal(
meta.liveTurnId,
"turn-2",
`the newest turn owns liveness once ${frame.label}`,
);
assert.equal(
meta.streamingItemId,
null,
`turn-1's tail must not read as streaming once ${frame.label}`,
);
}
// Final frame: turn-2 finally has something renderable, so it gets a block
// and its own prompt becomes the tail. Included so the sequence ends in the
// steady state rather than stopping at the gap.
const renderable = stream(
started,
resolved,
promptItem("prompt:2", "turn-2"),
);
const meta = buildConversationTurnMeta(
buildTranscriptDisplayBlocks(renderable),
{ isTurnLive: true, items: renderable, variant: "conversation" },
);
assert.equal(meta.liveTurnId, "turn-2");
assert.equal(meta.streamingItemId, "prompt:2");
});
test("buildConversationTurnMeta still names the live turn's own trailing item", () => {
// The other half of the ownership check: when the newest turn IS the turn that
// owns the trailing item, that item is the streaming one. Without this the
// gate would be a blanket "never report a tail" and every live block would
// fold while the reader watched it arrive.
const items = [
lifecycleItem("life:start:1", "turn_started", "turn-1"),
promptItem("prompt:1", "turn-1"),
toolItem("tool:1", "turn-1"),
];
const meta = buildConversationTurnMeta(buildTranscriptDisplayBlocks(items), {
isTurnLive: true,
items,
variant: "conversation",
});
assert.equal(meta.liveTurnId, "turn-1");
assert.equal(meta.streamingItemId, "tool:1");
});
// ---- parsePlanChecklist ----

View File

@@ -14,6 +14,12 @@ import type { TranscriptItem } from "./agentSessionTypes";
* in the order they appear. Prompt segments contribute their user message;
* setup segments contribute nothing (they render as a quiet divider, not work);
* summary segments contribute their collapsed tool items.
*
* This walks the SHARED segment union deliberately. The conversation variant's
* additive work-block transform runs later, at the list's render boundary
* (`TranscriptDisplayBlockView`), so no `work-block` segment can reach here —
* and the streaming tail must be computed from the true item order regardless
* of how the variant later groups those items for presentation.
*/
function turnSegmentItems(segment: TranscriptTurnSegment): TranscriptItem[] {
if (segment.kind === "prompt") return [segment.user];
@@ -22,99 +28,126 @@ function turnSegmentItems(segment: TranscriptTurnSegment): TranscriptItem[] {
return [segment.item];
}
function elapsedSeconds(from: string, to: string): number | null {
const start = Date.parse(from);
const end = Date.parse(to);
if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
const seconds = Math.round((end - start) / 1_000);
return seconds >= 0 ? seconds : null;
}
/**
* Derive the `conversation` variant's streaming/duration hints from the display
* blocks.
* Derive the `conversation` variant's live-turn hints from the display blocks.
*
* Two facts the individual render classes cannot see on their own:
* Two things the work block cannot see for itself:
*
* - **Which item is streaming.** Only the trailing leaf item of the final turn
* can be mid-stream, and only when the agent's turn is actually live. The
* thought disclosure auto-opens for exactly that item, so the reader watches
* reasoning arrive rather than clicking to find it.
* - **How long a thought ran.** A thought is finished once the turn produces
* ANY later leaf item — a tool call or plan update, not just the next
* assistant message. This is deliberate: reasoning recedes as soon as the
* agent *acts* on it, which is the moment the reader's attention should move
* to the action. Folding only on the next message would pin reasoning open
* across a long tool run, which is exactly the noise the focus view exists
* to remove. The gap between the thought's timestamp and that next item's
* timestamp is the "Thought for Ns" duration. Thoughts with no following
* item are still open, so they get no duration.
* - **`streamingItemId`** — the trailing leaf item of the final turn, and only
* when the agent's turn is actually live. The block reads this to decide
* whether it is still working: a thought or note streaming in carries no
* status of its own, so without this hint a block whose last step is prose
* would fold while the reader was still watching it arrive.
* - **`liveTurnId`** — which turn a session currently owns. A tool item keeps
* its `executing` status forever if the agent dies mid-step, so status alone
* cannot tell a running step from an abandoned one; the block needs to know
* whose turn is live to decide (see `AgentSessionTranscriptTurnMeta`).
*
* Returns the shared empty value for non-conversation variants so the default
* and compactPreview paths allocate nothing and stay byte-identical.
*/
export function buildConversationTurnMeta(
displayBlocks: TranscriptDisplayBlock[],
options: { isTurnLive: boolean; variant: AgentSessionTranscriptVariant },
options: {
isTurnLive: boolean;
items: TranscriptItem[];
variant: AgentSessionTranscriptVariant;
},
): AgentSessionTranscriptTurnMeta {
if (options.variant !== "conversation") {
if (options.variant !== "conversation" || !options.isTurnLive) {
return EMPTY_TRANSCRIPT_TURN_META;
}
const thoughtDurationSecondsById: Record<string, number> = {};
let streamingItemId: string | null = null;
const lastBlock = displayBlocks[displayBlocks.length - 1];
// The live turn is the newest turn in the ITEM stream, not the newest turn
// that produced a block. See `latestTurnId` for why the distinction is
// load-bearing. Read from the transcript rather than from the active-turn
// store because the store's turn ids and the transcript's are populated by
// different paths, and a mismatch would silently gate every step off.
const liveTurnId = latestTurnId(options.items, displayBlocks);
for (const block of displayBlocks) {
if (block.kind !== "turn") continue;
const items = block.segments.flatMap(turnSegmentItems);
for (let index = 0; index < items.length; index++) {
const item = items[index];
if (item.type !== "thought") continue;
const next = items[index + 1];
if (!next) continue;
const seconds = elapsedSeconds(item.timestamp, next.timestamp);
if (seconds !== null) {
thoughtDurationSecondsById[item.id] = seconds;
}
}
if (lastBlock?.kind === "single") {
return {
liveTurnId,
streamingItemId: streamingIdForTail(lastBlock.item, liveTurnId),
};
}
if (lastBlock?.kind !== "turn") {
return { liveTurnId, streamingItemId: null };
}
if (options.isTurnLive) {
const lastBlock = displayBlocks[displayBlocks.length - 1];
if (lastBlock?.kind === "turn") {
const items = lastBlock.segments.flatMap(turnSegmentItems);
streamingItemId = items[items.length - 1]?.id ?? null;
} else if (lastBlock?.kind === "single") {
streamingItemId = lastBlock.item.id;
}
}
return { streamingItemId, thoughtDurationSecondsById };
const items = lastBlock.segments.flatMap(turnSegmentItems);
const tail = items[items.length - 1];
return {
liveTurnId,
streamingItemId: tail ? streamingIdForTail(tail, liveTurnId) : null,
};
}
/**
* Disclosure label for a thought in the `conversation` variant.
* The trailing item counts as streaming only if the LIVE turn owns it.
*
* While the thought is the streaming tail of a live turn the reader is watching
* reasoning arrive, so the label stays present tense. Once the turn has moved
* on we know how long it ran and say so; a thought we cannot time (missing or
* unparseable timestamps) still gets a stable past-tense label rather than a
* misleading "0s".
* Without this check the newest block's tail is reported as streaming no matter
* whose turn it belongs to, so a finished turn's last step would hold that
* turn's work block open while a *different* turn is the live one. That is the
* same class of bug as an abandoned `executing` step: presenting settled history
* as work in flight.
*/
export function formatThoughtDisclosureLabel(options: {
durationSeconds?: number;
isStreaming: boolean;
}): string {
if (options.isStreaming) {
return "Thinking…";
}
const seconds = options.durationSeconds;
if (seconds === undefined) {
return "Thought";
}
if (seconds < 1) {
return "Thought for a moment";
}
return `Thought for ${seconds}s`;
function streamingIdForTail(
tail: TranscriptItem,
liveTurnId: string | null,
): string | null {
// Compared rather than tested for truthiness, for the same reason as
// `toolEntryState`: an item with no turn id is owned by nobody, and
// `null === null` must not read as ownership.
if (liveTurnId === null || tail.turnId !== liveTurnId) return null;
return tail.id;
}
/**
* The id of the newest turn the transcript has seen — including a turn that has
* arrived but has nothing renderable in it yet.
*
* Read from the items rather than the blocks because a turn that has only
* emitted setup lifecycle rows (`turn_started`, `session_resolved`) classifies
* to zero segments and so produces NO block at all
* (`agentSessionTranscriptGrouping`: `if (segments.length > 0)`). Walking the
* blocks backwards for the last `turn` therefore skipped straight past the new
* turn and returned the turn that had already ended — which then made its own
* trailing item the streaming item, and a settled block visibly re-opened,
* dropped to its last three steps and re-expanded for the whole gap between
* `turn_started` and the new turn's first prompt or thought. That gap is real
* observer-stream latency and happens on every turn, so this was not an edge
* case.
*
* Items are in wire order, so the last one carrying a turn id names the newest
* turn. Falls back to the last turn block for an item stream that carries no
* turn ids at all.
*/
function latestTurnId(
items: TranscriptItem[],
displayBlocks: TranscriptDisplayBlock[],
): string | null {
for (let index = items.length - 1; index >= 0; index -= 1) {
const turnId = items[index]?.turnId;
if (turnId) return turnId;
}
return lastTurnBlockId(displayBlocks);
}
/**
* The id of the last turn block, ignoring anything that trails it.
*
* A lifecycle row (a compaction notice, a session boundary) can land after the
* turn it belongs to and arrives as a `single` block, so the last block is not
* always the live turn — but the last *turn* is.
*/
function lastTurnBlockId(
displayBlocks: TranscriptDisplayBlock[],
): string | null {
for (let index = displayBlocks.length - 1; index >= 0; index -= 1) {
const block = displayBlocks[index];
if (block.kind === "turn") return block.turnId;
}
return null;
}

View File

@@ -28,22 +28,66 @@ export function useAgentSessionTranscriptVariant() {
/**
* 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) and how long each thought ran before the turn
* produced its next item. Derived once by the list from the display blocks —
* see `buildConversationTurnMeta`.
* 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;
/** Thought item id → seconds elapsed until the turn's next item. */
thoughtDurationSecondsById: Record<string, number>;
/**
* 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,
thoughtDurationSecondsById: {},
};
/**
* 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,

View File

@@ -0,0 +1,121 @@
import * as React from "react";
/**
* The reader's fold/unfold choices for focus-mode work blocks, keyed by the
* STEP ids a choice was taken on rather than by block id.
*
* ## Why this cannot be `useState` inside the block
*
* A work block's id is derived from its first step (`work-block:${items[0].id}`,
* see `groupConversationWorkBlocks`), and block membership is recomputed from
* scratch on every render. `findFinalAnswerId` exempts only the LAST assistant
* message from the block, so when a second assistant message arrives the first
* one stops being the answer, becomes work, and the run of steps around it
* merges: two blocks become one.
*
* frame 2 work-block:th:1[th:1,tool:1] msg:1 work-block:th:2[th:2,tool:2]
* frame 3 work-block:th:1[th:1,tool:1,msg:1,th:2,tool:2] msg:2
*
* `work-block:th:2` ceases to exist, so React unmounts it and any state it
* owned goes with it. A reader who had opened it to read the steps was folded
* shut by an event they did not cause — the agent posting another message.
*
* Keying on step ids survives that, because the steps are what the reader's
* intent was actually about: they are still on screen after the merge, just
* inside a different block.
*/
export type WorkBlockDisclosureChoices = ReadonlyMap<string, boolean>;
/**
* The reader's choice for a block, or `null` when they have not taken one and
* policy still owns the fold.
*
* An open choice wins over a folded one. After a merge the block can carry both
* — one constituent opened, another folded — and the two are not symmetric:
* showing steps a reader asked to see costs them a scroll, while hiding steps a
* reader asked to see loses the thing they were reading. Same reason the
* absorbed block's own choice cannot simply be dropped.
*/
export function readWorkBlockChoice(
choices: WorkBlockDisclosureChoices,
itemIds: readonly string[],
): boolean | null {
let folded: boolean | null = null;
for (const itemId of itemIds) {
const choice = choices.get(itemId);
if (choice === true) return true;
if (choice === false) folded = false;
}
return folded;
}
/**
* Record one choice against every step the block currently holds.
*
* Written across all of them, not just the first, because the block this choice
* was taken on may later be absorbed into a block that begins with a different
* step — and the reader's intent has to be findable from whichever step the
* merged block happens to start with.
*/
export function recordWorkBlockChoice(
choices: WorkBlockDisclosureChoices,
itemIds: readonly string[],
choice: boolean,
): WorkBlockDisclosureChoices {
const next = new Map(choices);
for (const itemId of itemIds) next.set(itemId, choice);
return next;
}
const EMPTY_CHOICES: WorkBlockDisclosureChoices = new Map();
export type WorkBlockDisclosureStore = {
choices: WorkBlockDisclosureChoices;
choose: (itemIds: readonly string[], choice: boolean) => void;
};
/**
* `null` when no transcript is providing a store.
*
* Deliberately not a no-op store: a work block rendered on its own — which is
* how most of its tests mount it — would then swallow every click silently and
* look like a component whose disclosure is broken. `useWorkBlockDisclosure`
* falls back to component-local state instead, which is correct in isolation
* (nothing is regrouping the block) and is the behaviour a reader of that
* component would expect. A provider emits no DOM, so `default` and
* `compactPreview` markup is unaffected either way.
*/
const WorkBlockDisclosureContext =
React.createContext<WorkBlockDisclosureStore | null>(null);
export function AgentSessionWorkBlockDisclosureProvider({
children,
}: {
children: React.ReactNode;
}) {
const store = useWorkBlockDisclosureState();
return (
<WorkBlockDisclosureContext.Provider value={store}>
{children}
</WorkBlockDisclosureContext.Provider>
);
}
/** The store's state and updater, so the fallback path can reuse it verbatim. */
export function useWorkBlockDisclosureState(): WorkBlockDisclosureStore {
const [choices, setChoices] =
React.useState<WorkBlockDisclosureChoices>(EMPTY_CHOICES);
const choose = React.useCallback(
(itemIds: readonly string[], choice: boolean) => {
setChoices((current) => recordWorkBlockChoice(current, itemIds, choice));
},
[],
);
return React.useMemo(() => ({ choices, choose }), [choices, choose]);
}
export function useWorkBlockDisclosureStore() {
return React.useContext(WorkBlockDisclosureContext);
}

View File

@@ -0,0 +1,553 @@
/**
* Work-block grouping: which of a turn's items fold onto the rail, where the
* block splits, and what its folded line says.
*/
import assert from "node:assert/strict";
import test from "node:test";
import {
formatPreviousStepsLabel,
formatWorkBlockSummaryLabel,
groupConversationWorkBlocks,
projectWorkBlockEntries,
summarizeWorkBlock,
windowWorkBlockEntries,
WORK_BLOCK_LIVE_WINDOW_SIZE,
} from "./agentSessionWorkBlockGrouping.ts";
const SHARED = { channelId: "chan-1", sessionId: "sess-1", turnId: "turn-1" };
/**
* Project the block's items with the block's own turn live — i.e. the agent is
* working on `turn-1` right now, which is the turn every fixture here belongs
* to. Most projection cases are about what a step looks like while its own turn
* is being worked, so this is the default lens.
*/
const projectLive = (items) =>
projectWorkBlockEntries(items, { liveTurnId: "turn-1" });
/**
* Project with no live turn — reopened history, or a session that has ended.
* The distinction matters because a tool item's `executing` status is written
* once at the start and never revised if the agent dies, so it is only evidence
* of work in flight when a session still owns the turn.
*/
const projectHistory = (items) =>
projectWorkBlockEntries(items, { liveTurnId: null });
function thought(id, timestamp = "2026-06-14T19:00:02.000Z") {
return {
...SHARED,
id,
type: "thought",
renderClass: "thought",
title: "Thinking",
text: "weighing the options",
timestamp,
};
}
function tool(id, overrides = {}) {
return {
...SHARED,
id,
type: "tool",
renderClass: "shell",
descriptor: {
renderClass: "shell",
label: "Ran a command",
preview: "cargo test",
tone: "neutral",
source: "shell",
},
title: "Ran a command",
toolName: "shell",
buzzToolName: null,
status: "completed",
args: { command: "cargo test" },
result: "ok",
isError: false,
timestamp: "2026-06-14T19:00:04.000Z",
startedAt: "2026-06-14T19:00:04.000Z",
completedAt: "2026-06-14T19:00:05.000Z",
...overrides,
};
}
function message(id, role, overrides = {}) {
return {
...SHARED,
id,
type: "message",
renderClass: "message",
role,
title: role === "user" ? "Ada" : "Test Agent",
text: "hello",
timestamp: "2026-06-14T19:00:09.000Z",
...overrides,
};
}
function plan(id) {
return {
...SHARED,
id,
type: "plan",
renderClass: "plan",
title: "Plan",
text: "- [ ] ship it",
timestamp: "2026-06-14T19:00:07.000Z",
};
}
function lifecycle(id, renderClass) {
return {
...SHARED,
id,
type: "lifecycle",
renderClass,
title:
renderClass === "permission"
? "Permission requested"
: "Context compacted",
text: "",
timestamp: "2026-06-14T19:00:06.000Z",
};
}
const itemSegments = (...items) =>
items.map((item) => ({ kind: "item", item }));
const kinds = (segments) => segments.map((segment) => segment.kind);
const blockItemIds = (segment) => segment.block.items.map((item) => item.id);
// ---- membership ----
test("thinking, tool steps and the prompt/answer split fold into one block", () => {
const prompt = {
kind: "prompt",
user: message("msg:user", "user"),
context: null,
setup: [],
};
const answer = message("msg:answer", "assistant");
const grouped = groupConversationWorkBlocks([
prompt,
...itemSegments(thought("thought:1"), tool("tool:1"), tool("tool:2")),
{ kind: "item", item: answer },
]);
assert.deepEqual(kinds(grouped), ["prompt", "work-block", "item"]);
assert.deepEqual(blockItemIds(grouped[1]), ["thought:1", "tool:1", "tool:2"]);
assert.equal(
grouped[2].item.id,
"msg:answer",
"the turn's answer stays outside the block as prose",
);
});
test("a single work item still becomes a block", () => {
// No minimum: a lone step must not render through a different mechanism than
// a run of steps, or the rail would appear and disappear by count.
const grouped = groupConversationWorkBlocks(itemSegments(tool("tool:1")));
assert.deepEqual(kinds(grouped), ["work-block"]);
assert.deepEqual(blockItemIds(grouped[0]), ["tool:1"]);
});
test("an interim agent note is work but the final answer is not", () => {
const grouped = groupConversationWorkBlocks(
itemSegments(
tool("tool:1"),
message("msg:interim", "assistant"),
tool("tool:2"),
message("msg:answer", "assistant"),
),
);
assert.deepEqual(kinds(grouped), ["work-block", "item"]);
assert.deepEqual(
blockItemIds(grouped[0]),
["tool:1", "msg:interim", "tool:2"],
"a mid-turn note reads as progress and belongs on the rail",
);
assert.equal(grouped[1].item.id, "msg:answer");
});
test("only the LAST assistant message is treated as the answer", () => {
// Two trailing notes: the earlier one is still work.
const grouped = groupConversationWorkBlocks(
itemSegments(
tool("tool:1"),
message("msg:first", "assistant"),
message("msg:last", "assistant"),
),
);
assert.deepEqual(kinds(grouped), ["work-block", "item"]);
assert.deepEqual(blockItemIds(grouped[0]), ["tool:1", "msg:first"]);
assert.equal(grouped[1].item.id, "msg:last");
});
test("a turn with no answer folds all of its work", () => {
// Mid-turn: nothing has been answered yet, so every item is work and the
// block must not hold back its most recent step waiting for an answer.
const grouped = groupConversationWorkBlocks(
itemSegments(thought("thought:1"), tool("tool:1")),
);
assert.deepEqual(kinds(grouped), ["work-block"]);
assert.deepEqual(blockItemIds(grouped[0]), ["thought:1", "tool:1"]);
});
// ---- what stays out ----
test("the plan stays a sibling and splits the work around it", () => {
const grouped = groupConversationWorkBlocks(
itemSegments(tool("tool:1"), plan("plan:1"), tool("tool:2")),
);
assert.deepEqual(kinds(grouped), ["work-block", "item", "work-block"]);
assert.equal(grouped[1].item.id, "plan:1");
assert.deepEqual(blockItemIds(grouped[0]), ["tool:1"]);
assert.deepEqual(blockItemIds(grouped[2]), ["tool:2"]);
});
test("permission gates and errors stay loud, in position", () => {
for (const renderClass of ["permission", "error", "status"]) {
const grouped = groupConversationWorkBlocks(
itemSegments(
tool("tool:1"),
lifecycle(`life:${renderClass}`, renderClass),
tool("tool:2"),
),
);
assert.deepEqual(
kinds(grouped),
["work-block", "item", "work-block"],
`${renderClass} must never fold into a collapsed block`,
);
assert.equal(
grouped[1].item.id,
`life:${renderClass}`,
`${renderClass} must stay where it happened, not be lifted out of order`,
);
}
});
test("the user's prompt is never work", () => {
const grouped = groupConversationWorkBlocks([
{
kind: "prompt",
user: message("msg:user", "user"),
context: null,
setup: [],
},
]);
assert.deepEqual(kinds(grouped), ["prompt"]);
});
test("setup segments neither join a block nor split one", () => {
// Setup renders as a quiet divider, not as work, and contributes no items —
// so it must not sit between two blocks that ought to be one.
const grouped = groupConversationWorkBlocks([
{ kind: "item", item: tool("tool:1") },
{ kind: "setup", items: [] },
{ kind: "item", item: tool("tool:2") },
]);
assert.deepEqual(kinds(grouped), ["work-block", "setup", "work-block"]);
});
test("a default-variant summary is expanded back to its leaf steps", () => {
// The other variants' "Read 3 files" summaries are a competing answer to the
// same grouping problem. Nesting one inside the block would cost the reader a
// second click to reach a step.
const grouped = groupConversationWorkBlocks([
{
kind: "summary",
summary: {
id: "summary:shell:tool:1",
label: "Ran 2 commands",
count: 2,
items: [tool("tool:1"), tool("tool:2")],
renderClass: "shell",
variant: "same-kind",
timestamp: "2026-06-14T19:00:04.000Z",
},
},
]);
assert.deepEqual(kinds(grouped), ["work-block"]);
assert.deepEqual(blockItemIds(grouped[0]), ["tool:1", "tool:2"]);
});
// ---- identity ----
test("the block id is derived from its first step so appends do not remount it", () => {
const first = groupConversationWorkBlocks(itemSegments(tool("tool:1")));
const grown = groupConversationWorkBlocks(
itemSegments(tool("tool:1"), tool("tool:2"), tool("tool:3")),
);
assert.equal(
grown[0].block.id,
first[0].block.id,
"a growing block must keep its identity or the reader's disclosure choice is lost",
);
});
// ---- projection ----
test("every item projects to exactly one rail kind, paired with its own item type", () => {
// The closed set is the point: glyph and body render off a switch over these
// three, so an item that projected to nothing (or to two things) is what let
// a note pick up a wrench on the abandoned card.
//
// The pairing half is what makes the body switch able to read `item.text`
// directly. `WorkBlockEntry` is a discriminated union, so `{ kind: "note",
// item: <thought> }` does not type-check — but a hand-written swap between the
// two prose branches is the easy mistake, and TypeScript is stripped at
// runtime, so the pairing is asserted here as well.
const entries = projectLive([
thought("thought:1"),
message("msg:interim", "assistant"),
tool("tool:1"),
]);
assert.deepEqual(
entries.map((entry) => entry.kind),
["thought", "note", "tool"],
);
assert.deepEqual(
entries.map((entry) => [entry.kind, entry.item.type]),
[
["thought", "thought"],
["note", "message"],
["tool", "tool"],
],
"a kind always carries its own item type — the body switch reads the item without re-checking",
);
assert.deepEqual(
entries.map((entry) => entry.item.id),
["thought:1", "msg:interim", "tool:1"],
"projection preserves arrival order",
);
});
test("only a tool step can be running or failed", () => {
const stateOf = (item) => projectLive([item])[0].state;
assert.equal(stateOf(tool("t", { status: "executing" })), "running");
assert.equal(stateOf(tool("t", { status: "pending" })), "running");
assert.equal(
stateOf(tool("t", { status: "failed" })),
"failed",
"a failed status is a failure even without isError",
);
assert.equal(
stateOf(tool("t", { isError: true })),
"failed",
"an error result is a failure even when the status reads completed",
);
assert.equal(stateOf(tool("t")), "settled");
// Prose has no outcome of its own. A thought whose text happens to mention a
// failure, or a note, must not colour the rail or the folded count.
assert.equal(stateOf(thought("thought:1")), "settled");
assert.equal(stateOf(message("msg:1", "assistant")), "settled");
});
test("a step that is both running and errored still reads as running", () => {
// Order matters: a tool can carry a stale isError from a retry while the new
// attempt executes. Reporting it as failed would fold a live block's count to
// "N steps · 1 failed" while the work is still in flight.
assert.equal(
projectLive([tool("t", { status: "executing", isError: true })])[0].state,
"running",
);
});
// ---- liveness ----
/**
* An abandoned step is not a running step.
*
* `executing` is written when a step starts and never revised if the agent dies
* first, so reopened history keeps that status forever. In this block `running`
* is not just a glyph — one running entry makes `summarizeWorkBlock` report
* `isActive`, which suppresses the folded summary line and holds the rail open.
* Ungated, a single orphaned step therefore renders finished history as work in
* progress, pulsing indefinitely.
*/
test("an executing step whose turn is not live reads as settled, not running", () => {
const orphan = tool("t", { status: "executing", completedAt: null });
assert.equal(
projectLive([orphan])[0].state,
"running",
"the same item IS running while a session owns its turn",
);
assert.equal(
projectHistory([orphan])[0].state,
"settled",
"with no live turn the status only says the step began, not that it is happening",
);
assert.equal(
projectWorkBlockEntries([orphan], { liveTurnId: "turn-2" })[0].state,
"settled",
"an agent live on a LATER turn does not resurrect an earlier turn's abandoned step",
);
});
test("a pending step whose turn is not live reads as settled too", () => {
// Both in-flight statuses go through the same gate; `pending` is the one a
// crash between queue and start leaves behind.
const queued = tool("t", { status: "pending", completedAt: null });
assert.equal(projectLive([queued])[0].state, "running");
assert.equal(projectHistory([queued])[0].state, "settled");
});
test("an abandoned step is settled rather than failed, so it never inflates the failure count", () => {
// We do not know an abandoned step failed — only that nobody finished it.
// Counting it as a failure would put "1 failed" on the folded line for work
// that may well have succeeded without its terminal update being recorded.
const status = summarizeWorkBlock(
projectHistory([
tool("tool:1"),
tool("tool:2", { status: "executing", completedAt: null }),
]),
{ streamingItemId: null },
);
assert.deepEqual(status, { count: 2, failedCount: 0, isActive: false });
assert.equal(
formatWorkBlockSummaryLabel(status),
"2 steps",
"reopened history folds to a neutral count",
);
});
test("a genuinely failed step is still failed when its turn is not live", () => {
// The gate is only about the in-flight statuses. A step that recorded a
// failure recorded a fact, and history must keep reporting it.
const failed = () => tool("t", { isError: true, status: "failed" });
assert.equal(projectHistory([failed()])[0].state, "failed");
assert.equal(
summarizeWorkBlock(projectHistory([failed()]), { streamingItemId: null })
.failedCount,
1,
);
});
test("a step with no turn id is not owned by a live turn", () => {
// `null === null` must not read as ownership: an item that never recorded a
// turn cannot be shown to belong to the live one.
assert.equal(
projectWorkBlockEntries(
[tool("t", { status: "executing", completedAt: null, turnId: null })],
{ liveTurnId: null },
)[0].state,
"settled",
);
});
// ---- status ----
test("summarizeWorkBlock counts steps and failures", () => {
const status = summarizeWorkBlock(
projectLive([
tool("tool:1"),
tool("tool:2", { isError: true, status: "failed" }),
thought("thought:1"),
]),
{ streamingItemId: null },
);
assert.deepEqual(status, { count: 3, failedCount: 1, isActive: false });
});
test("a block is active when a step is running OR when it holds the streaming item", () => {
assert.equal(
summarizeWorkBlock(
projectLive([tool("tool:1", { status: "executing", completedAt: null })]),
{ streamingItemId: null },
).isActive,
true,
"a step reporting itself as executing is enough",
);
// A thought streaming in carries no tool status, so status alone would miss it.
const streamingThought = projectLive([thought("thought:1")]);
assert.equal(
summarizeWorkBlock(streamingThought, { streamingItemId: "thought:1" })
.isActive,
true,
"the list's streaming hint covers work that carries no status of its own",
);
assert.equal(
summarizeWorkBlock(streamingThought, { streamingItemId: "other" }).isActive,
false,
"a streaming item in a DIFFERENT block must not make this one live",
);
});
// ---- labels ----
test("the folded line names a failure rather than hiding it behind a count", () => {
assert.equal(
formatWorkBlockSummaryLabel({ count: 6, failedCount: 0, isActive: false }),
"6 steps",
);
assert.equal(
formatWorkBlockSummaryLabel({ count: 6, failedCount: 1, isActive: false }),
"6 steps · 1 failed",
);
assert.equal(
formatWorkBlockSummaryLabel({ count: 6, failedCount: 2, isActive: false }),
"6 steps · 2 failed",
);
assert.equal(
formatWorkBlockSummaryLabel({ count: 1, failedCount: 0, isActive: false }),
"1 step",
"a single step must not read as '1 steps'",
);
});
test("the previous-steps label is singular for one step", () => {
assert.equal(formatPreviousStepsLabel(1), "1 previous step");
assert.equal(formatPreviousStepsLabel(4), "4 previous steps");
});
// ---- live window ----
const entryIds = (entries) => entries.map((entry) => entry.item.id);
test("a live block shows the last N steps in true order and hides the rest", () => {
const entries = projectLive(["a", "b", "c", "d", "e"].map((id) => tool(id)));
const { hiddenEntries, visibleEntries } = windowWorkBlockEntries(entries, {
isActive: true,
});
assert.equal(visibleEntries.length, WORK_BLOCK_LIVE_WINDOW_SIZE);
assert.deepEqual(
entryIds(visibleEntries),
["c", "d", "e"],
"the window is chronological — arrival order, not reversed",
);
assert.deepEqual(entryIds(hiddenEntries), ["a", "b"]);
});
test("a finished block shows every step", () => {
const entries = projectLive(["a", "b", "c", "d", "e"].map((id) => tool(id)));
const { hiddenEntries, visibleEntries } = windowWorkBlockEntries(entries, {
isActive: false,
});
assert.equal(hiddenEntries.length, 0);
assert.equal(visibleEntries.length, 5);
});
test("a live block at or under the window size hides nothing", () => {
const entries = projectLive(["a", "b", "c"].map((id) => tool(id)));
const { hiddenEntries, visibleEntries } = windowWorkBlockEntries(entries, {
isActive: true,
});
assert.equal(
hiddenEntries.length,
0,
"no disclosure until there is a step to hide",
);
assert.equal(visibleEntries.length, 3);
});

View File

@@ -0,0 +1,428 @@
import type {
TranscriptTurnSegment,
TranscriptDisplayBlock,
} from "./agentSessionTranscriptGrouping";
import type { TranscriptItem } from "./agentSessionTypes";
/** Reasoning. */
type WorkBlockThoughtItem = Extract<TranscriptItem, { type: "thought" }>;
/**
* An interim agent note: an assistant message that is not the turn's answer.
* The `role` intersection is what makes a *user* message unrepresentable as a
* note, so the rail cannot present the reader's own prompt as agent progress.
*/
type WorkBlockNoteItem = Extract<TranscriptItem, { type: "message" }> & {
role: "assistant";
};
/** A step the agent ran. */
type WorkBlockToolItem = Extract<TranscriptItem, { type: "tool" }>;
/**
* The items a work block admits, as a closed union.
*
* This is the type-level half of `isWorkItem`: a block cannot hold a plan, a
* lifecycle row or the turn's answer, so no downstream code has to consider
* what those would look like on the rail. Adding a `TranscriptItem` variant to
* this union is a deliberate act that forces a projection decision (see
* `projectWorkBlockEntry`) rather than letting the new type inherit another
* kind's presentation.
*/
export type WorkBlockItem =
| WorkBlockThoughtItem
| WorkBlockNoteItem
| WorkBlockToolItem;
/**
* One turn's work block: the thinking, tool steps and interim agent notes that
* happened between the reader's prompt and the agent's answer, presented as a
* single rail rather than as a row per item.
*/
export type TranscriptWorkBlock = {
/**
* Stable identity, derived from the FIRST item in the block. A block grows in
* place as later steps stream in, so keying on the first item keeps the id
* append-stable — anything derived from the last item would remount the block
* on every append and throw away the reader's disclosure choice.
*/
id: string;
/** Steps in true arrival order. */
items: WorkBlockItem[];
/** Timestamp of the block's first step. */
timestamp: string;
};
/**
* A turn's segments as the `conversation` variant renders them: the shared
* segment union plus the work block, which only this variant produces.
*/
export type TranscriptConversationSegment =
| TranscriptTurnSegment
| { kind: "work-block"; block: TranscriptWorkBlock };
/**
* Which items are *work* — the material the block absorbs.
*
* - **thoughts** — reasoning is a step on the rail, not a sibling disclosure.
* - **tool steps**, including failed ones. A failure belongs to the work it
* happened in; the folded line reports it (`N steps · 1 failed`) so it is
* never hidden behind a neutral count.
* - **interim agent notes** — an assistant message that is not the turn's
* answer. A relay post the agent makes mid-turn is a tool step and lands
* here for the same reason: it reads as progress, not as the reply.
*
* Everything else stays outside and keeps its own row. Plans stay a sibling
* (Buzz's checklist is a first-class surface, not a step), and lifecycle rows —
* permission gates, errors, status/compaction notices — are exactly the rows a
* reader may need to act on or reinterpret the rest of the turn through, so
* they never fold into a collapsed block.
*
* A type guard rather than a predicate, so the admitted set is checked once
* here and every later stage receives `WorkBlockItem` — the reason the entry
* projection can be exhaustive at all.
*/
function isWorkItem(
item: TranscriptItem,
finalAnswerId: string | null,
): item is WorkBlockItem {
if (item.type === "thought" || item.type === "tool") return true;
return (
item.type === "message" &&
item.role === "assistant" &&
item.id !== finalAnswerId
);
}
/**
* The turn's answer: its LAST assistant message.
*
* Deliberately positional rather than liveness-derived. While a turn streams,
* the message currently arriving is already the presumptive answer, so the same
* rule holds mid-turn and after it settles — the block does not reshuffle its
* membership at the moment a turn completes.
*/
function findFinalAnswerId(segments: TranscriptTurnSegment[]): string | null {
for (let index = segments.length - 1; index >= 0; index -= 1) {
const segment = segments[index];
if (
segment.kind === "item" &&
segment.item.type === "message" &&
segment.item.role === "assistant"
) {
return segment.item.id;
}
}
return null;
}
/**
* The leaf items a segment contributes as candidate work, in reading order.
*
* Summary segments are expanded back to their leaf tool rows: the default
* variant's "Read 3 files" summaries are a *different* answer to the same
* grouping problem, and nesting them inside the block would give the reader two
* collapsed layers to open before reaching a step. The block is the one
* grouping in this variant.
*/
function segmentWorkCandidates(
segment: TranscriptTurnSegment,
): TranscriptItem[] {
if (segment.kind === "item") return [segment.item];
if (segment.kind === "summary") return segment.summary.items;
return [];
}
/**
* The segment's candidates as admitted work items, or `null` when the segment
* is not (entirely) work and therefore keeps its own row.
*
* All-or-nothing per segment, as before: a summary segment is one grouping
* decision the shared code already made, so admitting half of it would emit its
* remaining rows outside the block they belong to. Returning the narrowed array
* rather than a boolean is what carries `WorkBlockItem` into the block — an
* `.every()` guard cannot narrow the array it tested.
*/
function admittedWorkItems(
segment: TranscriptTurnSegment,
finalAnswerId: string | null,
): WorkBlockItem[] | null {
const candidates = segmentWorkCandidates(segment);
if (candidates.length === 0) return null;
const admitted: WorkBlockItem[] = [];
for (const item of candidates) {
if (!isWorkItem(item, finalAnswerId)) return null;
admitted.push(item);
}
return admitted;
}
/**
* Collapse each maximal run of consecutive work items in a turn into one work
* block.
*
* ## Why runs, and not "everything between the prompt and the answer"
*
* A turn is normally one block: prompt → (thinking, tools, notes) → answer, with
* the plan checklist as a sibling. The span reading only differs when a
* non-work row lands *inside* the work — a permission gate, an error, a
* mid-turn plan update — and there the two readings disagree about order:
* producing a single block would mean lifting that row out and re-emitting it
* somewhere it did not happen.
*
* Splitting instead keeps every row where it occurred. That matters most for
* exactly the rows this is about: a permission gate is a question asked at a
* moment, and an error is the reason the steps after it look the way they do.
* Moving either away from its position would cost the reader the thing that
* makes it legible. So an interruption genuinely splits the work, and the seam
* is the interruption itself.
*/
export function groupConversationWorkBlocks(
segments: TranscriptTurnSegment[],
): TranscriptConversationSegment[] {
const finalAnswerId = findFinalAnswerId(segments);
const grouped: TranscriptConversationSegment[] = [];
let pending: WorkBlockItem[] = [];
const flush = () => {
if (pending.length === 0) return;
grouped.push({
kind: "work-block",
block: {
id: `work-block:${pending[0].id}`,
items: pending,
timestamp: pending[0].timestamp,
},
});
pending = [];
};
for (const segment of segments) {
const work = admittedWorkItems(segment, finalAnswerId);
if (work !== null) {
pending.push(...work);
continue;
}
flush();
grouped.push(segment);
}
flush();
return grouped;
}
/** Apply work-block grouping to every turn in a display block. */
export function conversationSegmentsForBlock(
block: Extract<TranscriptDisplayBlock, { kind: "turn" }>,
): TranscriptConversationSegment[] {
return groupConversationWorkBlocks(block.segments);
}
/** A tool step's rail state. Only a tool step can be in flight or have failed. */
export type WorkBlockEntryState = "running" | "failed" | "settled";
/**
* One projected rail row: what a row *is*, as a closed set.
*
* - `thought` — reasoning.
* - `note` — an interim agent message: prose the agent addressed to the
* reader mid-turn, which is not the turn's answer. berd models this as a
* distinct `progress` entry and gives it the same speech-bubble glyph as a
* thought, because it reads as the agent talking, not as the agent acting.
* - `tool` — a step the agent ran.
*
* Projection happens ONCE per item, and everything downstream — the glyph, the
* body, the folded line's counts — reads this rather than re-deriving it. Two
* independent classifications of the same item is exactly how the headline and
* the chain eligibility drifted apart on the abandoned tool-chain card, and
* asking `item.type === ...` at each render site is what let a note fall
* through to the tool branch and pick up a wrench.
*
* A discriminated union rather than a product of independent
* `{ item, kind, state }` fields, so the model itself rules out the
* combinations a product type leaves representable — and which every render
* site would otherwise have to defend against:
*
* - a `note` kind carrying a thought item (or vice versa), which is why the
* body switch previously had to re-check `item.type` and had a silent
* empty-string branch for the mismatch it could not otherwise handle;
* - prose that claims to be `running` or `failed`. Only a tool step has an
* outcome, so `state` is fixed to `settled` on the prose kinds — an edit
* that let a thought report a failure would not compile, rather than
* quietly adding to the folded line's `N failed`.
*/
export type WorkBlockEntry =
| { kind: "thought"; item: WorkBlockThoughtItem; state: "settled" }
| { kind: "note"; item: WorkBlockNoteItem; state: "settled" }
| { kind: "tool"; item: WorkBlockToolItem; state: WorkBlockEntryState };
/**
* A tool step's outcome.
*
* Order matters: a tool can carry a stale `isError` from a retry while the new
* attempt executes, and reporting that as failed would fold a live block's
* count to "N steps · 1 failed" while the work is still in flight.
*
* `executing`/`pending` only counts as *running* when a session actually owns
* the step's turn. That status is written when the step starts and is never
* revised if the agent dies first, so on its own it says "this step began", not
* "this step is happening" — reopened history full of abandoned steps would
* otherwise present as work in progress forever. See `liveTurnId`.
*
* An abandoned step is reported as `settled`, not as a new state: we do not
* know that it failed, so it must not count toward the folded line's
* `N failed`, and inventing a third outcome would put a marker on the rail for
* something the reader cannot act on. It renders as the neutral step it is,
* with whatever detail it managed to record.
*/
function toolEntryState(
item: WorkBlockToolItem,
liveTurnId: string | null,
): WorkBlockEntryState {
if (item.status === "executing" || item.status === "pending") {
// Compared rather than tested for truthiness: an item with no turn id
// cannot be owned by a live turn, and `null === null` must not read as
// ownership.
return liveTurnId !== null && item.turnId === liveTurnId
? "running"
: "settled";
}
if (item.isError || item.status === "failed") return "failed";
return "settled";
}
/**
* Project one admitted item into its rail entry.
*
* The switch is exhaustive over `WorkBlockItem` with no default: adding a
* variant to that union without deciding what it looks like on the rail leaves
* this function without an ending return, which is a type error. That is the
* whole point of the closed union — a new item type must fail loudly here
* instead of falling through to the tool branch and wearing a wrench.
*/
function projectWorkBlockEntry(
item: WorkBlockItem,
liveTurnId: string | null,
): WorkBlockEntry {
switch (item.type) {
case "thought":
return { item, kind: "thought", state: "settled" };
case "message":
return { item, kind: "note", state: "settled" };
case "tool":
return { item, kind: "tool", state: toolEntryState(item, liveTurnId) };
}
}
/**
* Project a block's items into rail entries, in true arrival order.
*
* `liveTurnId` is required rather than optional: whether a step is running is
* not a property of the step alone, and a default would let a caller that has
* not thought about liveness get the old spins-forever behaviour silently.
*/
export function projectWorkBlockEntries(
items: WorkBlockItem[],
options: { liveTurnId: string | null },
): WorkBlockEntry[] {
return items.map((item) => projectWorkBlockEntry(item, options.liveTurnId));
}
export type WorkBlockStatus = {
/** Total steps on the rail. */
count: number;
/** Steps that failed. */
failedCount: number;
/**
* Whether work is still happening: a step is pending/executing, or the turn
* is live and its streaming item belongs to this block.
*/
isActive: boolean;
};
/**
* Aggregate state of a block, read off the SAME projection the rail renders.
*
* `isActive` is what decides live-vs-finished presentation, so it accepts both
* evidence sources: an entry projected as `running`, and the list's
* streaming-item hint. Either alone leaves a real gap — a thought streaming in
* carries no tool status, and a tool left executing when the observer stream
* drops would otherwise pin the block open forever if we trusted status alone.
*/
export function summarizeWorkBlock(
entries: WorkBlockEntry[],
options: { streamingItemId: string | null },
): WorkBlockStatus {
let failedCount = 0;
let isActive = false;
for (const entry of entries) {
if (entry.state === "failed") failedCount += 1;
if (entry.state === "running") isActive = true;
if (
options.streamingItemId !== null &&
entry.item.id === options.streamingItemId
) {
isActive = true;
}
}
return { count: entries.length, failedCount, isActive };
}
function pluralSteps(count: number) {
return count === 1 ? "1 step" : `${count} steps`;
}
/**
* The folded line for a finished block.
*
* berd's is a bare count. The failure clause is a deliberate departure: a count
* alone is the one thing a reader cannot tell 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.
*/
export function formatWorkBlockSummaryLabel(status: WorkBlockStatus): string {
const steps = pluralSteps(status.count);
if (status.failedCount === 0) return steps;
return `${steps} · ${status.failedCount} failed`;
}
/** Label for the older steps tucked above the live window. */
export function formatPreviousStepsLabel(count: number): string {
return count === 1 ? "1 previous step" : `${count} previous steps`;
}
/**
* How many steps stay on the rail while work is in flight. The rest go behind
* the "N previous steps" disclosure, so a long run does not push the answer off
* screen while the reader is watching it arrive.
*/
export const WORK_BLOCK_LIVE_WINDOW_SIZE = 3;
export type WorkBlockWindow = {
/** Entries rendered on the rail, in true order. */
visibleEntries: WorkBlockEntry[];
/** Older entries behind the disclosure, in true order. */
hiddenEntries: WorkBlockEntry[];
};
/**
* Chronological window over a live block: the last N steps in true arrival
* order, with everything older behind the disclosure. A finished (or reader-
* expanded) block shows every step, so windowing only applies while live.
*/
export function windowWorkBlockEntries(
entries: WorkBlockEntry[],
options: { isActive: boolean },
): WorkBlockWindow {
if (!options.isActive || entries.length <= WORK_BLOCK_LIVE_WINDOW_SIZE) {
return { hiddenEntries: [], visibleEntries: entries };
}
const splitAt = entries.length - WORK_BLOCK_LIVE_WINDOW_SIZE;
return {
hiddenEntries: entries.slice(0, splitAt),
visibleEntries: entries.slice(splitAt),
};
}