From fd2e017998c666ccd520a0b430a3db881e32401a Mon Sep 17 00:00:00 2001
From: ss-dev-01
<11939edb7df583f855dbef923f2358f1184538f88ca452e19e7e35e42ad6d796@buzz.block.builderlab.xyz>
Date: Mon, 24 Aug 2026 15:45:47 -0700
Subject: [PATCH] fix(desktop): hide the decorative identity avatar and split
the conversation suite
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two review findings on #6720.
**P2 (Carl, CHANGES_REQUESTED): the identity row announced the agent
twice.** `UserAvatar` names itself — an `
`
or its fallback initials — and the row puts the same name in visible text
immediately after it, so assistive tech read the identity twice for every
agent turn in the transcript. Wrap the avatar in an `aria-hidden` span so
the visible name is the row's single accessible identity.
Marked at this call site rather than by teaching the shared `UserAvatar` a
decorative mode: other rows pair an avatar with adjacent name text 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.
Verified in Chromium's real accessibility tree over CDP, not from the DOM
attributes, through the channel-opened flow:
pre-fix role "image", name "Observer Agent avatar", ignored false
post-fix ignored true, ignoredReasons ["ariaHiddenSubtree"]
Geometry is unchanged across the two (row 114x20, avatar 20x20, label
90x16, 4px gap), so moving `shrink-0` onto the wrapper is not a visual
change. The new test is non-vacuous by two mutations: removing the
decorative marking fails it and only it, and re-adding a name-bearing
`aria-label` outside the hidden subtree fails its escape assertion with
the offending element named.
**P1 (Codex): the conversation test file exceeded the 1000-line
ceiling.** It reached 1253 lines. It slipped past `file-size-check`
because that script only scans `.ts`/`.tsx` under `src/features`, so the
gate I reported as green never covered this file — the rule in AGENTS.md
plainly does. Split into:
AgentSessionTranscriptList.conversationHarness.mjs 574 (shared)
AgentSessionTranscriptList.conversation.test.mjs 521
AgentSessionTranscriptList.conversationChrome.test.mjs 274
The shared jsdom setup, order-sensitive TZ/locale pins, fixtures and
render helpers live in a non-test harness module, following the existing
`observedUnreadTestHarness.mjs` precedent — duplicating those pins into
two files is exactly how they drift. Test bodies are moved verbatim; a
name-by-name diff against the previous head confirms zero tests lost and
exactly one added. Gate script untouched, per the routing of its `.mjs`
coverage gap to a separate follow-up.
Full desktop suite 5432 passing / 0 failing, including the byte-for-byte
`default`/`compactPreview` fixture.
Signed-off-by: ss-dev-01 <11939edb7df583f855dbef923f2358f1184538f88ca452e19e7e35e42ad6d796@buzz.block.builderlab.xyz>
Co-authored-by: Bradley Axen
Signed-off-by: Bradley Axen
---
...essionTranscriptList.conversation.test.mjs | 788 +-----------------
...TranscriptList.conversationChrome.test.mjs | 274 ++++++
...sionTranscriptList.conversationHarness.mjs | 576 +++++++++++++
.../activityRenderClasses/MessageActivity.tsx | 29 +-
4 files changed, 900 insertions(+), 767 deletions(-)
create mode 100644 desktop/src/features/agents/ui/AgentSessionTranscriptList.conversationChrome.test.mjs
create mode 100644 desktop/src/features/agents/ui/AgentSessionTranscriptList.conversationHarness.mjs
diff --git a/desktop/src/features/agents/ui/AgentSessionTranscriptList.conversation.test.mjs b/desktop/src/features/agents/ui/AgentSessionTranscriptList.conversation.test.mjs
index dd57a7f1f5..910932574f 100644
--- a/desktop/src/features/agents/ui/AgentSessionTranscriptList.conversation.test.mjs
+++ b/desktop/src/features/agents/ui/AgentSessionTranscriptList.conversation.test.mjs
@@ -1,559 +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";
+import { 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;
-
-/**
- * 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;
-
-let act;
-let cleanup;
-let render;
-let createElement;
-let useState;
-let createMemoryHistory;
-let createRootRoute;
-let createRouter;
-let RouterProvider;
-let AgentSessionTranscriptList;
-let ThemeProvider;
-let TooltipProvider;
-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,
- },
-};
-/**
- * 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.
- */
-const AGENT_AVATAR_URL = "https://cdn.example.test/agent-profile.png";
-const AGENT_PROFILES = {
- [AGENT.agentPubkey]: {
- displayName: "Test Agent",
- avatarUrl: AGENT_AVATAR_URL,
- 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, 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.
- */
-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. */
-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. */
-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.
- */
-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());
+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");
@@ -612,220 +94,6 @@ test("conversation caps the prompt bubble at a fixed measure, not a percentage",
assert.doesNotMatch(column.className, /max-w-\[\d+%\]/);
});
-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();
- }
-});
-
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",
@@ -964,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,
@@ -1021,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 0000000000..65b28bbab7
--- /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 0000000000..6dda85385d
--- /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/activityRenderClasses/MessageActivity.tsx b/desktop/src/features/agents/ui/activityRenderClasses/MessageActivity.tsx
index 6b42a637a5..9b1f8d588e 100644
--- a/desktop/src/features/agents/ui/activityRenderClasses/MessageActivity.tsx
+++ b/desktop/src/features/agents/ui/activityRenderClasses/MessageActivity.tsx
@@ -104,13 +104,28 @@ function MessageItem({
className="mb-0.5 flex items-center gap-1 text-xs"
data-testid="transcript-assistant-identity"
>
- {/* `size="xs"` is already 20px (`h-5 w-5`) in UserAvatar. */}
-
+ {/*
+ * 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}