preview: merge tool-chain cards (Slice C, carries B + restyle) onto main

* commit '91a5754ad':
  fix(desktop): keep a reader's work block open when a later answer regroups it
  test(desktop): pin the plain next-turn gap and stop the 5-minute suite hang
  fix(desktop): guard the work-block pulse and stop a finished block going live
  test(desktop): split the work-block suite and prune the conversation harness
  fix(desktop): settle a work block whose running step has no live session
  feat(desktop): fold focus-mode agent work into one transcript block
  fix(desktop): hide the decorative identity avatar and split the conversation suite
  fix(desktop): resolve the focus identity row agent through the profiles lookup
  fix(desktop): apply the focus code recipe to fenced human prompts too
  style(desktop): bring the conversation variant closer to berd's recipes
  fix(desktop): never show a trigger title as the prompt author
  fix(desktop): pin the transcript baseline fixture's locale too
  fix(desktop): pin the transcript baseline fixture to UTC
  fix(desktop): address review on the conversation transcript variant
  feat(desktop): add conversation transcript variant for focus-mode agent sessions

Signed-off-by: ss-dev-00 <a02c4e0850e5e612b4ddf95dbe2f5c56467cf27c6552203bc833ff438fb31971@buzz.block.builderlab.xyz>
This commit is contained in:
ss-dev-00
2026-08-25 14:17:46 -07:00
28 changed files with 6308 additions and 373 deletions

View File

@@ -256,6 +256,24 @@ scripts build the required E2E bridge before running Playwright.
See [TESTING.md](TESTING.md) for the full multi-agent E2E guide.
### Never assert a DOM element against `null` with `assert.equal`
In the desktop jsdom tests (`desktop/src/**/*.test.mjs`), write
```js
assert.ok(container.querySelector(sel) === null, "no header row");
```
**not** `assert.equal(container.querySelector(sel), null, ...)`. Both pass
identically, but when the `assert.equal` form *fails*, node serializes the
matched element and its entire subtree to build a diff. On a real transcript
that exhausts memory and the runner dies with SIGKILL after ~100s instead of
printing the assertion message — so a genuine regression is unreadable and
looks like a hang or an OOM in unrelated code. The `assert.ok(x === null)` form
fails in milliseconds with the message you wrote.
Comparing `getAttribute(...)` to `null` is fine — attributes are strings.
### PR Screenshots
> **Do NOT use `buzz upload`, the relay media endpoint, or any third-party

View File

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

View File

