diff --git a/AGENTS.md b/AGENTS.md
index b1f11bd3db1..0c43bd473c4 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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
diff --git a/desktop/src/features/agents/ui/AgentSessionTranscriptList.conversation.test.mjs b/desktop/src/features/agents/ui/AgentSessionTranscriptList.conversation.test.mjs
index 80afc41e840..910932574f3 100644
--- a/desktop/src/features/agents/ui/AgentSessionTranscriptList.conversation.test.mjs
+++ b/desktop/src/features/agents/ui/AgentSessionTranscriptList.conversation.test.mjs
@@ -1,437 +1,41 @@
/**
- * Rendering contract for the `conversation` transcript variant (focus mode).
+ * Rendering contract for the `conversation` transcript variant (focus mode):
+ * layout, prompt authorship, thoughts, plans, lifecycle chrome, and the
+ * byte-for-byte guarantee for the other variants.
*
* Mounts the shipping AgentSessionTranscriptList so the variant plumbing
* (variant context + derived turn meta) is exercised end to end rather than
* asserting against re-implemented render classes.
*
+ * The identity row and code-block chrome live in
+ * `AgentSessionTranscriptList.conversationChrome.test.mjs`; shared jsdom setup,
+ * ambient-formatting pins, and render helpers live in the harness both import.
+ *
* The byte-for-byte tests at the bottom are the important ones: `conversation`
- * is purely additive, so the `default` and `compactPreview` markup for the same
+ * is purely additive, so `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.
+ * existed. See the harness for how that fixture was produced.
*/
import assert from "node:assert/strict";
-import { readFileSync } from "node:fs";
-import { after, afterEach, before, test } 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";
-
-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";
-
-const BASELINE_MARKUP = JSON.parse(
- readFileSync(
- new URL(
- "./AgentSessionTranscriptList.conversation.baseline.json",
- import.meta.url,
- ),
- "utf8",
- ),
-);
-
-const dom = new JSDOM("
", {
- 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;
-
-let act;
-let cleanup;
-let render;
-let createElement;
-let useState;
-let createMemoryHistory;
-let createRootRoute;
-let createRouter;
-let RouterProvider;
-let AgentSessionTranscriptList;
-let resetActiveAgentTurnsStore;
-let syncAgentTurnsFromEvents;
-
-const AGENT = {
- agentAvatarUrl: null,
- agentName: "Test Agent",
- agentPubkey: "f".repeat(64),
-};
-const AUTHOR = "a".repeat(64);
-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";
-const AUTHOR_PROFILES = {
- [AUTHOR]: {
- displayName: "Ada Lovelace",
- avatarUrl: null,
- nip05Handle: null,
- ownerPubkey: null,
- },
-};
-
-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.
- */
-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",
- },
- ];
-}
-
-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, 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.
- */
-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"
- ));
-});
-
-afterEach(() => {
- cleanup?.();
- resetActiveAgentTurnsStore?.();
-});
-after(() => dom.window.close());
+import { test } from "node:test";
+
+import {
+ act,
+ AGENT,
+ AUTHOR,
+ AUTHOR_PROFILES,
+ AUTHOR_TRUNCATED,
+ BASELINE_MARKUP,
+ baselineItems,
+ cleanup,
+ domWindow,
+ FIXTURE_LOCALE,
+ items,
+ renderRerenderableTranscript,
+ renderTranscript,
+ syncAgentTurnsFromEvents,
+} from "./AgentSessionTranscriptList.conversationHarness.mjs";
test("conversation marks the transcript container and centers a reading column", async () => {
const { container } = await renderTranscript("conversation");
@@ -456,12 +60,40 @@ test("conversation renders the prompt as a filled right-aligned bubble with an a
'[data-testid="transcript-user-message"]',
);
assert.match(row.className, /justify-end/);
- const bubble = row.querySelector(".rounded-2xl");
+ // 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",
@@ -600,7 +232,7 @@ test("conversation folds the thought when the turn moves on, even after the brow
// event follows rather than causes that state.
await act(async () => {
disclosure.open = true;
- disclosure.dispatchEvent(new dom.window.Event("toggle"));
+ disclosure.dispatchEvent(new domWindow.Event("toggle"));
});
assert.equal(
disclosure.open,
@@ -657,7 +289,7 @@ test("conversation keeps a reader-opened thought open after the turn moves on",
await act(async () => {
disclosure.open = true;
- disclosure.dispatchEvent(new dom.window.Event("toggle"));
+ disclosure.dispatchEvent(new domWindow.Event("toggle"));
});
await setOverrides({ ...settledItems, items: items() });
diff --git a/desktop/src/features/agents/ui/AgentSessionTranscriptList.conversationChrome.test.mjs b/desktop/src/features/agents/ui/AgentSessionTranscriptList.conversationChrome.test.mjs
new file mode 100644
index 00000000000..65b28bbab75
--- /dev/null
+++ b/desktop/src/features/agents/ui/AgentSessionTranscriptList.conversationChrome.test.mjs
@@ -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 `` 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();
+ }
+});
diff --git a/desktop/src/features/agents/ui/AgentSessionTranscriptList.conversationHarness.mjs b/desktop/src/features/agents/ui/AgentSessionTranscriptList.conversationHarness.mjs
new file mode 100644
index 00000000000..6dda85385d8
--- /dev/null
+++ b/desktop/src/features/agents/ui/AgentSessionTranscriptList.conversationHarness.mjs
@@ -0,0 +1,576 @@
+/**
+ * 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("", {
+ 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.
+ */
+export 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/`
+ * 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());
+/**
+ * The jsdom window itself. Exported for the few tests that must construct a
+ * real DOM event (`new domWindow.Event("toggle")`) to simulate a browser echo.
+ */
+export const domWindow = dom.window;
diff --git a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx
index 779763989d7..9a5135c6f0c 100644
--- a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx
+++ b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx
@@ -16,6 +16,7 @@ import { useStableArrayShallow } from "@/shared/hooks/useStableReference";
import { cn } from "@/shared/lib/cn";
import { AnimatedCount } from "@/shared/ui/AnimatedCount";
import { FuzzyLogo } from "@/shared/ui/buzz-logo/FuzzyLogo";
+import { CodeBlockVariantContext } from "@/shared/ui/markdown/CodeBlock";
import type { TranscriptItem } from "./agentSessionTypes";
import { TurnLivenessIndicator } from "./TurnLivenessIndicator";
import {
@@ -229,41 +230,53 @@ export function AgentSessionTranscriptList({
ref={autoTail ? contentRef : undefined}
role="log"
>
-
-
- {displayBlocks.map((block) => {
- const blockKey = getDisplayBlockKey(block);
- return (
-
- {/* 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. */}
+
+
+
+ {displayBlocks.map((block) => {
+ const blockKey = getDisplayBlockKey(block);
+ return (
+
+ {/* 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. */}
-
+
+ );
+ })}
+ {isTurnLive && !isCompactPreview ? (
+
+ ) : null}
+
+
+
);
diff --git a/desktop/src/features/agents/ui/activityRenderClasses/MessageActivity.tsx b/desktop/src/features/agents/ui/activityRenderClasses/MessageActivity.tsx
index b819050b528..9b1f8d588ed 100644
--- a/desktop/src/features/agents/ui/activityRenderClasses/MessageActivity.tsx
+++ b/desktop/src/features/agents/ui/activityRenderClasses/MessageActivity.tsx
@@ -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,13 +21,27 @@ export function MessageActivity(props: ActivityRenderClassItemProps) {
return null;
}
- return ;
+ return (
+
+ );
}
function MessageItem({
+ agentAvatarUrl,
+ agentName,
+ agentPubkey,
item,
profiles,
}: {
+ agentAvatarUrl: string | null;
+ agentName: string;
+ agentPubkey: string;
item: Extract;
profiles?: UserProfileLookup;
}) {
@@ -32,6 +51,25 @@ function MessageItem({
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 (
@@ -55,6 +93,44 @@ function MessageItem({
data-testid="transcript-assistant-message"
>
+ {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.
+
+ {/*
+ * 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.
+ */}
+
+
+
+
+ {resolvedAgentName}
+
+
+ ) : null}
@@ -176,16 +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",
- // A filled surface (not just a hairline) is what makes the prompt
- // read as the human's turn at a glance in focus mode.
- isConversation && "border-transparent bg-muted/60 px-4 py-2.5",
+ // 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}
>
diff --git a/desktop/src/shared/ui/markdown/CodeBlock.tsx b/desktop/src/shared/ui/markdown/CodeBlock.tsx
index 9954b03aa6c..30748d236e5 100644
--- a/desktop/src/shared/ui/markdown/CodeBlock.tsx
+++ b/desktop/src/shared/ui/markdown/CodeBlock.tsx
@@ -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 | null =
null;
let shikiInitPromise: Promise | null = null;
@@ -75,6 +100,8 @@ export function MarkdownCodeBlock({
const [isCopying, setIsCopying] = React.useState(false);
const codeBlockRef = React.useRef(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 = (
+
+
+
+
+ Copy code
+
+ );
+
+ if (isFocusProse) {
+ return (
+
+
+ {language}
+ {focusProseCopyButton}
+
+
+ {children}
+
+
+ );
+ }
+
+ // 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 (