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

Focus mode gave every thought and tool step its own row and its own
disclosure, so a turn that did real work scrolled its answer off screen
behind a stack of chrome. This adopts berd's shipping transcript model:
within a turn, everything between the prompt and the answer that is
thinking, a tool step, or an interim agent note becomes one "work block"
— a thin rail of small glyph bullets that folds to a single "N steps"
line when the work is done.

Grouping is variant-aware AT THE LIST BOUNDARY rather than inside the
grouping module. `TranscriptDisplayBlockView` runs the additive
work-block transform only for `conversation`, so `default` and
`compactPreview` walk main's segments on the identical code path they
always did. That makes Slice B's byte-for-byte baseline fixture hold BY
CONSTRUCTION rather than by assertion, which is the property worth having
here: no future edit to the block can regress the other two variants
without first moving this branch.

Behaviour, mirroring berd:

- Live: the block is open and the rail IS the status, so there is no
  header line to restate it. The last three steps show in true arrival
  order; older ones go behind an "N previous steps" disclosure at the top
  of the rail, so a long run cannot push the answer out of view.
- Finished: folds to "N steps" with a chevron that rotates on open.
- The fold ANIMATES. `<details>` cannot do this — its content is either
  laid out or not, with no intermediate height — so the collapse would
  snap. A block that was live when it mounted renders open for a paint
  and then settles closed, giving the height tween a start state that was
  actually painted; a block already finished on mount (scrollback) never
  had a rail on screen and closes immediately, so the animation stays
  meaningful instead of firing on every mount.
- Reader choice wins: once the reader toggles a block, policy stops
  opening and closing it.

One deliberate departure from berd, per core-02: a finished block holding
a failure folds to "N steps · 1 failed". A bare count is the one thing a
reader cannot distinguish a clean run from a broken one by, and a fold
that hides a failure behind a neutral number invites them not to open it.
The rail bullet itself stays muted, per berd — failure is a glyph shape,
not a colour, so one bad step does not read as an alarm across the run.

Items are projected ONCE into rail entries, and the glyph, the body and
the folded line's counts all read that projection. Two independent
classifications of the same item is precisely how the headline and the
chain eligibility drifted apart on the abandoned tool-chain card, so both
render sites are exhaustive switches over the entry and neither asks
`item.type` again.

The projection is closed in the TYPE SYSTEM, not only by convention. An
earlier revision made the entry a product of independent fields
(`{ item, kind, state }`) with a catch-all `return "tool"`, which left two
things wrong that no test could see: a future `TranscriptItem` variant
would silently wear a wrench, and impossible pairs like
`{ kind: "note", item: <thought> }` stayed representable — so the body
switch still had to re-check `item.type` and render `""` on a mismatch it
could not otherwise handle. Meaning was therefore still derived in two
places.

Now:

- `WorkBlockItem` is the closed union of items a block admits, and
  `isWorkItem` is a type guard, so the membership decision is made once
  and every later stage receives the narrowed type. `admittedWorkItems`
  returns the narrowed array rather than a boolean because an `.every()`
  guard cannot narrow the array it tested.
- `WorkBlockEntry` is a discriminated union pairing each kind with its own
  item type, and fixing `state: "settled"` on the prose kinds. Both classes
  of impossible entry are now unrepresentable rather than defended against.
- `projectWorkBlockEntry` switches exhaustively over `WorkBlockItem` with
  no default, so admitting a new item type without deciding how it renders
  is a compile error (`TS2366: Function lacks ending return statement`),
  not a wrench.
- The body switch takes the whole entry, so narrowing on `kind` narrows
  `item` too. The `item.type === "thought" ? item.text : ""` fallbacks are
  gone because there is no longer a mismatch to fall back from.

Verified by compiling three mutants, each of which now fails `tsc` where
before it type-checked: admitting `plan` to `WorkBlockItem` without a
projection case (TS2366), projecting a thought as a note (TS2322 on
`item`), and giving a thought `state: "failed"` (TS2322 on `state`). The
runtime kind/item pairing is also asserted in
`agentSessionWorkBlockGrouping.test.mjs`, because types are stripped at
runtime and swapping the two prose branches by hand is the easy mistake —
that mutant fails the test.

The projection also fixes an ordering bug the old split invited: a tool
carrying a stale `isError` from a retry while the new attempt executes
reads as `running`, not `failed`, so a live block cannot fold its own
count to "N steps · 1 failed" while the work is still in flight.

**Interim notes suppress the identity row.** #6720 gives every
conversation-variant assistant message a 20px avatar + name row, which is
right for the turn's answer. A rail note is the same item type, so
routing it through that presenter would render a fully attributed agent
turn nested inside a muted step row — the agent apparently replying twice,
once inside the work it was doing. Notes render through a dedicated rail
prose body instead, keeping markdown and the focus code-block recipe by
providing the same `CodeBlockVariantContext` value the presenter would.
Done on this side rather than by reaching into #6720, so that PR keeps one
rule for what a message looks like. Notes share the thought's speech
bubble, matching berd's `progress` entry: both are the agent talking.

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

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

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

Three further notes on translation rather than transcription:

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

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

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

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

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

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

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

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

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

Co-authored-by: Bradley Axen <baxen@squareup.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>
This commit is contained in:
ss-dev-02
2026-08-24 12:36:05 -07:00
parent fd2e017998
commit ac794843a6
11 changed files with 2732 additions and 504 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
@@ -21,20 +24,16 @@ import assert from "node:assert/strict";
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 +146,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"]');