@@ -0,0 +1,358 @@
import * as React from "react";
import { CheckCheck, Clock, Radio } from "lucide-react";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
import { Toggle } from "@/shared/ui/toggle";
import type { PromptSection, TranscriptItem } from "./agentSessionTypes";
import { PromptSectionList as PromptContextSections } from "./PromptSectionAccordion";
import { useAgentSessionTranscriptVariant } from "./agentSessionTranscriptContext";
import {
formatTurnSetupLabel,
turnSetupDetail,
turnSetupTimestamp,
} from "./agentSessionTranscriptGrouping";
import { formatTranscriptTimestampTitle } from "./agentSessionUtils";
import { ConversationDivider } from "./activityRenderClasses/ConversationDivider";
import { TranscriptTimestamp } from "./activityRenderClasses/TranscriptTimestamp";
import { UserMessageBubble } from "./activityRenderClasses/UserMessageBubble";
const TRANSCRIPT_ACP_SOURCE_STORAGE_KEY = "buzz:show-transcript-acp-source";
/**
* Opt-in only: source pills are useful while iterating on observer parsing, but
* they should not appear for every local dev session.
*/
export const SHOW_TRANSCRIPT_ACP_SOURCE = shouldShowTranscriptAcpSource();
function shouldShowTranscriptAcpSource() {
// `import.meta.env` is bundler-provided; guard it so this module stays
// importable from node-based render tests (same pattern as
// features/onboarding/devFreshOnboarding.ts).
const envValue = import.meta.env?.VITE_SHOW_TRANSCRIPT_ACP_SOURCE;
if (envValue === "1" || envValue === "true") {
return true;
}
if (typeof window === "undefined") {
return false;
}
try {
return (
window.localStorage.getItem(TRANSCRIPT_ACP_SOURCE_STORAGE_KEY) === "1"
);
} catch {
return false;
}
}
export function TranscriptAcpSourceBadge({ source }: { source: string }) {
return (
<span
className="mb-1 inline-flex max-w-full rounded border border-amber-500/25 bg-amber-500/10 px-1.5 py-0.5 font-mono text-xs leading-none text-amber-800 dark:text-amber-200"
data-testid="transcript-acp-source"
title={`ACP wire source: ${source}`}
>
{source}
</span>
);
}
export function TurnPromptBlock({
context,
profiles,
setup,
user,
}: {
context: Extract<TranscriptItem, { type: "metadata" }> | null;
profiles?: UserProfileLookup;
setup: Extract<TranscriptItem, { type: "lifecycle" }>[];
user: Extract<TranscriptItem, { type: "message" }>;
}) {
return (
<div data-testid="transcript-prompt-bundle">
{SHOW_TRANSCRIPT_ACP_SOURCE ? (
<div className="mb-1 flex flex-wrap gap-1">
<TranscriptAcpSourceBadge source="session/prompt:user" />
{context ? (
<TranscriptAcpSourceBadge source="session/prompt:context" />
) : null}
</div>
) : null}
<PromptUserMessage
context={context}
item={user}
profiles={profiles}
setup={setup}
/>
</div>
);
}
function PromptUserMessage({
context = null,
item,
profiles,
setup = [],
}: {
context?: Extract<TranscriptItem, { type: "metadata" }> | null;
item: Extract<TranscriptItem, { type: "message" }>;
profiles?: UserProfileLookup;
setup?: Extract<TranscriptItem, { type: "lifecycle" }>[];
}) {
const variant = useAgentSessionTranscriptVariant();
const [contextOpen, setContextOpen] = React.useState(false);
const contextSections = React.useMemo(
() => [...(context?.sections ?? [])],
[context],
);
return (
<>
<UserMessageBubble
bubbleClassName={variant === "conversation" ? undefined : "p-2.5"}
footer={
<TurnSetupFooter
contextOpen={contextOpen}
hasContext={contextSections.length > 0}
items={setup}
messageLink={getTranscriptMessageLink(item)}
onContextOpenChange={setContextOpen}
timestamp={item.timestamp}
/>
}
item={item}
profiles={profiles}
/>
<PromptContextDialog
onOpenChange={setContextOpen}
open={contextOpen}
sections={contextSections}
setup={setup}
/>
</>
);
}
function PromptContextDialog({
onOpenChange,
open,
sections,
setup,
}: {
onOpenChange: (open: boolean) => void;
open: boolean;
sections: PromptSection[];
setup: Extract<TranscriptItem, { type: "lifecycle" }>[];
}) {
if (!open || sections.length === 0) {
return null;
}
const setupText = formatPromptSetupSummary(setup);
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="max-w-xl overflow-hidden p-0">
<div className="flex min-w-0 max-h-[85vh] flex-col">
<DialogHeader className="px-6 pb-3 pt-5 pr-14">
<DialogTitle>Prompt context</DialogTitle>
{setupText ? (
<div className="flex items-center gap-1.5">
<CheckCheck className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<DialogDescription>{setupText}</DialogDescription>
</div>
) : null}
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto px-6 pb-6 pt-2">
<PromptContextSections sections={sections} />
</div>
</div>
</DialogContent>
</Dialog>
);
}
function formatPromptSetupSummary(
items: Extract<TranscriptItem, { type: "lifecycle" }>[],
) {
const label = formatTurnSetupLabel(items);
const detail = turnSetupDetail(items);
return [label, detail].filter(Boolean).join(" · ");
}
function TurnSetupFooter({
contextOpen = false,
hasContext = false,
items,
messageLink = null,
onContextOpenChange,
showTimestamp = true,
timestamp,
}: {
contextOpen?: boolean;
hasContext?: boolean;
items: Extract<TranscriptItem, { type: "lifecycle" }>[];
messageLink?: { channelId: string; messageId: string } | null;
onContextOpenChange?: (open: boolean) => void;
showTimestamp?: boolean;
timestamp: string;
}) {
const label = formatTurnSetupLabel(items);
const detail = turnSetupDetail(items);
const tooltipText = [label, detail].filter(Boolean).join(" · ");
const showSetup = items.length > 0;
const showContext = hasContext && onContextOpenChange != null;
if (!showSetup && !showContext) {
return showTimestamp ? (
<TranscriptTimestamp messageLink={messageLink} timestamp={timestamp} />
) : null;
}
return (
<div
className="flex items-center gap-1.5 text-muted-foreground/80"
data-testid="transcript-turn-setup"
>
{showContext ? (
<Toggle
aria-label={`${contextOpen ? "Hide" : "Show"} prompt context`}
className="data-[state=on]:bg-primary/10 data-[state=on]:text-primary dark:data-[state=on]:bg-primary/15"
data-testid="transcript-prompt-context-toggle"
onPressedChange={onContextOpenChange}
pressed={contextOpen}
size="xs"
title={tooltipText || "Show prompt context"}
variant="ghost"
>
<CheckCheck aria-hidden="true" />
</Toggle>
) : (
<span className="inline-flex shrink-0 items-center justify-center rounded-sm text-muted-foreground/70">
<CheckCheck className="h-3.5 w-3.5" />
<span className="sr-only">{tooltipText}</span>
</span>
)}
{showTimestamp ? (
<TranscriptTimestamp messageLink={messageLink} timestamp={timestamp} />
) : null}
</div>
);
}
export function getTranscriptMessageLink(
item: Extract<TranscriptItem, { type: "message" }>,
) {
if (!item.channelId || !item.messageId) return null;
return {
channelId: item.channelId,
messageId: item.messageId,
};
}
export function TurnSetupStatus({
items,
}: {
items: Extract<TranscriptItem, { type: "lifecycle" }>[];
}) {
const variant = useAgentSessionTranscriptVariant();
const timestamp = turnSetupTimestamp(items);
if (items.length === 0 || !timestamp) {
return null;
}
// Focus mode recedes turn setup to a quiet centered divider: the checks-icon
// summary is ingress plumbing, not something a reader judges the turn by.
if (variant === "conversation") {
return (
<ConversationDivider
label={formatPromptSetupSummary(items) || formatTurnSetupLabel(items)}
testId="transcript-turn-setup"
title={formatTranscriptTimestampTitle(timestamp)}
/>
);
}
return (
<div
className="rounded-md px-2"
title={formatTranscriptTimestampTitle(timestamp)}
>
<TurnSetupFooter
items={items}
showTimestamp={false}
timestamp={timestamp}
/>
</div>
);
}
/**
* Horizontal rule rendered between session runs in the observer transcript.
*
* Three label states (based on live-frame observation, not harness affinity):
* - `"current"` — most recent session observed via the live relay subscription.
* - `"most-recent"` — newest visible session with no matching live frames
* (loaded from archive or session ended before observation).
* - `"earlier"` — an older session preceding the most-recent one.
*/
export function SessionBoundaryDivider({
labelState,
sessionStartTimestamp,
}: {
labelState: "current" | "most-recent" | "earlier";
sessionStartTimestamp: string;
}) {
const variant = useAgentSessionTranscriptVariant();
const label =
labelState === "current"
? "Latest live-observed session"
: labelState === "most-recent"
? "Most recent observed session"
: "Earlier observed session";
const formattedDate = new Date(sessionStartTimestamp).toLocaleString();
if (variant === "conversation") {
return (
<ConversationDivider
icon={
labelState === "current" ? (
<Radio aria-hidden="true" className="h-3 w-3" />
) : (
<Clock aria-hidden="true" className="h-3 w-3" />
)
}
label={`${label} · ${formattedDate}`}
testId="session-boundary-divider"
/>
);
}
return (
<div
className="flex items-center gap-2 px-3 py-2"
data-testid="session-boundary-divider"
>
<div className="h-px flex-1 bg-border" />
<span className="flex items-center gap-1 text-xs text-muted-foreground">
{labelState === "current" ? (
<Radio aria-hidden="true" className="h-3 w-3" />
) : (
<Clock aria-hidden="true" className="h-3 w-3" />
)}
{label}
{" · "}
{formattedDate}
</span>
<div className="h-px flex-1 bg-border" />
</div>
);
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,506 @@
/**
* Rendering contract for the `conversation` transcript variant (focus mode):
* layout, prompt authorship, plans, lifecycle chrome, and the byte-for-byte
* guarantee for the other variants.
*
* Mounts the shipping AgentSessionTranscriptList so the variant plumbing
* (variant context + derived turn meta) is exercised end to end rather than
* asserting against re-implemented render classes.
*
* Reasoning is no longer a per-thought disclosure on this path — thoughts,
* tool steps and interim notes fold into one work block, whose contract lives in
* `AgentSessionWorkBlock.test.mjs`. The identity row and code-block chrome live
* in `AgentSessionTranscriptList.conversationChrome.test.mjs`; shared jsdom
* setup, ambient-formatting pins, and render helpers live in the harness they
* all import.
*
* The byte-for-byte tests at the bottom are the important ones: `conversation`
* is purely additive, so `default` and `compactPreview` markup for the same
* transcript must be byte-identical to the markup captured before the variant
* existed. See the harness for how that fixture was produced.
*/
import assert from "node:assert/strict";
import { test } from "node:test";
import {
act,
AUTHOR,
AUTHOR_PROFILES,
AUTHOR_TRUNCATED,
BASELINE_MARKUP,
baselineItems,
cleanup,
FIXTURE_LOCALE,
items,
renderRerenderableTranscript,
renderTranscript,
} from "./AgentSessionTranscriptList.conversationHarness.mjs";
test("conversation marks the transcript container and centers a reading column", async () => {
const { container } = await renderTranscript("conversation");
const log = container.querySelector("[data-transcript-variant]");
assert.ok(log, "conversation should tag its container for variant styling");
assert.equal(log.getAttribute("data-transcript-variant"), "conversation");
assert.match(log.className, /max-w-3xl/);
assert.match(log.className, /gap-8/);
});
test("conversation renders the prompt as a filled right-aligned bubble with an author label", async () => {
const { container } = await renderTranscript("conversation", {
profiles: AUTHOR_PROFILES,
});
const author = container.querySelector(
'[data-testid="transcript-user-message-author"]',
);
assert.ok(author, "conversation should label the prompt author");
assert.equal(author.textContent, "Ada Lovelace");
const row = container.querySelector(
'[data-testid="transcript-user-message"]',
);
assert.match(row.className, /justify-end/);
// berd's user-turn recipe: soft tint, no border, `px-4 py-2`, and a 12px
// radius (berd's `rounded-sm` on its own scale = Buzz's `rounded-xl`).
const bubble = row.querySelector(".rounded-xl");
assert.ok(bubble, "the prompt bubble should take berd's 12px radius");
assert.match(bubble.className, /bg-muted\/60/);
assert.match(bubble.className, /px-4/);
assert.match(bubble.className, /py-2(?!\.)/);
assert.match(
bubble.className,
/border-0/,
"berd never draws a border on the user turn",
);
assert.doesNotMatch(
bubble.className,
/rounded-2xl/,
"the old 16px pill radius should be gone",
);
// Focus mode shows the whole prompt rather than clamping it.
assert.doesNotMatch(bubble.className, /max-h-36/);
});
test("conversation caps the prompt bubble at a fixed measure, not a percentage", async () => {
// berd caps the user turn with `--chat-user-message-max-width: 640px`. A
// percentage cap re-wraps the prompt every time the cover view is resized;
// a fixed measure holds one stable line length, which is the point of the
// recipe. Guards against a silent revert to `max-w-[85%]`.
const { container } = await renderTranscript("conversation");
const column = container.querySelector(
'[data-testid="transcript-user-message-author"]',
).parentElement;
assert.match(column.className, /max-w-prompt-bubble/);
assert.doesNotMatch(column.className, /max-w-\[\d+%\]/);
});
test("conversation never shows the trigger title as the prompt author when the sender is unresolved", async () => {
// Regression guard. The label chain's last fallback used to be the prompt
// item's `title`, which is a description of the trigger ("@Mention",
// "Prompt", "Buzz event") rather than an identity. On the other variants that
// string only seeds avatar initials, so it was harmless; the conversation
// author row displays it, so an unresolved sender was rendered as "@Mention"
// — a trigger presented as a person's name. With no profile for the author,
// the row must fall back to the truncated pubkey instead.
const { container } = await renderTranscript("conversation");
const author = container.querySelector(
'[data-testid="transcript-user-message-author"]',
);
assert.ok(author, "the author row should still render without a profile");
assert.equal(author.textContent, AUTHOR_TRUNCATED);
assert.doesNotMatch(
author.textContent,
/@Mention|Prompt|Buzz event/,
"the trigger title must never be displayed as an author name",
);
});
test("conversation prefers the resolved profile over both the trigger title and the pubkey", async () => {
// The truncated-pubkey fallback must not shadow a real identity: with a
// profile present the row shows the display name, and with only a NIP-05
// handle it shows that.
const { container } = await renderTranscript("conversation", {
profiles: {
[AUTHOR]: {
displayName: null,
avatarUrl: null,
nip05Handle: "ada@example.com",
ownerPubkey: null,
},
},
});
assert.equal(
container.querySelector('[data-testid="transcript-user-message-author"]')
.textContent,
"ada@example.com",
);
});
test("conversation renders agent messages as unboxed prose at full fidelity", async () => {
const { container } = await renderTranscript("conversation");
const message = container.querySelector(
'[data-testid="transcript-assistant-message"]',
);
assert.ok(message, "assistant message should render");
assert.doesNotMatch(message.innerHTML, /rounded-2xl/);
// The shared markdown renderer is reused, so inline code still renders as code.
assert.ok(message.querySelector("code"), "markdown/code fidelity preserved");
});
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"]');
assert.ok(card, "plan should render");
assert.equal(card.getAttribute("data-variant"), "conversation-plan-card");
assert.equal(
card.querySelector('[data-testid="transcript-plan-progress"]').textContent,
"1/3 complete",
);
const statuses = [...card.querySelectorAll("[data-plan-entry-status]")].map(
(entry) => entry.getAttribute("data-plan-entry-status"),
);
assert.deepEqual(statuses, ["completed", "in_progress", "pending"]);
});
test("conversation updates the same plan card in place as entries advance", async () => {
// The transcript reducer mutates the plan item in place (same item id), so the
// card must be re-rendered rather than replaced: progress copy changes while
// the same DOM node is retained. A new node here would mean the reader's
// scroll position and any card-level state get thrown away on every update.
const { container, setOverrides } =
await renderRerenderableTranscript("conversation");
const before = container.querySelector(
'[data-testid="transcript-plan-item"]',
);
assert.equal(
before.querySelector('[data-testid="transcript-plan-progress"]')
.textContent,
"1/3 complete",
);
const advanced = items().map((item) =>
item.id === "plan:1"
? {
...item,
text: "- [x] read the transcript\n- [x] write the summary\n- [ ] ship it (in progress)",
}
: item,
);
await setOverrides({ items: advanced });
const after = container.querySelector('[data-testid="transcript-plan-item"]');
assert.equal(after, before, "the same plan card node should be reused");
assert.equal(
after.querySelector('[data-testid="transcript-plan-progress"]').textContent,
"2/3 complete",
);
assert.deepEqual(
[...after.querySelectorAll("[data-plan-entry-status]")].map((entry) =>
entry.getAttribute("data-plan-entry-status"),
),
["completed", "completed", "in_progress"],
);
});
test("conversation quiets session boundaries and status rows into centered dividers", async () => {
const shared = { channelId: "chan-1", sessionId: "sess-2", turnId: "turn-2" };
const { container } = await renderTranscript("conversation", {
items: [
...items(),
{
...shared,
id: "msg:user2",
type: "message",
renderClass: "message",
role: "user",
title: "Ada",
text: "next task",
timestamp: "2026-06-14T19:05:01.000Z",
messageId: "event-2",
authorPubkey: AUTHOR,
},
{
...shared,
id: "life:status",
type: "lifecycle",
renderClass: "status",
title: "Context compacted",
text: "",
timestamp: "2026-06-14T19:05:02.000Z",
},
],
});
// A second session id draws the boundary rule between the two runs.
const boundary = container.querySelector(
'[data-testid="session-boundary-divider"]',
);
assert.ok(boundary, "a second session should draw a boundary");
assert.equal(boundary.getAttribute("data-variant"), "conversation-divider");
const status = container.querySelector(
'[data-testid="transcript-lifecycle-item"]',
);
assert.ok(status, "status lifecycle row should render");
assert.equal(status.getAttribute("data-variant"), "conversation-divider");
assert.match(status.textContent, /Context compacted/);
});
test("conversation keeps errors and permission gates loud", async () => {
const shared = { channelId: "chan-1", sessionId: "sess-1", turnId: "turn-1" };
const { container } = await renderTranscript("conversation", {
items: [
...items(),
{
...shared,
id: "life:permission",
type: "lifecycle",
renderClass: "permission",
title: "Permission requested",
text: "write src/main.rs\nOptions: Allow, Deny",
outcome: "Approved (once)",
timestamp: "2026-06-14T19:00:11.000Z",
},
{
...shared,
id: "life:error",
type: "lifecycle",
renderClass: "error",
title: "Turn failed",
text: "the harness exited",
timestamp: "2026-06-14T19:00:12.000Z",
},
],
});
const permission = container.querySelector(
'[data-testid="transcript-permission-item"]',
);
assert.ok(permission, "permission item should render");
assert.match(permission.className, /amber/);
assert.equal(
permission.getAttribute("data-variant"),
null,
"permission gates never route through the quiet divider",
);
assert.equal(
permission
.querySelector('[data-testid="transcript-permission-outcome"]')
.textContent.trim(),
"Approved (once)",
);
// The error shares the lifecycle testid, so pick it out by its copy.
const error = [
...container.querySelectorAll('[data-testid="transcript-lifecycle-item"]'),
].find((node) => node.textContent.includes("Turn failed"));
assert.ok(error, "error lifecycle item should still render");
assert.match(error.className, /destructive/);
assert.equal(
error.getAttribute("data-variant"),
null,
"errors never route through the quiet divider",
);
});
test("default and compactPreview markup is unchanged by the conversation variant", async () => {
for (const variant of ["default", "compactPreview"]) {
const { container } = await renderTranscript(variant, {
items: baselineItems(),
});
assert.equal(
container.innerHTML,
BASELINE_MARKUP[variant],
`${variant} transcript markup drifted`,
);
cleanup();
}
});
test("the byte-for-byte baseline actually exercises every renderable item kind", async () => {
// The fixture embeds formatted dates and times, so a zone or locale other than
// the one it was captured in fails the comparison above for a reason that has
// nothing to do with markup. Fail loudly and specifically instead.
assert.equal(
new Intl.DateTimeFormat().resolvedOptions().timeZone,
"UTC",
"the baseline fixture is captured in UTC — see the TZ pin at the top of this file",
);
assert.equal(
new Intl.DateTimeFormat().resolvedOptions().locale,
FIXTURE_LOCALE,
`the baseline fixture is captured in ${FIXTURE_LOCALE} — see the locale pin at the top of this file`,
);
assert.equal(
new Date("2026-06-14T19:05:00.000Z").toLocaleString(),
"6/14/2026, 7:05:00 PM",
"Date.prototype.toLocaleString must be locale-pinned too — the session-boundary divider uses it",
);
// Guards the fixture itself: a baseline that silently stopped covering a kind
// would keep passing the comparison above while protecting nothing. Asserted
// against `default`, which renders every kind.
const { container } = await renderTranscript("default", {
items: baselineItems(),
});
const present = (selector) => container.querySelector(selector) !== null;
for (const [kind, selector] of [
["prompt", '[data-testid="transcript-user-message"]'],
["prompt setup", '[data-testid="transcript-turn-setup"]'],
["assistant message", '[data-testid="transcript-assistant-message"]'],
["thought", '[data-testid="transcript-thought-item"]'],
["plan", '[data-testid="transcript-plan-item"]'],
["tool", '[data-testid="transcript-tool-item"]'],
["permission", '[data-testid="transcript-permission-item"]'],
["lifecycle status/error", '[data-testid="transcript-lifecycle-item"]'],
["session boundary", '[data-testid="session-boundary-divider"]'],
]) {
assert.ok(present(selector), `baseline must cover ${kind}`);
}
// Status and error share the lifecycle testid, so check both are really there.
const lifecycleText = [
...container.querySelectorAll('[data-testid="transcript-lifecycle-item"]'),
]
.map((node) => node.textContent)
.join("\n");
assert.match(lifecycleText, /Context compacted/);
assert.match(lifecycleText, /Turn failed/);
});
/**
* A reader's expansion survives the work blocks being regrouped underneath it.
*
* Work blocks are derived, not stored: `groupConversationWorkBlocks` rebuilds
* them every render and ids each one after its first step
* (`work-block:${items[0].id}`). `findFinalAnswerId` exempts only the LAST
* assistant message from the block, so a second assistant message demotes the
* first — the earlier answer becomes work, and the runs on either side of it
* merge into one block:
*
* frame 2 work-block:thought:1[...] msg:1 work-block:thought:2[...]
* frame 3 work-block:thought:1[thought:1,tool:1,msg:1,thought:2,tool:2] msg:2
*
* `work-block:thought:2` stops existing, so React unmounts it. With the fold
* state held in that component, a reader who had opened the second block to read
* its steps was folded shut by the agent posting a second message — an event
* they did not cause and cannot predict. This drives the real list so the actual
* regrouping runs, rather than asserting against hand-built segments.
*/
test("conversation keeps a reader's expanded work block open when a later answer regroups it", async () => {
const shared = { channelId: "chan-1", sessionId: "sess-1", turnId: "turn-1" };
const at = (seconds) =>
`2026-06-14T19:00:${String(seconds).padStart(2, "0")}.000Z`;
const thought = (id, seconds) => ({
...shared,
id,
type: "thought",
renderClass: "thought",
title: "Thinking",
text: `reasoning ${id}`,
timestamp: at(seconds),
});
const tool = (id, seconds) => ({
...shared,
id,
type: "tool",
renderClass: "shell",
title: id,
toolName: "shell",
buzzToolName: null,
status: "completed",
args: {},
result: "ok",
isError: false,
timestamp: at(seconds),
startedAt: at(seconds),
completedAt: at(seconds),
descriptor: {
renderClass: "shell",
label: "Ran command",
preview: id,
source: "shell",
groupKey: "shell:command",
},
});
const answer = (id, seconds) => ({
...shared,
id,
type: "message",
renderClass: "message",
role: "assistant",
title: "Test Agent",
text: `answer ${id}`,
timestamp: at(seconds),
});
const firstRun = [thought("thought:1", 1), tool("tool:1", 2)];
const frame1 = [...firstRun, answer("msg:1", 3)];
const frame2 = [...frame1, thought("thought:2", 4), tool("tool:2", 5)];
const frame3 = [...frame2, answer("msg:2", 6)];
const { container, setOverrides } = await renderRerenderableTranscript(
"conversation",
{ items: frame1 },
);
const blocks = () => [
...container.querySelectorAll('[data-testid="transcript-work-block"]'),
];
const summaries = () => [
...container.querySelectorAll(
'[data-testid="transcript-work-block-summary"]',
),
];
const openCount = () =>
summaries().filter((node) => node.getAttribute("aria-expanded") === "true")
.length;
await setOverrides({ items: frame2 });
assert.equal(
blocks().length,
2,
"the demoted-answer frame should show two separate work blocks",
);
assert.equal(openCount(), 0, "both blocks start folded once work finished");
// The reader opens the SECOND block — the one the merge destroys.
await act(async () => {
summaries()[1].click();
});
assert.equal(openCount(), 1, "the reader's click should open that block");
await setOverrides({ items: frame3 });
assert.equal(
blocks().length,
1,
"the second answer should merge the runs into one block",
);
assert.equal(
openCount(),
1,
"the merged block must stay open — the reader asked to see those steps, and an agent posting again is not a reason to fold them away",
);
// The steps they were reading are actually on screen, not merely a block
// reporting itself open.
const stepText = [
...container.querySelectorAll('[data-testid="transcript-work-block-step"]'),
]
.map((node) => node.textContent)
.join("\n");
assert.match(
stepText,
/tool:2/,
"the reader's steps should still be visible",
);
// ...and the merged block is still theirs to fold. Recording the choice
// against only the block's first step would leave the absorbed block's stale
// `open` entry behind, and since an open choice wins the read, the merged
// block could never be folded again — the reader's click would do nothing.
await act(async () => {
summaries()[0].click();
});
assert.equal(
openCount(),
0,
"a reader who folds the merged block must actually fold it",
);
});

