diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem/ToolItem.tsx b/desktop/src/features/agents/ui/AgentSessionToolItem/ToolItem.tsx
index e0f2ce9fbc..85a484deb5 100644
--- a/desktop/src/features/agents/ui/AgentSessionToolItem/ToolItem.tsx
+++ b/desktop/src/features/agents/ui/AgentSessionToolItem/ToolItem.tsx
@@ -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 (
{
@@ -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"]');
diff --git a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx
index 9a5135c6f0..f32dfe9dba 100644
--- a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx
+++ b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx
@@ -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 (
- {block.segments.map((segment) => (
+ {segments.map((segment) => (
+ );
+ }
+
if (segment.kind === "prompt") {
return (
", {
+ url: "http://localhost",
+});
+
+class NoopObserver {
+ disconnect() {}
+ observe() {}
+ unobserve() {}
+}
+
+let prefersReducedMotion = false;
+
+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());
+
+const START = "2026-06-18T00:00:00.000Z";
+const SHARED = { channelId: "chan-1", sessionId: "sess-1", turnId: "turn-1" };
+
+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,
+ };
+}
+
+function thoughtStep(id) {
+ return {
+ ...SHARED,
+ id,
+ type: "thought",
+ renderClass: "thought",
+ title: "Thinking",
+ text: "weighing the options",
+ timestamp: START,
+ };
+}
+
+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.
+ */
+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.
+ */
+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.
+ */
+async function renderBlock(items, { 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) =>
+ createElement(
+ AgentSessionTranscriptTurnMetaProvider,
+ {
+ value: {
+ 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, streamingItemId });
+ applyState = setState;
+ return element(state.items, state.streamingItemId);
+ };
+ // 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.
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { 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.
+ stream: (nextItems, streaming = null) =>
+ applyState({ items: nextItems, 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"),
+ ),
+ q,
+ qa,
+ };
+}
+
+// ── 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");
+ prefersReducedMotion = 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.
+ *
+ * `` 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 `` 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 `` 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; would need the hook's guard",
+ );
+ assert.equal(
+ view.q("details"),
+ null,
+ "no 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.ok(
+ running.className.includes("animate-pulse"),
+ "work in flight pulses",
+ );
+ assert.ok(
+ !failed.className.includes("animate-pulse"),
+ "a settled failure is not in flight",
+ );
+});
+
+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"
+ );
+
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { 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-` 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: {
+ 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"]);
+});
diff --git a/desktop/src/features/agents/ui/AgentSessionWorkBlock.tsx b/desktop/src/features/agents/ui/AgentSessionWorkBlock.tsx
new file mode 100644
index 0000000000..af20f73f49
--- /dev/null
+++ b/desktop/src/features/agents/ui/AgentSessionWorkBlock.tsx
@@ -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.
+ *
+ * `` 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 ? {children}
: null;
+ }
+
+ return (
+
+ {open ? (
+
+ {children}
+
+ ) : null}
+
+ );
+}
+
+/**
+ * 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
+ * `` echo trap: `` 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 ``, 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(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.
+
+
+ {/* While live the rail IS the status: a header would only restate what
+ the arriving steps already show. */}
+ {isLive ? null : (
+
+ )}
+
+
+ {hiddenEntries.length > 0 ? (
+
+ ) : null}
+
+
+
+
+
+ );
+}
+
+function WorkBlockSummaryTrigger({
+ onToggle,
+ open,
+ status,
+}: {
+ onToggle: () => void;
+ open: boolean;
+ status: WorkBlockStatus;
+}) {
+ return (
+
+
+ {formatWorkBlockSummaryLabel(status)}
+
+
+
+ );
+}
+
+function PreviousStepsDisclosure({
+ agentAvatarUrl,
+ agentName,
+ agentPubkey,
+ entries,
+ motionEnabled,
+ profiles,
+}: AgentTranscriptIdentityProps & {
+ entries: WorkBlockEntry[];
+ motionEnabled: boolean;
+ profiles?: UserProfileLookup;
+}) {
+ const [open, setOpen] = React.useState(false);
+
+ return (
+
+ setOpen((previous) => !previous)}
+ type="button"
+ >
+
+ {formatPreviousStepsLabel(entries.length)}
+
+
+
+
+
+
+
+ );
+}
+
+function WorkBlockRail({
+ agentAvatarUrl,
+ agentName,
+ agentPubkey,
+ animateEnter,
+ entries,
+ profiles,
+}: AgentTranscriptIdentityProps & {
+ animateEnter: boolean;
+ entries: WorkBlockEntry[];
+ profiles?: UserProfileLookup;
+}) {
+ return (
+
+
+ {entries.map((entry, index) => (
+
+
+
+ ))}
+
+
+ );
+}
+
+function WorkBlockStepRow({
+ agentAvatarUrl,
+ agentName,
+ agentPubkey,
+ entry,
+ isLast,
+ profiles,
+}: AgentTranscriptIdentityProps & {
+ entry: WorkBlockEntry;
+ isLast: boolean;
+ profiles?: UserProfileLookup;
+}) {
+ return (
+
+
+
+
+ );
+}
+
+/**
+ * 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 (
+
+
+
+ );
+});
+
+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 (
+
+ );
+ case "note":
+ return (
+
+ );
+ case "tool":
+ return (
+
+ );
+ }
+}
+
+/**
+ * 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 (
+
+
+
+
+
+ );
+}
+
+/**
+ * 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 (
+
+ {isLast ? null : (
+
+ )}
+
+
+
+
+ );
+}
+
+/** 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 ;
+ 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.
+
+ ) : (
+
+ );
+ }
+}
diff --git a/desktop/src/features/agents/ui/activityRenderClasses/ThoughtActivity.tsx b/desktop/src/features/agents/ui/activityRenderClasses/ThoughtActivity.tsx
index b5701b5fe3..7c66e02750 100644
--- a/desktop/src/features/agents/ui/activityRenderClasses/ThoughtActivity.tsx
+++ b/desktop/src/features/agents/ui/activityRenderClasses/ThoughtActivity.tsx
@@ -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 ;
}
+/**
+ * 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;
}) {
- const variant = useAgentSessionTranscriptVariant();
-
- if (variant === "conversation") {
- return ;
- }
-
return (
);
}
-
-/**
- * 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;
-}) {
- const turnMeta = useAgentSessionTranscriptTurnMeta();
- const isStreaming = turnMeta.streamingItemId === item.id;
- const [userOpen, setUserOpen] = React.useState(null);
- const isOpen = userOpen ?? isStreaming;
- const label = formatThoughtDisclosureLabel({
- durationSeconds: turnMeta.thoughtDurationSecondsById[item.id],
- isStreaming,
- });
-
- // `` 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) => {
- const open = event.currentTarget.open;
- if (open === renderedOpenRef.current) return;
- setUserOpen(open);
- },
- [],
- );
-
- return (
-
-
-
- {isStreaming ? {label} : {label} }
-
-
-
-
-
-
- );
-}
diff --git a/desktop/src/features/agents/ui/agentSessionConversationMeta.test.mjs b/desktop/src/features/agents/ui/agentSessionConversationMeta.test.mjs
index 9eb09c8558..477ed54ced 100644
--- a/desktop/src/features/agents/ui/agentSessionConversationMeta.test.mjs
+++ b/desktop/src/features/agents/ui/agentSessionConversationMeta.test.mjs
@@ -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 ----
diff --git a/desktop/src/features/agents/ui/agentSessionConversationMeta.ts b/desktop/src/features/agents/ui/agentSessionConversationMeta.ts
index d1e33b5825..cf5fa5301d 100644
--- a/desktop/src/features/agents/ui/agentSessionConversationMeta.ts
+++ b/desktop/src/features/agents/ui/agentSessionConversationMeta.ts
@@ -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 = {};
- 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 };
}
diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptContext.ts b/desktop/src/features/agents/ui/agentSessionTranscriptContext.ts
index 1384504cc9..bc085f7b74 100644
--- a/desktop/src/features/agents/ui/agentSessionTranscriptContext.ts
+++ b/desktop/src/features/agents/ui/agentSessionTranscriptContext.ts
@@ -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;
};
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(
EMPTY_TRANSCRIPT_TURN_META,
diff --git a/desktop/src/features/agents/ui/agentSessionWorkBlockGrouping.test.mjs b/desktop/src/features/agents/ui/agentSessionWorkBlockGrouping.test.mjs
new file mode 100644
index 0000000000..ce5f9bf7ad
--- /dev/null
+++ b/desktop/src/features/agents/ui/agentSessionWorkBlockGrouping.test.mjs
@@ -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: }` 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);
+});
diff --git a/desktop/src/features/agents/ui/agentSessionWorkBlockGrouping.ts b/desktop/src/features/agents/ui/agentSessionWorkBlockGrouping.ts
new file mode 100644
index 0000000000..893ed34c2a
--- /dev/null
+++ b/desktop/src/features/agents/ui/agentSessionWorkBlockGrouping.ts
@@ -0,0 +1,398 @@
+import type {
+ TranscriptTurnSegment,
+ TranscriptDisplayBlock,
+} from "./agentSessionTranscriptGrouping";
+import type { TranscriptItem } from "./agentSessionTypes";
+
+/** Reasoning. */
+type WorkBlockThoughtItem = Extract;
+/**
+ * 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 & {
+ role: "assistant";
+};
+/** A step the agent ran. */
+type WorkBlockToolItem = Extract;
+
+/**
+ * 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,
+): 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),
+ };
+}