View File

@@ -34,6 +34,11 @@ import {
getTranscriptMessageLink,
} from "./AgentSessionTranscriptChrome";
import { buildConversationTurnMeta } from "./agentSessionConversationMeta";
import { AgentSessionWorkBlockSegment } from "./AgentSessionWorkBlock";
import {
conversationSegmentsForBlock,
type TranscriptConversationSegment,
} from "./agentSessionWorkBlockGrouping";
import { useTranscriptAnimationEnabled } from "./transcriptAnimationPreference";
import { useTranscriptTimestampsEnabled } from "./transcriptTimestampPreference";
import { TranscriptActivityItem } from "./activityRenderClasses/TranscriptActivityItem";
@@ -378,6 +383,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 +401,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 +425,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 +454,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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,631 @@
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 { 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, for as long as the block stays mounted.
*
* This is deliberately local and 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) {
const [readerChoice, setReaderChoice] = React.useState<boolean | null>(null);
const open = readerChoice ?? policyOpen;
return {
chosenByReader: readerChoice !== null,
open,
toggle: React.useCallback(() => setReaderChoice(!open), [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),
[block.items],
);
const status = React.useMemo(
() =>
summarizeWorkBlock(entries, {
streamingItemId: turnMeta.streamingItemId,
}),
[entries, turnMeta.streamingItemId],
);
const motionEnabled = useWorkBlockMotionEnabled();
const settling = useSettleOnFinish(status.isActive, motionEnabled);
const disclosure = useWorkBlockDisclosure(status.isActive || settling);
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",
entry.state === "running" && "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

@@ -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,10 +1,7 @@
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 {
formatPlanChecklistProgress,
@@ -24,6 +21,46 @@ function turnBlock(segments) {
return { kind: "turn", turnId: "turn-1", segments };
}
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", () => {
@@ -39,77 +76,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(
buildConversationTurnMeta(
[
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(
buildConversationTurnMeta(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", () => {
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 = buildConversationTurnMeta(
[
turnBlock([
thoughtSegment("thought:1", "2026-06-14T19:00:01.000Z"),
{
kind: "setup",
items: [
@@ -123,121 +128,77 @@ 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 = buildConversationTurnMeta(
[
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", () => {
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 = 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: "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", () => {
assert.equal(
formatThoughtDisclosureLabel({ durationSeconds: 4, isStreaming: true }),
"Thinking…",
test("buildConversationTurnMeta reads a trailing single block directly", () => {
const meta = buildConversationTurnMeta(
[
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" },
);
});
test("formatThoughtDisclosureLabel reports a duration once the turn moved on", () => {
assert.equal(
formatThoughtDisclosureLabel({ durationSeconds: 9, isStreaming: false }),
"Thought for 9s",
);
});
test("formatThoughtDisclosureLabel avoids a misleading 0s", () => {
assert.equal(
formatThoughtDisclosureLabel({ durationSeconds: 0, isStreaming: false }),
"Thought for a moment",
);
assert.equal(formatThoughtDisclosureLabel({ isStreaming: false }), "Thought");
assert.equal(meta.streamingItemId, "life:orphan");
});
// ---- 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,33 +28,14 @@ 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 streaming hint from the display blocks.
*
* Two facts the individual render classes cannot see on their own:
*
* - **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.
* Only the trailing leaf item of the final turn can be mid-stream, and only
* when the agent's turn is actually live. The work 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.
*
* Returns the shared empty value for non-conversation variants so the default
* and compactPreview paths allocate nothing and stay byte-identical.
@@ -57,64 +44,18 @@ export function buildConversationTurnMeta(
displayBlocks: TranscriptDisplayBlock[],
options: { isTurnLive: boolean; 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;
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;
}
}
const lastBlock = displayBlocks[displayBlocks.length - 1];
if (lastBlock?.kind === "single") {
return { streamingItemId: lastBlock.item.id };
}
if (lastBlock?.kind !== "turn") {
return EMPTY_TRANSCRIPT_TURN_META;
}
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 };
}
/**
* Disclosure label for a thought in the `conversation` variant.
*
* 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".
*/
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`;
const items = lastBlock.segments.flatMap(turnSegmentItems);
return { streamingItemId: items[items.length - 1]?.id ?? null };
}

View File

@@ -28,22 +28,49 @@ 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>;
};
export const EMPTY_TRANSCRIPT_TURN_META: AgentSessionTranscriptTurnMeta = {
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,462 @@
/**
* 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" };
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 = projectWorkBlockEntries([
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) => projectWorkBlockEntries([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(
projectWorkBlockEntries([
tool("t", { status: "executing", isError: true }),
])[0].state,
"running",
);
});
// ---- status ----
test("summarizeWorkBlock counts steps and failures", () => {
const status = summarizeWorkBlock(
projectWorkBlockEntries([
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(
projectWorkBlockEntries([
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 = projectWorkBlockEntries([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 = projectWorkBlockEntries(
["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 = projectWorkBlockEntries(
["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 = projectWorkBlockEntries(
["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,398 @@
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.
*/
function toolEntryState(item: WorkBlockToolItem): WorkBlockEntryState {
if (item.status === "executing" || item.status === "pending") {
return "running";
}
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): 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) };
}
}
/** Project a block's items into rail entries, in true arrival order. */
export function projectWorkBlockEntries(
items: WorkBlockItem[],
): WorkBlockEntry[] {
return items.map(projectWorkBlockEntry);
}
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),
};
}