View File

@@ -0,0 +1,274 @@
/**
* Presentation contract for the `conversation` transcript variant's *chrome*:
* the agent identity row above each reply, and the focus code-block recipe.
*
* Split out of `AgentSessionTranscriptList.conversation.test.mjs` to stay under
* the repo's hard 1000-line/file ceiling (AGENTS.md). The shared jsdom setup,
* ambient-formatting pins, and render helpers live in the harness so the two
* suites cannot drift apart.
*/
import assert from "node:assert/strict";
import { test } from "node:test";
import {
AGENT,
AGENT_AVATAR_URL,
AGENT_PROFILES,
cleanup,
fencedCodeItems,
fencedCodePromptItems,
renderTranscript,
renderTranscriptWithCodeChrome,
} from "./AgentSessionTranscriptList.conversationHarness.mjs";
test("the conversation identity row announces the agent exactly once", async () => {
// `UserAvatar` names itself — either an `<img alt="${displayName} avatar">` or
// its fallback initials — and the row puts the agent's name in visible text
// immediately after it. Left unhidden, assistive tech reads the same identity
// twice for every single agent turn in the transcript. The visible name is
// the row's one accessible identity; the avatar is decorative.
const { container } = await renderTranscript("conversation", {
agentAvatarUrl: null,
profiles: AGENT_PROFILES,
});
const identity = container.querySelector(
'[data-testid="transcript-assistant-identity"]',
);
assert.ok(identity, "the identity row should render");
// The avatar is present and still shows the resolved image ...
const decorative = identity.querySelector('[aria-hidden="true"]');
assert.ok(decorative, "the avatar must be hidden from the accessible tree");
const image = decorative.querySelector("img");
assert.ok(image, "hiding the avatar must not stop it rendering visually");
assert.equal(image.getAttribute("src"), AGENT_AVATAR_URL);
// ... but every name-bearing node it contains is inside the hidden subtree,
// so the accessible tree is left with exactly one copy of the name.
const named = [...identity.querySelectorAll("img[alt], [aria-label]")];
assert.ok(named.length > 0, "the avatar should still expose a raw alt/label");
for (const node of named) {
assert.ok(
node.closest('[aria-hidden="true"]') !== null,
`${node.tagName} carrying an accessible name escapes the hidden avatar subtree`,
);
}
// The one remaining accessible identity is the visible name text.
assert.equal(identity.textContent, "Test Agent");
});
test("conversation labels the agent turn with a berd-style identity row", async () => {
// The single biggest divergence from berd was that agent prose carried no
// attribution at all. berd puts a 20px round avatar + the agent name at
// `text-xs` above every reply (MessageBubble.tsx:961-981).
const { container } = await renderTranscript("conversation");
const identity = container.querySelector(
'[data-testid="transcript-assistant-identity"]',
);
assert.ok(identity, "conversation should label the agent turn");
assert.match(identity.textContent, /Test Agent/);
assert.match(identity.className, /text-xs/);
assert.match(identity.className, /gap-1(?!\d)/);
// 20px avatar, berd's size (UserAvatar `size="xs"` → `h-5 w-5`).
assert.ok(
identity.querySelector(".h-5.w-5"),
"identity row should carry a 20px avatar",
);
// The prose itself stays unboxed and full-width.
const message = container.querySelector(
'[data-testid="transcript-assistant-message"]',
);
assert.doesNotMatch(message.innerHTML, /rounded-2xl/);
});
test("the conversation identity row resolves the agent avatar from the profiles lookup", async () => {
// Regression guard for the primary channel flow. `ChannelAgentSessionAgent`
// (useChannelAgentSessions.ts:21-29) has no avatar field at all, so when the
// focus conversation is opened from a channel the panel passes
// `agentAvatarUrl: null` — which is exactly this mount. The row must still
// show the configured avatar by resolving it out of the `profiles` lookup the
// panel already hands down, the same way the ToolItem row
// (ToolItem.tsx:45-52) and the panel header above it already do. Before the
// fix, every channel-opened session fell back to initials.
const { container } = await renderTranscript("conversation", {
agentAvatarUrl: null,
profiles: AGENT_PROFILES,
});
const identity = container.querySelector(
'[data-testid="transcript-assistant-identity"]',
);
assert.ok(identity, "the identity row should render");
const image = identity.querySelector("img");
assert.ok(
image,
"the profile avatar must win over the caller's null agent record avatar",
);
assert.equal(image.getAttribute("src"), AGENT_AVATAR_URL);
});
test("the conversation identity row resolves the agent name the same way the header does", async () => {
// The row sits directly under the panel header, which labels the same agent
// through `resolveUserLabel` (AgentSessionThreadPanel.tsx:244-249). Reading
// the raw `agentName` prop instead let the two disagree whenever the relay
// profile's display name differed from the caller's agent record.
const { container } = await renderTranscript("conversation", {
agentName: "stale-record-name",
profiles: {
[AGENT.agentPubkey]: {
displayName: "Profile Display Name",
avatarUrl: null,
nip05Handle: null,
ownerPubkey: null,
},
},
});
const identity = container.querySelector(
'[data-testid="transcript-assistant-identity"]',
);
assert.equal(identity.textContent, "Profile Display Name");
});
test("the conversation identity row keeps the caller's avatar when the lookup has none", async () => {
// The managed-agent path is the other direction: a locally managed agent can
// carry an avatar its relay profile never published. The prop stays the
// fallback, so resolving profile-first must not drop it.
const localAvatar = "https://cdn.example.test/local-managed.png";
const { container } = await renderTranscript("conversation", {
agentAvatarUrl: localAvatar,
profiles: {
[AGENT.agentPubkey]: {
displayName: "Test Agent",
avatarUrl: null,
nip05Handle: null,
ownerPubkey: null,
},
},
});
const image = container
.querySelector('[data-testid="transcript-assistant-identity"]')
.querySelector("img");
assert.ok(image, "the caller-supplied avatar should still render");
assert.equal(image.getAttribute("src"), localAvatar);
});
test("conversation frames fenced code with berd's header row", async () => {
// berd puts the language in a real header row above the frame, with the copy
// action opposite it (`code-block.tsx` CodeBlockHeader:388-402), and the code
// itself in a 10px-radius, page-background, borderless-shadow frame
// (:528-529). Buzz's `rounded-lg` (`--radius: 0.625rem`) is exactly berd's
// `rounded-[0.625rem]`.
const { container } = await renderTranscriptWithCodeChrome("conversation", {
items: fencedCodeItems(),
});
const header = container.querySelector(
'[data-testid="markdown-code-block-header"]',
);
assert.ok(header, "focus mode should render a code-block header row");
// Language sits in the header, not inside the frame.
assert.match(header.textContent, /^ts/);
assert.match(header.className, /justify-between/);
assert.match(header.className, /items-end/);
assert.match(header.className, /min-h-7/);
assert.ok(
header.querySelector('[aria-label="Copy code block"]'),
"the copy action is a flow sibling of the language label",
);
const frame = container.querySelector("pre");
assert.ok(frame, "the code frame should render");
assert.match(frame.className, /rounded-lg/);
assert.match(frame.className, /bg-background/);
assert.match(frame.className, /border-border\/80/);
assert.doesNotMatch(
frame.className,
/shadow/,
"berd's code frame carries no shadow",
);
// Guards against the default recipe leaking in: it uses a 16px radius, a
// muted fill, `pr-12` to clear an absolutely-positioned copy button, and an
// inline `borderRadius` style.
assert.doesNotMatch(frame.className, /rounded-2xl/);
assert.doesNotMatch(frame.className, /bg-muted/);
assert.doesNotMatch(frame.className, /pr-12/);
assert.equal(frame.style.borderRadius, "");
// Line numbers come from `.code-block-lines [data-line]` in markdown.css, so
// the frame only has to keep emitting per-line elements under that class.
const code = frame.querySelector("code.code-block-lines");
assert.ok(code, "the code element keeps the line-number class");
assert.equal(code.querySelectorAll("[data-line]").length, 2);
});
test("conversation applies the code recipe to a fenced human prompt too", async () => {
// Regression guard for a real bug quality caught. The provider was first
// mounted inside `MessageActivity`, which only handles assistant items — the
// user bubble returns before it, so a fence inside a prompt kept the legacy
// 16px muted frame nested inside the new 12px bubble. The recipe is a
// property of the *surface*, not of a role, so the provider now sits at the
// transcript boundary and both roles inherit it.
const { container } = await renderTranscriptWithCodeChrome("conversation", {
items: fencedCodePromptItems(),
});
const bubble = container.querySelector(
'[data-testid="transcript-user-message"]',
);
assert.ok(bubble, "the prompt should render");
assert.ok(
bubble.querySelector('[data-testid="markdown-code-block-header"]'),
"a fence inside the prompt gets berd's header row",
);
const frame = bubble.querySelector("pre");
assert.match(frame.className, /rounded-lg/);
assert.doesNotMatch(
frame.className,
/rounded-2xl/,
"the legacy 16px frame must not nest inside the 12px bubble",
);
assert.doesNotMatch(frame.className, /pr-12/);
});
test("the default transcript variant keeps the legacy code chrome", async () => {
// The markdown renderer is shared with channel messages, so `focusProse` is
// opt-in per surface. Rendering the same fenced block through the `default`
// transcript variant must still produce the original chrome: no header row,
// 16px radius, muted fill, and the absolutely-positioned copy button.
//
// This proves the *variant gate*, not the channel-message row itself — those
// rows are covered by the markdown tests in `shared/ui/markdown`.
const { container } = await renderTranscriptWithCodeChrome("default", {
items: fencedCodeItems(),
});
// `assert.ok(x === null)` rather than `assert.equal(x, null)`: on failure the
// latter serializes the whole matched jsdom element (and its ancestors) to
// build a diff, which exhausts memory instead of printing the message.
assert.ok(
container.querySelector('[data-testid="markdown-code-block-header"]') ===
null,
"the default recipe has no header row",
);
const frame = container.querySelector("pre");
assert.match(frame.className, /rounded-2xl/);
assert.match(frame.className, /bg-muted\/60/);
assert.match(frame.className, /pr-12/);
assert.match(frame.className, /shadow-xs/);
const copy = container.querySelector('[aria-label="Copy code block"]');
assert.ok(copy, "the default copy button still renders");
assert.match(copy.className, /absolute/);
});
test("the identity row is conversation-only", async () => {
// `default`/`compactPreview` markup is pinned byte-for-byte, so the identity
// row must not leak into them. The fixture comparison would catch this too;
// this asserts it directly so the failure names the cause.
for (const variant of ["default", "compactPreview"]) {
const { container } = await renderTranscript(variant);
assert.ok(
container.querySelector(
'[data-testid="transcript-assistant-identity"]',
) === null,
`${variant} must not render the identity row`,
);
cleanup();
}
});

View File

@@ -0,0 +1,571 @@
/**
* Shared test infrastructure for the `conversation` transcript-variant suites.
*
* Lives in a non-test file for two reasons. The `src/**\/*.test.mjs` glob would
* otherwise pick it up as a suite of its own, and — more importantly — the
* ambient-formatting pins below are order-sensitive and easy to get subtly
* wrong, so the two suites that need them must share one copy rather than
* maintain two. See `AgentSessionTranscriptList.conversation.test.mjs` (layout
* and lifecycle contracts) and
* `AgentSessionTranscriptList.conversationChrome.test.mjs` (identity row and
* code-block chrome).
*
* Importing this module installs the jsdom globals and registers the
* `before`/`afterEach`/`after` hooks for the importing suite. Import it before
* anything that reaches for React or the DOM.
*
* The byte-for-byte contract is the important one: `conversation` is purely
* additive, so the `default` and `compactPreview` markup for the same
* transcript must be byte-identical to the markup captured before the variant
* existed. That snapshot lives in
* AgentSessionTranscriptList.conversation.baseline.json and was produced by
* mounting `baselineItems()` — a transcript containing every renderable item
* kind across two sessions — on pre-change main (074561233) in a clean
* throwaway worktree. Regenerate it only when a deliberate change to the other
* variants is being made.
*/
import { readFileSync } from "node:fs";
import { after, afterEach, before } from "node:test";
// The captured markup embeds formatted dates and times, so the fixture is only
// reproducible if every ambient formatting input is pinned. Two of them bite:
//
// - **Zone.** `formatTranscriptTimestampTitle` formats in the ambient zone
// ("… at 7:00:01 PM"), so a capture at UTC-7 fails against CI's UTC.
// - **Locale.** The session-boundary divider uses a bare `toLocaleString()`
// (`AgentSessionTranscriptChrome.tsx`), which is locale-sensitive as well as
// zone-sensitive: "6/14/2026, 7:05:00 PM" becomes "14.6.2026, 19:05:00"
// under de-DE. Node derives its default locale from LANG/LC_ALL, so this
// varies by machine independently of the zone.
//
// `TZ` can be set here because `Date` reads it lazily. The locale CANNOT: node
// resolves its default locale once at startup, so assigning `process.env.LANG`
// at runtime has no effect (verified — it silently keeps the startup locale).
// Pinning it therefore means overriding the two formatting surfaces the render
// path can reach: `Intl.DateTimeFormat` when constructed with no explicit
// locale, and `Date.prototype.toLocale*`, which does NOT route through
// `Intl.DateTimeFormat` and so needs its own patch.
//
// All of this must happen before the transcript modules are imported: their
// `Intl.DateTimeFormat` instances are module-level constants that resolve zone
// and locale once, at construction.
process.env.TZ = "UTC";
export const FIXTURE_LOCALE = "en-US";
const OriginalDateTimeFormat = Intl.DateTimeFormat;
// A plain function, not an arrow: the render path calls
// `new Intl.DateTimeFormat(...)`, and an arrow function is not a constructor.
// Returning a genuine instance keeps `new`, plain calls, and `instanceof` all
// working.
function LocalePinnedDateTimeFormat(locales, options) {
return new OriginalDateTimeFormat(locales ?? FIXTURE_LOCALE, options);
}
LocalePinnedDateTimeFormat.prototype = OriginalDateTimeFormat.prototype;
LocalePinnedDateTimeFormat.supportedLocalesOf =
OriginalDateTimeFormat.supportedLocalesOf.bind(OriginalDateTimeFormat);
Intl.DateTimeFormat = LocalePinnedDateTimeFormat;
for (const method of [
"toLocaleString",
"toLocaleDateString",
"toLocaleTimeString",
]) {
const original = Date.prototype[method];
Date.prototype[method] = function (locales, options) {
return original.call(this, locales ?? FIXTURE_LOCALE, options);
};
}
import { JSDOM } from "jsdom";
export const BASELINE_MARKUP = JSON.parse(
readFileSync(
new URL(
"./AgentSessionTranscriptList.conversation.baseline.json",
import.meta.url,
),
"utf8",
),
);
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
url: "http://localhost",
});
class NoopObserver {
disconnect() {}
observe() {}
unobserve() {}
}
Object.assign(globalThis, {
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,
document: dom.window.document,
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,
});
dom.window.matchMedia = () => ({
matches: false,
addEventListener() {},
removeEventListener() {},
});
dom.window.requestAnimationFrame = (callback) => setTimeout(callback, 0);
dom.window.cancelAnimationFrame = (id) => clearTimeout(id);
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame;
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame;
/**
* Radix's `AvatarImage` renders nothing until its own preloader reports
* `loaded` (`react-avatar/dist/index.mjs` `useImageLoadingStatus`), and jsdom
* never fetches, so a real avatar URL would otherwise stay in `loading` forever
* and every avatar assertion would see only the initials fallback — the exact
* bug under test, passing vacuously. This stub reports a decoded image as soon
* as `src` is assigned. It only affects avatars that HAVE a url: with
* `avatarUrl: null` radix resolves to `error` and skips the preloader entirely,
* so the byte-for-byte baseline (whose agent and author carry no avatar) is
* untouched.
*/
class LoadedImageStub {
constructor() {
this._src = "";
this.complete = false;
this.naturalWidth = 0;
}
addEventListener() {}
removeEventListener() {}
get src() {
return this._src;
}
set src(value) {
this._src = value;
this.complete = true;
this.naturalWidth = 1;
}
}
dom.window.Image = LoadedImageStub;
/**
* Assigned in `before`. Exported as `let` so importers see the resolved values
* through ES module live bindings rather than a snapshot taken at import time.
*/
export let act;
export let cleanup;
export let render;
let createElement;
let useState;
let createMemoryHistory;
let createRootRoute;
let createRouter;
let RouterProvider;
let AgentSessionTranscriptList;
let ThemeProvider;
let TooltipProvider;
export let resetActiveAgentTurnsStore;
export let syncAgentTurnsFromEvents;
export const AGENT = {
agentAvatarUrl: null,
agentName: "Test Agent",
agentPubkey: "f".repeat(64),
};
export const AUTHOR = "a".repeat(64);
export const AUTHOR_TRUNCATED = `${AUTHOR.slice(0, 8)}…${AUTHOR.slice(-4)}`;
/**
* What the transcript builder actually puts in a prompt item's `title`: a
* description of the trigger that started the turn, not an identity. Real values
* are "Prompt", "Buzz event", and title-cased event kinds like "@Mention"
* (`agentSessionTranscriptHelpers.ts` `parsePromptText`). The author row must
* never display this as a name.
*/
const TRIGGER_TITLE = "@Mention";
export const AUTHOR_PROFILES = {
[AUTHOR]: {
displayName: "Ada Lovelace",
avatarUrl: null,
nip05Handle: null,
ownerPubkey: null,
},
};
/**
* A relay-resolved profile for the *agent*. Deliberately not a `/media/<hex>`
* relay URL: `UserAvatar` routes those through the localhost media proxy
* (`rewriteRelayUrl`), which would make the rendered `src` a moving target.
*/
export const AGENT_AVATAR_URL = "https://cdn.example.test/agent-profile.png";
export const AGENT_PROFILES = {
[AGENT.agentPubkey]: {
displayName: "Test Agent",
avatarUrl: AGENT_AVATAR_URL,
nip05Handle: null,
ownerPubkey: null,
},
};
export function items() {
const shared = { channelId: "chan-1", sessionId: "sess-1", turnId: "turn-1" };
return [
{
...shared,
id: "msg:user",
type: "message",
renderClass: "message",
role: "user",
title: TRIGGER_TITLE,
text: "please summarize the plan",
timestamp: "2026-06-14T19:00:00.000Z",
messageId: "event-1",
authorPubkey: AUTHOR,
},
{
...shared,
id: "thought:1",
type: "thought",
renderClass: "thought",
title: "Thinking",
text: "weighing the options",
timestamp: "2026-06-14T19:00:02.000Z",
},
{
...shared,
id: "plan:1",
type: "plan",
renderClass: "plan",
title: "Plan",
text: "- [x] read the transcript\n- [ ] write the summary (in progress)\n- [ ] ship it",
timestamp: "2026-06-14T19:00:07.000Z",
},
{
...shared,
id: "msg:assistant",
type: "message",
renderClass: "message",
role: "assistant",
title: "Test Agent",
text: "Here is the summary with `code`.",
timestamp: "2026-06-14T19:00:09.000Z",
},
];
}
/**
* Everything the legacy variants can render, in one transcript.
*
* The byte-for-byte contract covers `default`/`compactPreview` for EVERY item
* kind, so the baseline input has to contain every kind rather than the happy
* path: prompt (with prompt context and setup lifecycle so the ingress chrome
* renders), assistant message, thought, plan, a tool item, ordinary lifecycle
* status, error, permission — across two sessions so a session-boundary divider
* is forced too. Where `compactPreview` deliberately suppresses a kind, that
* absence is captured in the fixture and is therefore also protected.
*
* Single tool item on purpose: a run of three would collapse into a grouped
* summary and the leaf tool row would never be captured.
*/
export function baselineItems() {
const first = { channelId: "chan-1", sessionId: "sess-1", turnId: "turn-1" };
const second = { channelId: "chan-1", sessionId: "sess-2", turnId: "turn-2" };
return [
{
...first,
id: "life:setup",
type: "lifecycle",
renderClass: "status",
title: "Turn started",
text: "1 trigger",
timestamp: "2026-06-14T19:00:00.000Z",
acpSource: "turn_started",
},
{
...first,
id: "meta:context",
type: "metadata",
renderClass: "raw-rail",
title: "Prompt context",
sections: [{ title: "Channel", body: "engineering" }],
timestamp: "2026-06-14T19:00:00.500Z",
acpSource: "session/prompt:context",
},
{
...first,
id: "msg:user",
type: "message",
renderClass: "message",
role: "user",
title: "Ada",
text: "please summarize the plan",
timestamp: "2026-06-14T19:00:01.000Z",
messageId: "event-1",
authorPubkey: AUTHOR,
acpSource: "session/prompt:user",
},
{
...first,
id: "thought:1",
type: "thought",
renderClass: "thought",
title: "Thinking",
text: "weighing the options",
timestamp: "2026-06-14T19:00:02.000Z",
},
{
...first,
id: "plan:1",
type: "plan",
renderClass: "plan",
title: "Plan",
text: "- [x] read the transcript\n- [ ] write the summary (in progress)\n- [ ] ship it",
timestamp: "2026-06-14T19:00:03.000Z",
},
{
...first,
id: "tool:1",
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",
},
{
...first,
id: "life:permission",
type: "lifecycle",
renderClass: "permission",
title: "Permission requested",
text: "write src/main.rs\nOptions: Allow, Deny",
outcome: "Approved (once)",
timestamp: "2026-06-14T19:00:06.000Z",
},
{
...first,
id: "life:status",
type: "lifecycle",
renderClass: "status",
title: "Context compacted",
text: "",
timestamp: "2026-06-14T19:00:07.000Z",
},
{
...first,
id: "msg:assistant",
type: "message",
renderClass: "message",
role: "assistant",
title: "Test Agent",
text: "Here is the summary with `code`.",
timestamp: "2026-06-14T19:00:08.000Z",
},
{
...first,
id: "life:error",
type: "lifecycle",
renderClass: "error",
title: "Turn failed",
text: "the harness exited",
timestamp: "2026-06-14T19:00:09.000Z",
},
// Second session run: forces a session-boundary divider between the runs.
{
...second,
id: "msg:user2",
type: "message",
renderClass: "message",
role: "user",
title: "Ada",
text: "next task",
timestamp: "2026-06-14T19:05:00.000Z",
messageId: "event-2",
authorPubkey: AUTHOR,
acpSource: "session/prompt:user",
},
{
...second,
id: "msg:assistant2",
type: "message",
renderClass: "message",
role: "assistant",
title: "Test Agent",
text: "on it",
timestamp: "2026-06-14T19:05:01.000Z",
},
];
}
export async function renderTranscript(variant, overrides = {}) {
const rootRoute = createRootRoute({
component: () =>
createElement(AgentSessionTranscriptList, {
...AGENT,
emptyDescription: "nothing yet",
items: items(),
variant,
...overrides,
}),
});
const router = createRouter({
history: createMemoryHistory({ initialEntries: ["/"] }),
routeTree: rootRoute,
});
await router.load();
return render(createElement(RouterProvider, { router }));
}
/**
* Same mount, wrapped in the providers a fenced code block needs.
*
* `MarkdownCodeBlock` reaches for the theme (shiki highlighting) and a Radix
* tooltip provider for its copy action, so a transcript containing a fenced
* block throws without them. Kept as a separate helper rather than folded into
* `renderTranscript` so the byte-for-byte fixture keeps rendering through the
* exact tree it was captured with.
*/
export async function renderTranscriptWithCodeChrome(variant, overrides = {}) {
const rootRoute = createRootRoute({
component: () =>
createElement(
ThemeProvider,
null,
createElement(
TooltipProvider,
null,
createElement(AgentSessionTranscriptList, {
...AGENT,
emptyDescription: "nothing yet",
items: items(),
variant,
...overrides,
}),
),
),
});
const router = createRouter({
history: createMemoryHistory({ initialEntries: ["/"] }),
routeTree: rootRoute,
});
await router.load();
return render(createElement(RouterProvider, { router }));
}
/** One assistant turn whose body is a fenced code block. */
export function fencedCodeItems() {
return [
{
channelId: "chan-1",
sessionId: "sess-1",
turnId: "turn-1",
id: "msg:assistant",
type: "message",
renderClass: "message",
role: "assistant",
title: "Test Agent",
text: "before\n\n```ts\nconst a = 1;\nconst b = 2;\n```\n",
timestamp: "2026-06-14T19:00:09.000Z",
},
];
}
/** One *human prompt* whose body is a fenced code block. */
export function fencedCodePromptItems() {
return [
{
channelId: "chan-1",
sessionId: "sess-1",
turnId: "turn-1",
id: "msg:user",
type: "message",
renderClass: "message",
role: "user",
title: TRIGGER_TITLE,
text: "fix this\n\n```ts\nconst a = 1;\nconst b = 2;\n```\n",
timestamp: "2026-06-14T19:00:00.000Z",
messageId: "event-1",
authorPubkey: AUTHOR,
},
];
}
/**
* Same mount, but the caller can swap the list props afterwards. Needed for the
* contracts that are only visible across a rerender: a streaming thought
* folding once the turn moves on, and a plan mutating in place.
*/
export async function renderRerenderableTranscript(
variant,
initialOverrides = {},
) {
let applyProps;
const Harness = () => {
const [overrides, setOverrides] = useState(initialOverrides);
applyProps = setOverrides;
return createElement(AgentSessionTranscriptList, {
...AGENT,
emptyDescription: "nothing yet",
items: items(),
variant,
...overrides,
});
};
const rootRoute = createRootRoute({ component: Harness });
const router = createRouter({
history: createMemoryHistory({ initialEntries: ["/"] }),
routeTree: rootRoute,
});
await router.load();
const utils = render(createElement(RouterProvider, { router }));
return {
...utils,
async setOverrides(next) {
await act(async () => {
applyProps(next);
});
},
};
}
before(async () => {
({ act, cleanup, render } = await import("@testing-library/react"));
({ createElement, useState } = await import("react"));
({ createMemoryHistory, createRootRoute, createRouter, RouterProvider } =
await import("@tanstack/react-router"));
({ AgentSessionTranscriptList } = await import(
"./AgentSessionTranscriptList.tsx"
));
({ resetActiveAgentTurnsStore, syncAgentTurnsFromEvents } = await import(
"../activeAgentTurnsStore.ts"
));
({ ThemeProvider } = await import("@/shared/theme/ThemeProvider.tsx"));
({ TooltipProvider } = await import("@/shared/ui/tooltip.tsx"));
});
afterEach(() => {
cleanup?.();
resetActiveAgentTurnsStore?.();
});
after(() => dom.window.close());

View File

@@ -1,6 +1,6 @@
import * as React from "react";
import { motion, useReducedMotion } from "motion/react";
import { CheckCheck, Clock, Radio } from "lucide-react";
import { Radio } from "lucide-react";
import {
useActiveAgentTurns,
@@ -14,24 +14,32 @@ import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { useAnchoredScroll } from "@/features/messages/ui/useAnchoredScroll";
import { useStableArrayShallow } from "@/shared/hooks/useStableReference";
import { cn } from "@/shared/lib/cn";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
import { Toggle } from "@/shared/ui/toggle";
import { AnimatedCount } from "@/shared/ui/AnimatedCount";
import { FuzzyLogo } from "@/shared/ui/buzz-logo/FuzzyLogo";
import type { PromptSection, TranscriptItem } from "./agentSessionTypes";
import { CodeBlockVariantContext } from "@/shared/ui/markdown/CodeBlock";
import type { TranscriptItem } from "./agentSessionTypes";
import { TurnLivenessIndicator } from "./TurnLivenessIndicator";
import { PromptSectionList as PromptContextSections } from "./PromptSectionAccordion";
import {
AgentSessionTranscriptTurnMetaProvider,
AgentSessionTranscriptVariantProvider,
type AgentSessionTranscriptVariant,
useAgentSessionTranscriptVariant,
} from "./agentSessionTranscriptContext";
import {
SessionBoundaryDivider,
SHOW_TRANSCRIPT_ACP_SOURCE,
TranscriptAcpSourceBadge,
TurnPromptBlock,
TurnSetupStatus,
getTranscriptMessageLink,
} from "./AgentSessionTranscriptChrome";
import { buildConversationTurnMeta } from "./agentSessionConversationMeta";
import { AgentSessionWorkBlockSegment } from "./AgentSessionWorkBlock";
import { AgentSessionWorkBlockDisclosureProvider } from "./agentSessionWorkBlockDisclosure";
import {
conversationSegmentsForBlock,
type TranscriptConversationSegment,
} from "./agentSessionWorkBlockGrouping";
import { useTranscriptAnimationEnabled } from "./transcriptAnimationPreference";
import { useTranscriptTimestampsEnabled } from "./transcriptTimestampPreference";
import { TranscriptActivityItem } from "./activityRenderClasses/TranscriptActivityItem";
@@ -48,10 +56,7 @@ import type { AgentTranscriptIdentityProps } from "./activityRenderClasses/types
import type { FileEditDiff } from "./agentSessionFileEditDiff";
import {
buildTranscriptDisplayBlocks,
formatTurnSetupLabel,
getDisplayBlockKey,
turnSetupDetail,
turnSetupTimestamp,
type TranscriptDisplayBlock,
type TranscriptTurnSegment,
} from "./agentSessionTranscriptGrouping";
@@ -59,9 +64,6 @@ import { buildCompactToolSummary } from "./agentSessionToolSummary";
import { shouldShowTranscriptRowTimestamp } from "./agentSessionTranscriptPresentation";
import { formatTranscriptTimestampTitle } from "./agentSessionUtils";
import { hasFileEditLineDiff } from "./FileEditDiffView";
import { UserMessageBubble } from "./activityRenderClasses/UserMessageBubble";
const TRANSCRIPT_ACP_SOURCE_STORAGE_KEY = "buzz:show-transcript-acp-source";
const ROW_ENTER_SPRING = {
damping: 38,
@@ -84,33 +86,8 @@ function useHasCompletedInitialRender() {
return ref;
}
/**
* Opt-in only: source pills are useful while iterating on observer parsing, but
* they should not appear for every local dev session.
*/
const SHOW_TRANSCRIPT_ACP_SOURCE = shouldShowTranscriptAcpSource();
export type AgentSessionTranscriptEmptyState = "idle" | "loading";
function shouldShowTranscriptAcpSource() {
const envValue = import.meta.env.VITE_SHOW_TRANSCRIPT_ACP_SOURCE;
if (envValue === "1" || envValue === "true") {
return true;
}
if (typeof window === "undefined") {
return false;
}
try {
return (
window.localStorage.getItem(TRANSCRIPT_ACP_SOURCE_STORAGE_KEY) === "1"
);
} catch {
return false;
}
}
export function AgentSessionTranscriptList({
agentAvatarUrl,
agentName,
@@ -181,6 +158,7 @@ export function AgentSessionTranscriptList({
});
const isCompactPreview = variant === "compactPreview";
const isConversation = variant === "conversation";
const animationPreferenceEnabled = useTranscriptAnimationEnabled();
const shouldReduceMotion = useReducedMotion();
const animationsDisabled =
@@ -195,6 +173,14 @@ export function AgentSessionTranscriptList({
const hasCompletedInitialRenderRef = useHasCompletedInitialRender();
const hasRenderableContent =
items.length > 0 && hasRenderableDisplayContent(displayBlocks, variant);
// Streaming/duration hints for the conversation variant's thought disclosure.
// Returns a shared constant for the other variants, so their render output is
// untouched.
const turnMeta = React.useMemo(
() =>
buildConversationTurnMeta(displayBlocks, { isTurnLive, items, variant }),
[displayBlocks, isTurnLive, items, variant],
);
const scrollContainerClassNames = cn(
"w-full",
@@ -240,46 +226,66 @@ export function AgentSessionTranscriptList({
aria-live="polite"
className={cn(
"flex w-full flex-col",
isCompactPreview ? "gap-1" : "gap-4",
isCompactPreview ? "gap-1" : isConversation ? "gap-8" : "gap-4",
// Focus mode reads as a document: center the column and cap it at a
// comfortable measure so long agent prose does not run edge to edge.
isConversation && "mx-auto max-w-3xl",
autoTail && "pb-4",
contentContainerClassName,
)}
data-transcript-variant={isConversation ? "conversation" : undefined}
ref={autoTail ? contentRef : undefined}
role="log"
>
<AgentSessionTranscriptVariantProvider value={variant}>
{displayBlocks.map((block) => {
const blockKey = getDisplayBlockKey(block);
return (
<motion.div
animate={ROW_ENTER_TO}
data-message-id={blockKey}
initial={
animationsDisabled || !hasCompletedInitialRenderRef.current
? false
: ROW_ENTER_FROM
}
key={blockKey}
layout={layoutAnimationsEnabled ? "position" : false}
transition={ROW_ENTER_SPRING}
>
{/* content-visibility stays on a non-animated child: motion
{/* The berd code-block recipe is a property of the *surface*, not of a
role: a fenced block in a human prompt must get the same chrome as
one in an agent reply. Provided at the transcript boundary so every
descendant markdown render inherits it. A context provider emits no
DOM, so `default`/`compactPreview` markup is unaffected. */}
<CodeBlockVariantContext.Provider
value={isConversation ? "focusProse" : "default"}
>
<AgentSessionTranscriptVariantProvider value={variant}>
<AgentSessionTranscriptTurnMetaProvider value={turnMeta}>
<AgentSessionWorkBlockDisclosureProvider>
{displayBlocks.map((block) => {
const blockKey = getDisplayBlockKey(block);
return (
<motion.div
animate={ROW_ENTER_TO}
data-message-id={blockKey}
initial={
animationsDisabled ||
!hasCompletedInitialRenderRef.current
? false
: ROW_ENTER_FROM
}
key={blockKey}
layout={layoutAnimationsEnabled ? "position" : false}
transition={ROW_ENTER_SPRING}
>
{/* content-visibility stays on a non-animated child: motion
measures the outer wrapper for layout animations, which
would otherwise force skipped offscreen rows to render. */}
<div className="content-visibility-auto">
<TranscriptDisplayBlockView
agentAvatarUrl={agentAvatarUrl}
agentName={agentName}
agentPubkey={agentPubkey}
block={block}
profiles={profiles}
/>
</div>
</motion.div>
);
})}
{isTurnLive && !isCompactPreview ? <TurnLivenessIndicator /> : null}
</AgentSessionTranscriptVariantProvider>
<div className="content-visibility-auto">
<TranscriptDisplayBlockView
agentAvatarUrl={agentAvatarUrl}
agentName={agentName}
agentPubkey={agentPubkey}
block={block}
profiles={profiles}
/>
</div>
</motion.div>
);
})}
{isTurnLive && !isCompactPreview ? (
<TurnLivenessIndicator />
) : null}
</AgentSessionWorkBlockDisclosureProvider>
</AgentSessionTranscriptTurnMetaProvider>
</AgentSessionTranscriptVariantProvider>
</CodeBlockVariantContext.Provider>
</div>
</motion.div>
);
@@ -337,18 +343,6 @@ function isRenderableCompactItem(item: TranscriptItem) {
return item.renderClass !== "raw-rail" && item.renderClass !== "suppressed";
}
function TranscriptAcpSourceBadge({ source }: { source: string }) {
return (
<span
className="mb-1 inline-flex max-w-full rounded border border-amber-500/25 bg-amber-500/10 px-1.5 py-0.5 font-mono text-xs leading-none text-amber-800 dark:text-amber-200"
data-testid="transcript-acp-source"
title={`ACP wire source: ${source}`}
>
{source}
</span>
);
}
function TranscriptDisplayBlockView({
agentAvatarUrl,
agentName,
@@ -361,6 +355,7 @@ function TranscriptDisplayBlockView({
}) {
const variant = useAgentSessionTranscriptVariant();
const isCompactPreview = variant === "compactPreview";
const isConversation = variant === "conversation";
const animationPreferenceEnabled = useTranscriptAnimationEnabled();
const shouldReduceMotion = useReducedMotion();
// Streaming tool calls land as new segments inside the current turn block
@@ -392,13 +387,25 @@ function TranscriptDisplayBlockView({
);
}
// Variant in, presentation out: the conversation variant folds a turn's
// thinking/tools/notes into work blocks, and every other variant renders the
// shared segments untouched. Keeping the choice here — rather than inside the
// segment components — is what keeps `default`/`compactPreview` byte-identical
// to their pre-work-block markup.
const segments: TranscriptConversationSegment[] = isConversation
? conversationSegmentsForBlock(block)
: block.segments;
return (
<div
className={cn("flex flex-col", isCompactPreview ? "gap-2.5" : "gap-4")}
className={cn(
"flex flex-col",
isCompactPreview ? "gap-2.5" : isConversation ? "gap-3" : "gap-4",
)}
data-testid="transcript-turn-group"
data-turn-id={block.turnId}
>
{block.segments.map((segment) => (
{segments.map((segment) => (
<motion.div
animate={ROW_ENTER_TO}
initial={
@@ -422,7 +429,13 @@ function TranscriptDisplayBlockView({
);
}
function getTurnSegmentKey(turnId: string, segment: TranscriptTurnSegment) {
function getTurnSegmentKey(
turnId: string,
segment: TranscriptConversationSegment,
) {
if (segment.kind === "work-block") {
return segment.block.id;
}
if (segment.kind === "setup") {
return `turn:${turnId}:setup`;
}
@@ -445,8 +458,20 @@ function TranscriptTurnSegmentView({
segment,
}: AgentTranscriptIdentityProps & {
profiles?: UserProfileLookup;
segment: TranscriptTurnSegment;
segment: TranscriptConversationSegment;
}) {
if (segment.kind === "work-block") {
return (
<AgentSessionWorkBlockSegment
agentAvatarUrl={agentAvatarUrl}
agentName={agentName}
agentPubkey={agentPubkey}
block={segment.block}
profiles={profiles}
/>
);
}
if (segment.kind === "prompt") {
return (
<TurnPromptBlock
@@ -652,198 +677,6 @@ function ToolRunSummaryLabel({
);
}
function TurnPromptBlock({
context,
profiles,
setup,
user,
}: {
context: Extract<TranscriptItem, { type: "metadata" }> | null;
profiles?: UserProfileLookup;
setup: Extract<TranscriptItem, { type: "lifecycle" }>[];
user: Extract<TranscriptItem, { type: "message" }>;
}) {
return (
<div data-testid="transcript-prompt-bundle">
{SHOW_TRANSCRIPT_ACP_SOURCE ? (
<div className="mb-1 flex flex-wrap gap-1">
<TranscriptAcpSourceBadge source="session/prompt:user" />
{context ? (
<TranscriptAcpSourceBadge source="session/prompt:context" />
) : null}
</div>
) : null}
<PromptUserMessage
context={context}
item={user}
profiles={profiles}
setup={setup}
/>
</div>
);
}
function PromptUserMessage({
context = null,
item,
profiles,
setup = [],
}: {
context?: Extract<TranscriptItem, { type: "metadata" }> | null;
item: Extract<TranscriptItem, { type: "message" }>;
profiles?: UserProfileLookup;
setup?: Extract<TranscriptItem, { type: "lifecycle" }>[];
}) {
const [contextOpen, setContextOpen] = React.useState(false);
const contextSections = React.useMemo(
() => [...(context?.sections ?? [])],
[context],
);
return (
<>
<UserMessageBubble
bubbleClassName="p-2.5"
footer={
<TurnSetupFooter
contextOpen={contextOpen}
hasContext={contextSections.length > 0}
items={setup}
messageLink={getTranscriptMessageLink(item)}
onContextOpenChange={setContextOpen}
timestamp={item.timestamp}
/>
}
item={item}
profiles={profiles}
/>
<PromptContextDialog
onOpenChange={setContextOpen}
open={contextOpen}
sections={contextSections}
setup={setup}
/>
</>
);
}
function PromptContextDialog({
onOpenChange,
open,
sections,
setup,
}: {
onOpenChange: (open: boolean) => void;
open: boolean;
sections: PromptSection[];
setup: Extract<TranscriptItem, { type: "lifecycle" }>[];
}) {
if (!open || sections.length === 0) {
return null;
}
const setupText = formatPromptSetupSummary(setup);
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="max-w-xl overflow-hidden p-0">
<div className="flex min-w-0 max-h-[85vh] flex-col">
<DialogHeader className="px-6 pb-3 pt-5 pr-14">
<DialogTitle>Prompt context</DialogTitle>
{setupText ? (
<div className="flex items-center gap-1.5">
<CheckCheck className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<DialogDescription>{setupText}</DialogDescription>
</div>
) : null}
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto px-6 pb-6 pt-2">
<PromptContextSections sections={sections} />
</div>
</div>
</DialogContent>
</Dialog>
);
}
function formatPromptSetupSummary(
items: Extract<TranscriptItem, { type: "lifecycle" }>[],
) {
const label = formatTurnSetupLabel(items);
const detail = turnSetupDetail(items);
return [label, detail].filter(Boolean).join(" · ");
}
function TurnSetupFooter({
contextOpen = false,
hasContext = false,
items,
messageLink = null,
onContextOpenChange,
showTimestamp = true,
timestamp,
}: {
contextOpen?: boolean;
hasContext?: boolean;
items: Extract<TranscriptItem, { type: "lifecycle" }>[];
messageLink?: { channelId: string; messageId: string } | null;
onContextOpenChange?: (open: boolean) => void;
showTimestamp?: boolean;
timestamp: string;
}) {
const label = formatTurnSetupLabel(items);
const detail = turnSetupDetail(items);
const tooltipText = [label, detail].filter(Boolean).join(" · ");
const showSetup = items.length > 0;
const showContext = hasContext && onContextOpenChange != null;
if (!showSetup && !showContext) {
return showTimestamp ? (
<TranscriptTimestamp messageLink={messageLink} timestamp={timestamp} />
) : null;
}
return (
<div
className="flex items-center gap-1.5 text-muted-foreground/80"
data-testid="transcript-turn-setup"
>
{showContext ? (
<Toggle
aria-label={`${contextOpen ? "Hide" : "Show"} prompt context`}
className="data-[state=on]:bg-primary/10 data-[state=on]:text-primary dark:data-[state=on]:bg-primary/15"
data-testid="transcript-prompt-context-toggle"
onPressedChange={onContextOpenChange}
pressed={contextOpen}
size="xs"
title={tooltipText || "Show prompt context"}
variant="ghost"
>
<CheckCheck aria-hidden="true" />
</Toggle>
) : (
<span className="inline-flex shrink-0 items-center justify-center rounded-sm text-muted-foreground/70">
<CheckCheck className="h-3.5 w-3.5" />
<span className="sr-only">{tooltipText}</span>
</span>
)}
{showTimestamp ? (
<TranscriptTimestamp messageLink={messageLink} timestamp={timestamp} />
) : null}
</div>
);
}
function getTranscriptMessageLink(
item: Extract<TranscriptItem, { type: "message" }>,
) {
if (!item.channelId || !item.messageId) return null;
return {
channelId: item.channelId,
messageId: item.messageId,
};
}
function TranscriptItemRow({
agentAvatarUrl,
agentName,
@@ -906,74 +739,6 @@ function TranscriptRowTimestamp({
);
}
function TurnSetupStatus({
items,
}: {
items: Extract<TranscriptItem, { type: "lifecycle" }>[];
}) {
const timestamp = turnSetupTimestamp(items);
if (items.length === 0 || !timestamp) {
return null;
}
return (
<div
className="rounded-md px-2"
title={formatTranscriptTimestampTitle(timestamp)}
>
<TurnSetupFooter
items={items}
showTimestamp={false}
timestamp={timestamp}
/>
</div>
);
}
/**
* Horizontal rule rendered between session runs in the observer transcript.
*
* Three label states (based on live-frame observation, not harness affinity):
* - `"current"` — most recent session observed via the live relay subscription.
* - `"most-recent"` — newest visible session with no matching live frames
* (loaded from archive or session ended before observation).
* - `"earlier"` — an older session preceding the most-recent one.
*/
function SessionBoundaryDivider({
labelState,
sessionStartTimestamp,
}: {
labelState: "current" | "most-recent" | "earlier";
sessionStartTimestamp: string;
}) {
const label =
labelState === "current"
? "Latest live-observed session"
: labelState === "most-recent"
? "Most recent observed session"
: "Earlier observed session";
const formattedDate = new Date(sessionStartTimestamp).toLocaleString();
return (
<div
className="flex items-center gap-2 px-3 py-2"
data-testid="session-boundary-divider"
>
<div className="h-px flex-1 bg-border" />
<span className="flex items-center gap-1 text-xs text-muted-foreground">
{labelState === "current" ? (
<Radio aria-hidden="true" className="h-3 w-3" />
) : (
<Clock aria-hidden="true" className="h-3 w-3" />
)}
{label}
{" · "}
{formattedDate}
</span>
<div className="h-px flex-1 bg-border" />
</div>
);
}
const TranscriptItemView = React.memo(function TranscriptItemView({
agentAvatarUrl,
agentName,

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,42 @@
import type * as React from "react";
import { cn } from "@/shared/lib/cn";
/**
* Quiet centered rule used by the `conversation` variant for items that mark
* structure rather than work: session boundaries and turn setup/status.
*
* VISION_ACTIVITY's "failures rise; reads recede" applies to salience, not just
* color — in focus mode these items recede to a hairline with a small centered
* caption so the reader's eye stays on the prompt/response rhythm. Errors and
* permission gates keep their loud treatment and never route here.
*/
export function ConversationDivider({
className,
icon,
label,
testId,
title,
}: {
className?: string;
icon?: React.ReactNode;
label: string;
testId?: string;
title?: string;
}) {
return (
<div
className={cn("flex items-center gap-2 py-1", className)}
data-testid={testId}
data-variant="conversation-divider"
title={title}
>
<div className="h-px flex-1 bg-border/60" />
<span className="flex min-w-0 items-center gap-1 text-xs text-muted-foreground/70">
{icon}
<span className="truncate">{label}</span>
</span>
<div className="h-px flex-1 bg-border/60" />
</div>
);
}

View File

@@ -1,7 +1,9 @@
import { AlertCircle, CheckCircle2, ShieldCheck, XCircle } from "lucide-react";
import { useAgentSessionTranscriptVariant } from "../agentSessionTranscriptContext";
import { formatTranscriptTimestampTitle } from "../agentSessionUtils";
import { ActivityRow, ActivityRowLabel } from "./ActivityRow";
import { ConversationDivider } from "./ConversationDivider";
import { ToolActivity } from "./ToolActivity";
import type { ActivityRenderClassItemProps } from "./types";
@@ -118,12 +120,45 @@ export function LifecycleActivity(props: ActivityRenderClassItemProps) {
);
}
return (
<LifecycleStatusRow item={props.item} timestampTitle={timestampTitle} />
);
}
/**
* Plain lifecycle status ("Turn started", "Context compacted") — structure, not
* work. Focus mode recedes it to a quiet centered divider so the reader's eye
* stays on the prompt/response rhythm; errors and permission gates never reach
* here and keep their loud treatment.
*
* Split into its own component so `LifecycleActivity` stays hook-free above its
* early returns.
*/
function LifecycleStatusRow({
item,
timestampTitle,
}: {
item: Extract<ActivityRenderClassItemProps["item"], { type: "lifecycle" }>;
timestampTitle: string | undefined;
}) {
const variant = useAgentSessionTranscriptVariant();
if (variant === "conversation") {
return (
<ConversationDivider
label={[item.title, item.text].filter(Boolean).join(" · ")}
testId="transcript-lifecycle-item"
title={timestampTitle}
/>
);
}
return (
<ActivityRow testId="transcript-lifecycle-item" title={timestampTitle}>
<ActivityRowLabel
object={props.item.text || undefined}
object={item.text || undefined}
openToneScope="none"
verb={props.item.title}
verb={item.title}
/>
</ActivityRow>
);

View File

@@ -1,5 +1,10 @@
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import {
resolveUserLabel,
type UserProfileLookup,
} from "@/features/profile/lib/identity";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { Markdown } from "@/shared/ui/markdown";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import { useAgentSessionTranscriptVariant } from "../agentSessionTranscriptContext";
import { formatTranscriptTimestampTitle } from "../agentSessionUtils";
import type { TranscriptItem } from "../agentSessionTypes";
@@ -16,21 +21,55 @@ export function MessageActivity(props: ActivityRenderClassItemProps) {
return null;
}
return <MessageItem item={props.item} profiles={props.profiles} />;
return (
<MessageItem
agentAvatarUrl={props.agentAvatarUrl}
agentName={props.agentName}
agentPubkey={props.agentPubkey}
item={props.item}
profiles={props.profiles}
/>
);
}
function MessageItem({
agentAvatarUrl,
agentName,
agentPubkey,
item,
profiles,
}: {
agentAvatarUrl: string | null;
agentName: string;
agentPubkey: string;
item: Extract<TranscriptItem, { type: "message" }>;
profiles?: UserProfileLookup;
}) {
const variant = useAgentSessionTranscriptVariant();
const isCompactPreview = variant === "compactPreview";
const isConversation = variant === "conversation";
const isAssistant = item.role === "assistant";
const text = item.text.trim();
const messageLink = getTranscriptMessageLink(item);
// The identity row must resolve the agent through the profiles lookup first,
// exactly as `ToolItem` (ToolItem.tsx:45-52) and the panel header
// (AgentSessionThreadPanel.tsx:243-249) already do for the same agent on the
// same surface. The `agentAvatarUrl`/`agentName` props only carry what the
// *caller's* agent record holds, and the primary channel flow's record
// (`ChannelAgentSessionAgent`) has no avatar field at all — so relying on the
// prop alone showed initials for every channel-opened session while the
// managed-agent panel showed the real avatar. Resolving here keeps the row
// agreeing with the header directly above it; the props stay as the fallback
// for callers that have an avatar the lookup does not (a locally managed
// agent whose avatar was never published to a relay profile).
const agentProfile = profiles?.[normalizePubkey(agentPubkey)] ?? null;
const resolvedAgentAvatarUrl = agentProfile?.avatarUrl ?? agentAvatarUrl;
const resolvedAgentName = resolveUserLabel({
pubkey: agentPubkey,
fallbackName: agentName,
profiles,
preferResolvedSelfLabel: true,
});
if (!isAssistant) {
return (
@@ -54,6 +93,44 @@ function MessageItem({
data-testid="transcript-assistant-message"
>
<div className="group relative flex w-full min-w-0 flex-col items-start gap-1">
{isConversation ? (
// berd labels every agent turn with a small identity row above the
// prose — 20px round avatar + name at `text-xs`, `mb-0.5`, `gap-1`
// (MessageBubble.tsx:961-981). Without it the reply reads as
// unattributed body text in a full-cover view, which was the largest
// single divergence from berd. Only the conversation variant gets it:
// the other variants' markup is pinned by the byte-for-byte fixture.
<div
className="mb-0.5 flex items-center gap-1 text-xs"
data-testid="transcript-assistant-identity"
>
{/*
* The avatar is decorative here: `UserAvatar` exposes either an
* image named `${displayName} avatar` or its fallback initials, and
* the agent's name already follows as visible text, so an
* unhidden avatar makes a screen reader announce the same identity
* twice for every agent turn. The visible name is the row's single
* accessible identity. Hidden at this call site rather than by
* teaching the shared `UserAvatar` a decorative mode: other rows
* that pair an avatar with adjacent name text (for example
* `ForumPostCard.tsx:91-99`) have the same shape and would want the
* same treatment, but changing the shared component's accessible
* name affects all 45 of its call sites and is not this PR's scope.
*
* `size="xs"` is already 20px (`h-5 w-5`) in UserAvatar.
*/}
<span aria-hidden="true" className="flex shrink-0">
<UserAvatar
avatarUrl={resolvedAgentAvatarUrl}
displayName={resolvedAgentName}
size="xs"
/>
</span>
<span className="min-w-0 truncate font-normal text-foreground">
{resolvedAgentName}
</span>
</div>
) : null}
<div
className={
isCompactPreview
@@ -63,7 +140,16 @@ function MessageItem({
title={formatTranscriptTimestampTitle(item.timestamp)}
>
<Markdown
className={isCompactPreview ? "text-xs leading-4" : "leading-5"}
className={
isCompactPreview
? "text-xs leading-4"
: isConversation
? // Focus mode reads as prose: no box, comfortable line
// height, and full-fidelity markdown/code from the shared
// renderer.
"leading-relaxed"
: "leading-5"
}
content={text || " "}
/>
</div>

View File

@@ -1,3 +1,6 @@
import { Check, ListChecks, Loader } from "lucide-react";
import { cn } from "@/shared/lib/cn";
import { Markdown } from "@/shared/ui/markdown";
import {
ActivityRow,
@@ -5,6 +8,12 @@ import {
ActivityRowLabel,
} from "./ActivityRow";
import { ToolActivity } from "./ToolActivity";
import {
formatPlanChecklistProgress,
parsePlanChecklist,
type PlanChecklistEntry,
} from "../agentSessionPlanChecklist";
import { useAgentSessionTranscriptVariant } from "../agentSessionTranscriptContext";
import { formatTranscriptTimestampTitle } from "../agentSessionUtils";
import type { ActivityRenderClassItemProps } from "./types";
@@ -16,14 +25,27 @@ export function PlanActivity(props: ActivityRenderClassItemProps) {
return null;
}
if (props.item.isUpdate) {
return <PlanItem item={props.item} />;
}
function PlanItem({
item,
}: {
item: Extract<ActivityRenderClassItemProps["item"], { type: "plan" }>;
}) {
const variant = useAgentSessionTranscriptVariant();
// Plan-update markers stay a one-line "Updated plan · 3/5 complete" row in
// every variant: in focus mode the card below already carries current state,
// so the marker is a timeline note and must not compete with it.
if (item.isUpdate) {
return (
<ActivityRow
testId="transcript-plan-update-item"
title={formatTranscriptTimestampTitle(props.item.timestamp)}
title={formatTranscriptTimestampTitle(item.timestamp)}
>
<ActivityRowLabel
object={<PlanUpdateLabelObject text={props.item.text} />}
object={<PlanUpdateLabelObject text={item.text} />}
openToneScope="none"
verb="Updated"
/>
@@ -31,22 +53,125 @@ export function PlanActivity(props: ActivityRenderClassItemProps) {
);
}
if (variant === "conversation") {
return <ConversationPlanCard item={item} />;
}
return (
<ActivityRow
testId="transcript-plan-item"
title={formatTranscriptTimestampTitle(props.item.timestamp)}
title={formatTranscriptTimestampTitle(item.timestamp)}
>
<ActivityRowLabel object="plan" openToneScope="tool" verb="Updated" />
<ActivityRowContent className="pt-1 pb-1.5 text-sm leading-5 text-muted-foreground">
<Markdown
className="leading-5"
content={props.item.text.trim() || "No plan details."}
content={item.text.trim() || "No plan details."}
/>
</ActivityRowContent>
</ActivityRow>
);
}
/**
* Focus-mode plan: a checklist card rather than a collapsed markdown row.
*
* The plan item is mutated in place by the transcript reducer (`replaceItem`
* keeps the same item id), so this card is the one surface that shows current
* plan state — re-rendering it as entries flip is the "updates in place"
* behavior; no local state is involved.
*
* Adapters that send free-form plan text instead of ACP `entries[]` produce no
* checklist lines, so the card falls back to the markdown body.
*/
function ConversationPlanCard({
item,
}: {
item: Extract<ActivityRenderClassItemProps["item"], { type: "plan" }>;
}) {
const text = item.text.trim();
const checklist = parsePlanChecklist(text);
const progress = formatPlanChecklistProgress(checklist);
return (
<div
className="not-prose w-full rounded-xl border border-border/70 bg-card/60 px-4 py-3"
data-testid="transcript-plan-item"
data-variant="conversation-plan-card"
title={formatTranscriptTimestampTitle(item.timestamp)}
>
<div className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<ListChecks aria-hidden="true" className="h-3.5 w-3.5 shrink-0" />
<span>Plan</span>
{progress ? (
<>
<span aria-hidden="true">·</span>
<span data-testid="transcript-plan-progress">{progress}</span>
</>
) : null}
</div>
{checklist.entries.length > 0 ? (
<ul className="mt-2 flex flex-col gap-1.5">
{checklist.entries.map((entry, index) => (
<PlanChecklistRow
entry={entry}
// biome-ignore lint/suspicious/noArrayIndexKey: plan entries have no stable id and are replaced wholesale on each update
key={index}
/>
))}
</ul>
) : (
<div className="mt-2 text-sm leading-relaxed text-muted-foreground">
<Markdown
className="leading-relaxed"
content={text || "No plan details."}
/>
</div>
)}
</div>
);
}
function PlanChecklistRow({ entry }: { entry: PlanChecklistEntry }) {
return (
<li
className="flex items-start gap-2 text-sm leading-relaxed"
data-plan-entry-status={entry.status}
>
<span className="mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center">
{entry.status === "completed" ? (
<Check aria-hidden="true" className="h-3.5 w-3.5 text-primary" />
) : entry.status === "in_progress" ? (
<Loader
aria-hidden="true"
className="h-3.5 w-3.5 text-muted-foreground"
/>
) : (
<span className="h-3 w-3 rounded-[3px] border border-border" />
)}
</span>
<span
className={cn(
entry.status === "completed"
? "text-muted-foreground line-through"
: entry.status === "in_progress"
? "text-foreground"
: "text-muted-foreground",
)}
>
{entry.label}
</span>
<span className="sr-only">
{entry.status === "completed"
? " (completed)"
: entry.status === "in_progress"
? " (in progress)"
: " (pending)"}
</span>
</li>
);
}
function PlanUpdateLabelObject({ text }: { text: string }) {
return (
<>

View File

@@ -16,17 +16,30 @@ export function ThoughtActivity(props: ActivityRenderClassItemProps) {
return null;
}
return <ThoughtItem item={props.item} />;
}
/**
* The `default`/`compactPreview` thought row.
*
* The `conversation` variant does not reach this presenter: focus mode renders
* reasoning as a row on the work block's rail (`AgentSessionWorkBlock`), so the
* separate per-thought disclosure that used to live here is gone rather than
* duplicated. Its "Thought for Ns" label logic moved with it.
*/
function ThoughtItem({
item,
}: {
item: Extract<ActivityRenderClassItemProps["item"], { type: "thought" }>;
}) {
return (
<ActivityRow
testId="transcript-thought-item"
title={formatTranscriptTimestampTitle(props.item.timestamp)}
title={formatTranscriptTimestampTitle(item.timestamp)}
>
<ActivityRowLabel openToneScope="tool" verb={props.item.title} />
<ActivityRowLabel openToneScope="tool" verb={item.title} />
<ActivityRowContent className="pt-1 pb-1.5 text-sm leading-5 text-muted-foreground">
<Markdown
className="leading-5"
content={props.item.text.trim() || " "}
/>
<Markdown className="leading-5" content={item.text.trim() || " "} />
</ActivityRowContent>
</ActivityRow>
);

View File

@@ -33,24 +33,48 @@ export function UserMessageBubble({
const { goChannel } = useAppNavigation();
const { openProfilePanel } = useProfilePanel();
const isCompactPreview = variant === "compactPreview";
const shouldClampBubble = !isCompactPreview;
const isConversation = variant === "conversation";
// Focus mode shows the whole prompt: the reader is here to read the turn, and
// the channel-context affordance already lives in the footer, so clamping the
// bubble would hide the one thing they came for.
const shouldClampBubble = !isCompactPreview && !isConversation;
const [bubbleRef, hasBubbleOverflow] =
useTranscriptBubbleOverflow(shouldClampBubble);
const text = item.text.trim();
// The bubble stays a link back to the originating channel message in both
// polished variants; only the dense preview drops it.
const messageLink =
shouldClampBubble && item.channelId && item.messageId
!isCompactPreview && item.channelId && item.messageId
? { channelId: item.channelId, messageId: item.messageId }
: null;
const authorProfile = item.authorPubkey
? profiles?.[item.authorPubkey.toLowerCase()]
: null;
const authorLabel = item.authorPubkey
// The other variants seed the avatar from `resolveUserLabel(…, fallbackName:
// item.title)`, whose last resort is the prompt item's title. That title
// describes the *trigger* that started the turn ("@Mention", "Prompt", "Buzz
// event"), never a person — harmless when it only picks avatar initials, so
// this chain is left exactly as it was for `default`/`compactPreview`.
const triggerSeededLabel = item.authorPubkey
? resolveUserLabel({
pubkey: item.authorPubkey,
fallbackName: item.title,
profiles,
})
: item.title || "User";
// Focus mode promotes the author to displayed text, where a trigger title
// would read as a false identity — an unresolved sender showing up as
// "@Mention". Identity resolution here therefore stops at the profile
// (display name, then NIP-05 handle, then the truncated pubkey): a truncated
// pubkey is a real, if terse, identity, and it keeps the utterance attributed
// in a full-cover view. `item.title` stays available as trigger chrome in the
// footer; it is never a name. Only when the item carries no author at all does
// the row fall back to a generic placeholder.
const authorLabel = isConversation
? item.authorPubkey
? resolveUserLabel({ pubkey: item.authorPubkey, profiles })
: "User"
: triggerSeededLabel;
const handleBubbleClick = React.useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
if (!messageLink || isNestedInteractiveTarget(event)) return;
@@ -130,9 +154,23 @@ export function UserMessageBubble({
className={cn(
"group relative flex min-w-0 flex-1 flex-col items-end gap-1",
isCompactPreview && "items-start",
// berd caps the user turn at a fixed measure, not a percentage of the
// column (`--chat-user-message-max-width: 640px`,
// MessageBubble.tsx:956): a percentage keeps re-wrapping the prompt as
// the cover width changes, while a fixed measure holds one stable
// reading line length. `max-w-prompt-bubble` carries the 640px token.
isConversation && "max-w-prompt-bubble flex-initial",
className,
)}
>
{isConversation ? (
<span
className="px-1 text-xs font-medium text-muted-foreground"
data-testid="transcript-user-message-author"
>
{authorLabel}
</span>
) : null}
<div
className={cn(
"w-full min-w-0 rounded-2xl border border-border/70 bg-transparent p-3 text-sm leading-relaxed text-foreground",
@@ -140,13 +178,28 @@ export function UserMessageBubble({
messageLink &&
"group/bubble cursor-pointer transition-colors hover:border-border hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
isCompactPreview && "p-2 text-xs leading-4",
// berd's user-turn recipe (MessageBubble.tsx:990): a soft tint, no
// border at all, `px-4 py-2`, and a tighter radius than a chat
// "pill". berd's `rounded-sm` is 12px on its own scale
// (globals.css `--radius-sm: 12px`), NOT Tailwind's stock 2px —
// Buzz's `rounded-xl` is the exact 12px equivalent here.
// `leading-normal` overrides the `leading-relaxed` base, as berd
// does, so the prompt sits tighter than the agent's prose.
isConversation &&
"rounded-xl border-0 bg-muted/60 px-4 py-2 leading-normal",
bubbleClassName,
)}
ref={bubbleRef}
{...bubbleLinkProps}
>
<Markdown
className={isCompactPreview ? "text-xs leading-4" : "leading-5"}
className={cn(
isCompactPreview
? "text-xs leading-4"
: isConversation
? "leading-normal"
: "leading-5",
)}
content={text || " "}
mediaInset
/>
@@ -156,7 +209,17 @@ export function UserMessageBubble({
) : null}
{messageLink ? <MessageLinkHoverCue /> : null}
</div>
{footer}
{isConversation && footer ? (
// Timestamp/context row is chrome, not content: focus mode keeps it
// out of the reading rhythm until the row is hovered or
// keyboard-focused. Other variants render `footer` bare so their
// markup is unchanged.
<div className="opacity-0 transition-opacity focus-within:opacity-100 group-hover:opacity-100">
{footer}
</div>
) : (
footer
)}
</div>
</div>
);

View File

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

View File

@@ -0,0 +1,153 @@
import type {
AgentSessionTranscriptTurnMeta,
AgentSessionTranscriptVariant,
} from "./agentSessionTranscriptContext";
import { EMPTY_TRANSCRIPT_TURN_META } from "./agentSessionTranscriptContext";
import type {
TranscriptDisplayBlock,
TranscriptTurnSegment,
} from "./agentSessionTranscriptGrouping";
import type { TranscriptItem } from "./agentSessionTypes";
/**
* Flatten a turn's segments into the leaf items that the reader actually sees
* 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];
if (segment.kind === "setup") return [];
if (segment.kind === "summary") return segment.summary.items;
return [segment.item];
}
/**
* Derive the `conversation` variant's live-turn hints from the display blocks.
*
* Two things the work block cannot see for itself:
*
* - **`streamingItemId`** — the trailing leaf item of the final turn, and only
* when the agent's turn is actually live. The block reads this to decide
* whether it is still working: a thought or note streaming in carries no
* status of its own, so without this hint a block whose last step is prose
* would fold while the reader was still watching it arrive.
* - **`liveTurnId`** — which turn a session currently owns. A tool item keeps
* its `executing` status forever if the agent dies mid-step, so status alone
* cannot tell a running step from an abandoned one; the block needs to know
* whose turn is live to decide (see `AgentSessionTranscriptTurnMeta`).
*
* Returns the shared empty value for non-conversation variants so the default
* and compactPreview paths allocate nothing and stay byte-identical.
*/
export function buildConversationTurnMeta(
displayBlocks: TranscriptDisplayBlock[],
options: {
isTurnLive: boolean;
items: TranscriptItem[];
variant: AgentSessionTranscriptVariant;
},
): AgentSessionTranscriptTurnMeta {
if (options.variant !== "conversation" || !options.isTurnLive) {
return EMPTY_TRANSCRIPT_TURN_META;
}
const lastBlock = displayBlocks[displayBlocks.length - 1];
// The live turn is the newest turn in the ITEM stream, not the newest turn
// that produced a block. See `latestTurnId` for why the distinction is
// load-bearing. Read from the transcript rather than from the active-turn
// store because the store's turn ids and the transcript's are populated by
// different paths, and a mismatch would silently gate every step off.
const liveTurnId = latestTurnId(options.items, displayBlocks);
if (lastBlock?.kind === "single") {
return {
liveTurnId,
streamingItemId: streamingIdForTail(lastBlock.item, liveTurnId),
};
}
if (lastBlock?.kind !== "turn") {
return { liveTurnId, streamingItemId: null };
}
const items = lastBlock.segments.flatMap(turnSegmentItems);
const tail = items[items.length - 1];
return {
liveTurnId,
streamingItemId: tail ? streamingIdForTail(tail, liveTurnId) : null,
};
}
/**
* The trailing item counts as streaming only if the LIVE turn owns it.
*
* Without this check the newest block's tail is reported as streaming no matter
* whose turn it belongs to, so a finished turn's last step would hold that
* turn's work block open while a *different* turn is the live one. That is the
* same class of bug as an abandoned `executing` step: presenting settled history
* as work in flight.
*/
function streamingIdForTail(
tail: TranscriptItem,
liveTurnId: string | null,
): string | null {
// Compared rather than tested for truthiness, for the same reason as
// `toolEntryState`: an item with no turn id is owned by nobody, and
// `null === null` must not read as ownership.
if (liveTurnId === null || tail.turnId !== liveTurnId) return null;
return tail.id;
}
/**
* The id of the newest turn the transcript has seen — including a turn that has
* arrived but has nothing renderable in it yet.
*
* Read from the items rather than the blocks because a turn that has only
* emitted setup lifecycle rows (`turn_started`, `session_resolved`) classifies
* to zero segments and so produces NO block at all
* (`agentSessionTranscriptGrouping`: `if (segments.length > 0)`). Walking the
* blocks backwards for the last `turn` therefore skipped straight past the new
* turn and returned the turn that had already ended — which then made its own
* trailing item the streaming item, and a settled block visibly re-opened,
* dropped to its last three steps and re-expanded for the whole gap between
* `turn_started` and the new turn's first prompt or thought. That gap is real
* observer-stream latency and happens on every turn, so this was not an edge
* case.
*
* Items are in wire order, so the last one carrying a turn id names the newest
* turn. Falls back to the last turn block for an item stream that carries no
* turn ids at all.
*/
function latestTurnId(
items: TranscriptItem[],
displayBlocks: TranscriptDisplayBlock[],
): string | null {
for (let index = items.length - 1; index >= 0; index -= 1) {
const turnId = items[index]?.turnId;
if (turnId) return turnId;
}
return lastTurnBlockId(displayBlocks);
}
/**
* The id of the last turn block, ignoring anything that trails it.
*
* A lifecycle row (a compaction notice, a session boundary) can land after the
* turn it belongs to and arrives as a `single` block, so the last block is not
* always the live turn — but the last *turn* is.
*/
function lastTurnBlockId(
displayBlocks: TranscriptDisplayBlock[],
): string | null {
for (let index = displayBlocks.length - 1; index >= 0; index -= 1) {
const block = displayBlocks[index];
if (block.kind === "turn") return block.turnId;
}
return null;
}

View File

@@ -0,0 +1,54 @@
/**
* Parse the markdown checklist a `plan` transcript item carries into structured
* entries, so the `conversation` variant can render it as a checklist card
* instead of a block of markdown.
*
* The text is produced by `formatPlanEntry` in agentSessionTranscriptHelpers:
* `- [x] content` for completed entries, `- [ ] content` otherwise, with an
* ` (in progress)` suffix when the ACP entry status is `in_progress`. Adapters
* that send free-form `content` text instead of `entries[]` yield text with no
* checklist lines at all — those return no entries and the card falls back to
* rendering the raw markdown.
*/
export type PlanChecklistEntry = {
label: string;
status: "completed" | "in_progress" | "pending";
};
export type PlanChecklist = {
completedCount: number;
entries: PlanChecklistEntry[];
};
const CHECKLIST_LINE = /^\s*[-*]\s+\[([ xX])\]\s*(.*)$/;
const IN_PROGRESS_SUFFIX = /\s*\(in progress\)\s*$/i;
export function parsePlanChecklist(text: string): PlanChecklist {
const entries: PlanChecklistEntry[] = [];
for (const line of text.split(/\r?\n/)) {
const match = line.match(CHECKLIST_LINE);
if (!match) continue;
const completed = match[1].toLowerCase() === "x";
const rawLabel = match[2].trim();
const inProgress = !completed && IN_PROGRESS_SUFFIX.test(rawLabel);
entries.push({
label: rawLabel.replace(IN_PROGRESS_SUFFIX, "").trim(),
status: completed ? "completed" : inProgress ? "in_progress" : "pending",
});
}
return {
completedCount: entries.filter((entry) => entry.status === "completed")
.length,
entries,
};
}
/** "2/5 complete" progress caption, or null when there is nothing to count. */
export function formatPlanChecklistProgress(
checklist: PlanChecklist,
): string | null {
if (checklist.entries.length === 0) return null;
return `${checklist.completedCount}/${checklist.entries.length} complete`;
}

View File

@@ -1,6 +1,19 @@
import * as React from "react";
export type AgentSessionTranscriptVariant = "default" | "compactPreview";
/**
* Presentation modes for the observer transcript.
*
* - `default` — the polished activity feed (agent panel, thread panel).
* - `compactPreview` — dense, single-column preview (profile panel cards).
* - `conversation` — full-cover focus-mode reading view: prompts as
* right-aligned bubbles, agent messages as unboxed prose,
* thoughts/plans as disclosures, lifecycle as quiet
* dividers. Tool items render exactly as `default`.
*/
export type AgentSessionTranscriptVariant =
| "default"
| "compactPreview"
| "conversation";
const AgentSessionTranscriptVariantContext =
React.createContext<AgentSessionTranscriptVariant>("default");
@@ -11,3 +24,78 @@ export const AgentSessionTranscriptVariantProvider =
export function useAgentSessionTranscriptVariant() {
return React.useContext(AgentSessionTranscriptVariantContext);
}
/**
* Per-render derivation the `conversation` variant needs but individual render
* classes cannot see on their own: which item is currently streaming (the
* trailing item of a live turn). Derived once by the list from the display
* blocks — see `buildConversationTurnMeta`.
*/
export type AgentSessionTranscriptTurnMeta = {
/** Trailing item of a live turn, or null when nothing is streaming. */
streamingItemId: string | null;
/**
* The turn that is actually live, or null when no turn is.
*
* A tool item's `executing`/`pending` status is not evidence that work is
* happening: an agent that dies after emitting a tool start leaves that
* status on the item permanently, so reopened history still claims to be
* mid-step. Whether a session owns that step is knowledge only the list has,
* which is why it is published here rather than re-derived per row.
*
* A turn id rather than a boolean, because "some turn is live" is not the
* question. An agent that crashed during turn 1 and is now working on turn 2
* is live, yet turn 1's abandoned step is no more running than before — a
* global flag would keep it spinning in exactly the case a restarted agent
* makes common.
*/
liveTurnId: string | null;
};
export const EMPTY_TRANSCRIPT_TURN_META: AgentSessionTranscriptTurnMeta = {
liveTurnId: null,
streamingItemId: null,
};
/**
* Whether the surrounding subtree is a work block's rail.
*
* The rail's job is to show *what the agent did*, one muted row per step. Two
* tool presentations disagree with that: a relay `messages send` classifies as
* `renderClass: "message"` and therefore renders through
* `CompactMessageSummary` — a 28px avatar, a bordered speech bubble, a
* timestamp and a delivery-receipt button. That treatment is right in the
* activity feed, where a posted message is a destination the reader may want to
* open, and wrong on the rail, where the same markup reads as the agent
* *replying* in the middle of its own work.
*
* This is the same failure the interim-note case avoids (see
* `WorkBlockProseBody`), reached by a different route: there the item is an
* assistant message, here it is a tool call that merely classifies as one. The
* signal is explicit and additive rather than inferred from the transcript
* variant, because `conversation` alone is not the condition — the same relay
* step rendered outside a block in that variant should keep its bubble.
*
* Defaults to `false`, so `default` and `compactPreview` cannot observe it and
* their markup stays byte-identical.
*/
const AgentSessionWorkBlockRailContext = React.createContext(false);
export const AgentSessionWorkBlockRailProvider =
AgentSessionWorkBlockRailContext.Provider;
export function useIsInsideWorkBlockRail() {
return React.useContext(AgentSessionWorkBlockRailContext);
}
const AgentSessionTranscriptTurnMetaContext =
React.createContext<AgentSessionTranscriptTurnMeta>(
EMPTY_TRANSCRIPT_TURN_META,
);
export const AgentSessionTranscriptTurnMetaProvider =
AgentSessionTranscriptTurnMetaContext.Provider;
export function useAgentSessionTranscriptTurnMeta() {
return React.useContext(AgentSessionTranscriptTurnMetaContext);
}

View File

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

View File

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

View File

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

View File

@@ -16,6 +16,7 @@ import {
scopeByChannel,
} from "@/features/agents/ui/agentSessionPanelLayout";
import { deriveTranscriptBlockIds } from "@/features/agents/ui/agentSessionTranscriptGrouping";
import type { AgentSessionTranscriptVariant } from "@/features/agents/ui/agentSessionTranscriptContext";
import type { ObserverEvent } from "@/features/agents/ui/agentSessionTypes";
import { ManagedAgentSessionPanel } from "@/features/agents/ui/ManagedAgentSessionPanel";
import {
@@ -81,6 +82,15 @@ type AgentSessionThreadPanelProps = {
onClose: () => void;
widthPx: number;
transparentChrome?: boolean;
/**
* Transcript presentation. Caller-pinned only: the panel keeps the dense
* activity feed unless a host explicitly asks for `conversation` (the
* full-cover focus view does). There is deliberately no width or layout
* heuristic here — an automatic wide-pane mode would be a separate product
* decision, and swapping the whole presentation as a reader drags a resize
* handle across a threshold is not one we want to make implicitly.
*/
transcriptVariant?: AgentSessionTranscriptVariant;
};
export function AgentSessionThreadPanel({
@@ -95,6 +105,7 @@ export function AgentSessionThreadPanel({
onClose,
widthPx,
transparentChrome = false,
transcriptVariant = "default",
}: AgentSessionThreadPanelProps) {
const isLive = isManagedAgentActive(agent);
const isOverlay = useIsThreadPanelOverlay();
@@ -495,6 +506,7 @@ export function AgentSessionThreadPanel({
rawLayout="exclusive"
showHeader={false}
showRaw={showRawFeed}
transcriptVariant={transcriptVariant}
/>
</div>
</AuxiliaryPanelBody>

View File

@@ -19,6 +19,31 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import { getReactNodeText } from "./utils";
/**
* Code-block presentation, supplied by the rendering surface.
*
* `focusProse` applies berd's recipe (`code-block.tsx` CodeBlockHeader:388-402,
* viewport:528-529): the language in a real header row above the frame with the
* copy action opposite it, a 10px radius, page-background fill behind a subtle
* border, and no shadow.
*
* It is opt-in because this renderer is shared with channel messages —
* restyling the default would change every code block in the app. The variant
* is a property of the *surface*, not of a role: `AgentSessionTranscriptList`
* provides it once at the transcript boundary, so a fence in a human prompt
* gets the same chrome as one in an agent reply.
*
* Delivered through context rather than a `MarkdownCodeBlock` prop for the same
* reason as `VideoReviewMarkdownContext`: the component map handed to
* react-markdown must stay module-stable. A prop would have to be threaded
* through `createMarkdownComponents`, which would also mean partitioning the
* parsed-node cache that identifies that map by variant string
* (`nodeCache.ts`). A context read at render time needs neither.
*/
export const CodeBlockVariantContext = React.createContext<
"default" | "focusProse"
>("default");
let shikiHighlighter: HighlighterGeneric<BundledLanguage, BundledTheme> | null =
null;
let shikiInitPromise: Promise<void> | null = null;
@@ -75,6 +100,8 @@ export function MarkdownCodeBlock({
const [isCopying, setIsCopying] = React.useState(false);
const codeBlockRef = React.useRef<HTMLPreElement | null>(null);
const code = React.useMemo(() => getCodeBlockText(children), [children]);
const isFocusProse =
React.useContext(CodeBlockVariantContext) === "focusProse";
useSmoothCorners(codeBlockRef);
const handleCopy = React.useCallback(
@@ -96,6 +123,52 @@ export function MarkdownCodeBlock({
[code],
);
const focusProseCopyButton = (
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label="Copy code block"
// In berd's header row the action is a flow sibling of the language
// label, so it needs no absolute placement, backdrop, or ring to stay
// legible over the code.
className="h-5 w-5 text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100 group-focus-within:opacity-100 disabled:opacity-60"
disabled={isCopying}
onClick={handleCopy}
size="icon"
type="button"
variant="ghost"
>
<Copy className="h-3.5 w-3.5" />
<span className="sr-only">Copy code block</span>
</Button>
</TooltipTrigger>
<TooltipContent>Copy code</TooltipContent>
</Tooltip>
);
if (isFocusProse) {
return (
<div className="group relative w-full min-w-0" data-code-block="">
<div
className="flex min-h-7 items-end justify-between gap-2 text-2xs leading-4 text-muted-foreground"
data-testid="markdown-code-block-header"
>
<span className="min-w-0 truncate font-mono">{language}</span>
{focusProseCopyButton}
</div>
<pre
className="relative max-h-[400px] min-w-0 max-w-full overflow-auto rounded-lg border border-border/80 bg-background px-3 py-1.5"
ref={codeBlockRef}
>
{children}
</pre>
</div>
);
}
// Unchanged default path: this renderer is shared with channel messages, so
// its markup stays byte-identical (including class order) to keep every other
// code block in the app exactly as it was.
return (
<div className="group relative" data-code-block="">
<pre

View File

@@ -63,6 +63,14 @@ export default {
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
maxWidth: {
// Focus-mode agent prompt bubble. Mirrors berd's
// `--chat-user-message-max-width: 640px` (shared/styles/globals.css):
// a fixed measure, not a percentage of the column, so the prompt keeps
// one stable line length as the full-cover view is resized. Not a text
// size, so the px-text guard does not apply.
"prompt-bubble": "640px",
},
spacing: {
4.5: "1.125rem",
"conversation-body": "var(--conversation-body-gap)",