From f36b699220d20ec94ccd99967fa50b3e5d3c266d Mon Sep 17 00:00:00 2001 From: ss-dev-01 <11939edb7df583f855dbef923f2358f1184538f88ca452e19e7e35e42ad6d796@buzz.block.builderlab.xyz> Date: Mon, 24 Aug 2026 13:08:42 -0700 Subject: [PATCH 1/4] style(desktop): bring the conversation variant closer to berd's recipes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dev-00's design-delta note measured the conversation variant against berd's shipping chat surface and named three divergences that make the focus view read as "not berd" regardless of how the rest is arranged. This takes all three literally, on Buzz tokens rather than berd's. **Agent identity row.** The largest gap was that agent prose carried no attribution at all, so a reply read as unowned body text in a full-cover view. berd labels every agent turn with a 20px round avatar plus the name at `text-xs`, `mb-0.5`, `gap-1` (`MessageBubble.tsx:961-981`). Added for the `conversation` variant only — the other variants' markup is pinned byte-for-byte by the baseline fixture, and a test asserts the row does not leak into them so a failure names the cause instead of printing a markup diff. **Prompt bubble.** berd's user turn is a soft fill with no border at all, `px-4 py-2`, and a 12px radius (`MessageBubble.tsx:990`). 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 equivalent, so the radius drops from 16px rather than collapsing to a hairline. The cap moves from `max-w-[85%]` to a fixed 640px measure mirroring berd's `--chat-user-message-max-width` (`globals.css:615`): a percentage cap re-wraps the prompt on every resize of the cover, while a fixed measure holds one stable line length. **Fenced code.** berd puts the language in a real header row above the frame with the copy action opposite it, and frames the code at a 10px radius on the page background behind a subtle border, with no shadow (`ai-elements/code-block.tsx:379`, `:395`, `:528-529`). Buzz's `rounded-lg` is `--radius: 0.625rem` — exactly berd's `rounded-[0.625rem]`. The markdown renderer is shared with channel messages, so this recipe is opt-in: `CodeBlockVariantContext` is read at render time by `MarkdownCodeBlock` and provided by `MessageActivity`. A prop would have to thread through `createMarkdownComponents`, whose component map must stay module-stable and whose parsed-node cache keys on a variant string — the same reason `VideoReviewMarkdownContext` already exists. A context provider renders no DOM, so `MessageActivity` can wrap unconditionally and `default`/`compactPreview` markup stays byte-identical. A companion test renders the same fenced block through `default` and asserts the original chrome (16px radius, muted fill, `pr-12`, absolutely-positioned copy button) is untouched. Fenced blocks need `ThemeProvider` and `TooltipProvider` to mount, so those tests use a separate `renderTranscriptWithCodeChrome` helper; the byte-for-byte fixture keeps rendering through the exact tree it was captured with. Both new code-block tests were proven non-vacuous by mutation: forcing the provider to `default` everywhere fails only the focus-header test, and forcing `focusProse` everywhere fails only the channel-message guard. That guard is written as `assert.ok(x === null)` rather than `assert.equal(x, null)` — on failure the latter serializes the matched jsdom element and its subtree to build a diff, which exhausts memory instead of printing the message. Verified at this tree: full desktop suite 5427 passing / 0 failing, `tsc --noEmit` clean, `biome check` at main's baseline, px-text / pubkey-truncation / file-size checks clean. 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 | 201 +++++++++++++++++- .../activityRenderClasses/MessageActivity.tsx | 70 ++++-- .../UserMessageBubble.tsx | 30 ++- desktop/src/shared/ui/markdown/CodeBlock.tsx | 70 ++++++ desktop/tailwind.config.js | 8 + 5 files changed, 356 insertions(+), 23 deletions(-) diff --git a/desktop/src/features/agents/ui/AgentSessionTranscriptList.conversation.test.mjs b/desktop/src/features/agents/ui/AgentSessionTranscriptList.conversation.test.mjs index 80afc41e840..73abe860e84 100644 --- a/desktop/src/features/agents/ui/AgentSessionTranscriptList.conversation.test.mjs +++ b/desktop/src/features/agents/ui/AgentSessionTranscriptList.conversation.test.mjs @@ -130,6 +130,8 @@ let createRootRoute; let createRouter; let RouterProvider; let AgentSessionTranscriptList; +let ThemeProvider; +let TooltipProvider; let resetActiveAgentTurnsStore; let syncAgentTurnsFromEvents; @@ -379,6 +381,60 @@ async function renderTranscript(variant, overrides = {}) { 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", + }, + ]; +} + /** * 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 @@ -425,6 +481,8 @@ before(async () => { ({ resetActiveAgentTurnsStore, syncAgentTurnsFromEvents } = await import( "../activeAgentTurnsStore.ts" )); + ({ ThemeProvider } = await import("@/shared/theme/ThemeProvider.tsx")); + ({ TooltipProvider } = await import("@/shared/ui/tooltip.tsx")); }); afterEach(() => { @@ -456,12 +514,153 @@ 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 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("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("channel-message code blocks are untouched by the focus recipe", async () => { + // The markdown renderer is shared with channel messages, so `focusProse` is + // opt-in per surface. Rendering the same fenced block through `default` must + // still produce the original chrome: no header row, 16px radius, muted fill, + // and the absolutely-positioned copy button. + 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", diff --git a/desktop/src/features/agents/ui/activityRenderClasses/MessageActivity.tsx b/desktop/src/features/agents/ui/activityRenderClasses/MessageActivity.tsx index b819050b528..84d4a4cd355 100644 --- a/desktop/src/features/agents/ui/activityRenderClasses/MessageActivity.tsx +++ b/desktop/src/features/agents/ui/activityRenderClasses/MessageActivity.tsx @@ -1,5 +1,7 @@ import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { Markdown } from "@/shared/ui/markdown"; +import { CodeBlockVariantContext } from "@/shared/ui/markdown/CodeBlock"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; import { useAgentSessionTranscriptVariant } from "../agentSessionTranscriptContext"; import { formatTranscriptTimestampTitle } from "../agentSessionUtils"; import type { TranscriptItem } from "../agentSessionTypes"; @@ -16,13 +18,24 @@ export function MessageActivity(props: ActivityRenderClassItemProps) { return null; } - return ; + return ( + + ); } function MessageItem({ + agentAvatarUrl, + agentName, item, profiles, }: { + agentAvatarUrl: string | null; + agentName: string; item: Extract; profiles?: UserProfileLookup; }) { @@ -55,6 +68,29 @@ 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. +
+ {/* `size="xs"` is already 20px (`h-5 w-5`) in UserAvatar. */} + + + {agentName} + +
+ ) : null}
- + {/* A context provider renders no DOM, so wrapping unconditionally + keeps `default`/`compactPreview` markup byte-identical. */} + + +
diff --git a/desktop/src/features/agents/ui/activityRenderClasses/UserMessageBubble.tsx b/desktop/src/features/agents/ui/activityRenderClasses/UserMessageBubble.tsx index 20f0ff1f44c..02980f61117 100644 --- a/desktop/src/features/agents/ui/activityRenderClasses/UserMessageBubble.tsx +++ b/desktop/src/features/agents/ui/activityRenderClasses/UserMessageBubble.tsx @@ -154,10 +154,12 @@ export function UserMessageBubble({ className={cn( "group relative flex min-w-0 flex-1 flex-col items-end gap-1", isCompactPreview && "items-start", - // Focus mode caps the prompt to a chat-bubble measure rather than the - // full reading column, so the right-aligned turn opener reads as an - // utterance against the agent's full-width prose. - isConversation && "max-w-[85%] flex-initial", + // berd caps the user turn at a fixed measure, not a percentage of the + // column (`--chat-user-message-max-width: 640px`, + // MessageBubble.tsx:956): a percentage keeps re-wrapping the prompt as + // the cover width changes, while a fixed measure holds one stable + // reading line length. `max-w-prompt-bubble` carries the 640px token. + isConversation && "max-w-prompt-bubble flex-initial", className, )} > @@ -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..d804faa1a39 100644 --- a/desktop/src/shared/ui/markdown/CodeBlock.tsx +++ b/desktop/src/shared/ui/markdown/CodeBlock.tsx @@ -19,6 +19,28 @@ 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. + * + * 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 +97,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 +120,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 (
Date: Mon, 24 Aug 2026 13:24:51 -0700
Subject: [PATCH 2/4] fix(desktop): apply the focus code recipe to fenced human
 prompts too
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Quality review caught a real bug in the previous commit. The
`CodeBlockVariantContext` provider was mounted inside `MessageActivity`,
which only handles assistant items — `UserMessageBubble` returns before
it. A fence inside a human prompt therefore kept the legacy 16px muted
frame, nested inside the new 12px prompt bubble.

The recipe is a property of the *surface*, not of a role: a fenced block
in a prompt should read the same as one in a reply. The provider moves up
to `AgentSessionTranscriptList`, alongside the variant and turn-meta
providers, so every descendant markdown render inherits it. It still
renders no DOM, so `default`/`compactPreview` markup stays byte-identical
and the baseline fixture is unchanged.

Added a fenced-prompt test, which the existing fenced test could not
catch because it only rendered an assistant item. Proven non-vacuous two
ways: pinning the boundary provider to `default` fails both fenced tests,
and re-adding a role-scoped `default` provider around only the user
bubble's markdown fails the new prompt test alone.

Also renamed `channel-message code blocks are untouched by the focus
recipe` to `the default transcript variant keeps the legacy code chrome`.
It renders the `default` transcript variant, not a channel message row, so
the old name claimed a contract it did not prove.

AGENTS.md gains the `assert.equal(el, null)` note: that form serializes
the matched element's whole subtree to build a failure diff and OOMs the
runner instead of printing the message, which made a genuine failure
unreadable. Use `assert.ok(x === null)`.

Verified at this tree: full desktop suite 5428 passing / 0 failing,
`tsc --noEmit` clean, `biome check` at main's baseline, px-text /
pubkey-truncation / file-size clean. Screenshots captured light and dark
by temporarily pinning `transcriptVariant="conversation"` in
`AgentSessionThreadPanel`; that pin is dev-00's slice and is not in this
commit.

Signed-off-by: ss-dev-01 <11939edb7df583f855dbef923f2358f1184538f88ca452e19e7e35e42ad6d796@buzz.block.builderlab.xyz>
Co-authored-by: Bradley Axen 
Signed-off-by: Bradley Axen 
---
 AGENTS.md                                     | 18 +++++
 ...essionTranscriptList.conversation.test.mjs | 59 +++++++++++++-
 .../agents/ui/AgentSessionTranscriptList.tsx  | 79 +++++++++++--------
 .../activityRenderClasses/MessageActivity.tsx | 33 +++-----
 desktop/src/shared/ui/markdown/CodeBlock.tsx  |  5 +-
 5 files changed, 136 insertions(+), 58 deletions(-)

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 73abe860e84..a0676857d80 100644
--- a/desktop/src/features/agents/ui/AgentSessionTranscriptList.conversation.test.mjs
+++ b/desktop/src/features/agents/ui/AgentSessionTranscriptList.conversation.test.mjs
@@ -435,6 +435,26 @@ function fencedCodeItems() {
   ];
 }
 
+/** 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
@@ -619,11 +639,42 @@ test("conversation frames fenced code with berd's header row", async () => {
   assert.equal(code.querySelectorAll("[data-line]").length, 2);
 });
 
-test("channel-message code blocks are untouched by the focus recipe", async () => {
+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 `default` must
-  // still produce the original chrome: no header row, 16px radius, muted fill,
-  // and the absolutely-positioned copy button.
+  // 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(),
   });
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} -
-
+
+ +
+
+ ); + })} + {isTurnLive && !isCompactPreview ? ( + + ) : null} +
+
+
); diff --git a/desktop/src/features/agents/ui/activityRenderClasses/MessageActivity.tsx b/desktop/src/features/agents/ui/activityRenderClasses/MessageActivity.tsx index 84d4a4cd355..8451eaa9ce1 100644 --- a/desktop/src/features/agents/ui/activityRenderClasses/MessageActivity.tsx +++ b/desktop/src/features/agents/ui/activityRenderClasses/MessageActivity.tsx @@ -1,6 +1,5 @@ import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { Markdown } from "@/shared/ui/markdown"; -import { CodeBlockVariantContext } from "@/shared/ui/markdown/CodeBlock"; import { UserAvatar } from "@/shared/ui/UserAvatar"; import { useAgentSessionTranscriptVariant } from "../agentSessionTranscriptContext"; import { formatTranscriptTimestampTitle } from "../agentSessionUtils"; @@ -99,25 +98,19 @@ function MessageItem({ } title={formatTranscriptTimestampTitle(item.timestamp)} > - {/* A context provider renders no DOM, so wrapping unconditionally - keeps `default`/`compactPreview` markup byte-identical. */} - - - + diff --git a/desktop/src/shared/ui/markdown/CodeBlock.tsx b/desktop/src/shared/ui/markdown/CodeBlock.tsx index d804faa1a39..30748d236e5 100644 --- a/desktop/src/shared/ui/markdown/CodeBlock.tsx +++ b/desktop/src/shared/ui/markdown/CodeBlock.tsx @@ -28,7 +28,10 @@ import { getReactNodeText } from "./utils"; * 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. + * 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 From e8709554a3960bc5756c8b0a42719b055a7dc9a1 Mon Sep 17 00:00:00 2001 From: ss-dev-01 <11939edb7df583f855dbef923f2358f1184538f88ca452e19e7e35e42ad6d796@buzz.block.builderlab.xyz> Date: Mon, 24 Aug 2026 15:01:25 -0700 Subject: [PATCH 3/4] fix(desktop): resolve the focus identity row agent through the profiles lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The conversation variant's identity row read the raw `agentAvatarUrl` and `agentName` props. The primary channel-opened flow passes `agentAvatarUrl: null`, because `ChannelAgentSessionAgent` (useChannelAgentSessions.ts:21-29) carries no avatar field at all — so every channel-opened focus session showed initials in the identity row while the panel header directly above it showed the real profile avatar. The two disagreed about the same agent on the same surface. Resolve profile-first out of the `profiles` lookup the panel already hands down, exactly as `ToolItem` (ToolItem.tsx:45-52) and the panel header (AgentSessionThreadPanel.tsx:243-249) already do. The props stay as the fallback rather than being replaced: a locally managed agent can hold an avatar its relay profile never published. Found by ss-bugs-02's bug pass on #6720. Three tests: the profile avatar wins over a null prop (the channel path), the profile display name wins over a stale prop name, and the caller's avatar survives when the lookup has none. Proven non-vacuous by three mutations — reverting the resolution fails exactly the two resolution tests, dropping the `?? agentAvatarUrl` fallback fails only the fallback test, and removing the `LoadedImageStub` fails both avatar tests. That stub is load-bearing, not masking: Radix `AvatarImage` renders nothing until its own preloader reports `loaded`, and jsdom never fetches, so both avatar assertions would otherwise have passed vacuously against the initials fallback — the very bug under test. It only affects avatars that have a url, so the byte-for-byte `default`/`compactPreview` baseline fixture stays byte-identical. Verified empirically through the real channel-opened flow in a Chromium build, not just jsdom. Pre-fix the row rendered `hasImg: false` with an "OA" initials fallback while the header showed the real image; post-fix it renders the decoded `` and the resolved name. That probe harness is untracked and not in this commit. 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 | 114 ++++++++++++++++++ .../activityRenderClasses/MessageActivity.tsx | 34 +++++- 2 files changed, 144 insertions(+), 4 deletions(-) diff --git a/desktop/src/features/agents/ui/AgentSessionTranscriptList.conversation.test.mjs b/desktop/src/features/agents/ui/AgentSessionTranscriptList.conversation.test.mjs index a0676857d80..dd57a7f1f56 100644 --- a/desktop/src/features/agents/ui/AgentSessionTranscriptList.conversation.test.mjs +++ b/desktop/src/features/agents/ui/AgentSessionTranscriptList.conversation.test.mjs @@ -120,6 +120,36 @@ 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; @@ -158,6 +188,20 @@ const AUTHOR_PROFILES = { 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" }; @@ -592,6 +636,76 @@ test("conversation labels the agent turn with a berd-style identity row", async 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 diff --git a/desktop/src/features/agents/ui/activityRenderClasses/MessageActivity.tsx b/desktop/src/features/agents/ui/activityRenderClasses/MessageActivity.tsx index 8451eaa9ce1..6b42a637a59 100644 --- a/desktop/src/features/agents/ui/activityRenderClasses/MessageActivity.tsx +++ b/desktop/src/features/agents/ui/activityRenderClasses/MessageActivity.tsx @@ -1,4 +1,8 @@ -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"; @@ -21,6 +25,7 @@ export function MessageActivity(props: ActivityRenderClassItemProps) { @@ -30,11 +35,13 @@ export function MessageActivity(props: ActivityRenderClassItemProps) { function MessageItem({ agentAvatarUrl, agentName, + agentPubkey, item, profiles, }: { agentAvatarUrl: string | null; agentName: string; + agentPubkey: string; item: Extract; profiles?: UserProfileLookup; }) { @@ -44,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 ( @@ -80,13 +106,13 @@ function MessageItem({ > {/* `size="xs"` is already 20px (`h-5 w-5`) in UserAvatar. */} - {agentName} + {resolvedAgentName} ) : null} 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 4/4] 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 `${displayName} avatar` 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 | 790 +----------------- ...TranscriptList.conversationChrome.test.mjs | 274 ++++++ ...sionTranscriptList.conversationHarness.mjs | 576 +++++++++++++ .../activityRenderClasses/MessageActivity.tsx | 29 +- 4 files changed, 901 insertions(+), 768 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 dd57a7f1f56..910932574f3 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"; - -// 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 { 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"); @@ -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 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 `${displayName} avatar` or + // its fallback initials — and the row puts the agent's name in visible text + // immediately after it. Left unhidden, assistive tech reads the same identity + // twice for every single agent turn in the transcript. The visible name is + // the row's one accessible identity; the avatar is decorative. + const { container } = await renderTranscript("conversation", { + agentAvatarUrl: null, + profiles: AGENT_PROFILES, + }); + const identity = container.querySelector( + '[data-testid="transcript-assistant-identity"]', + ); + assert.ok(identity, "the identity row should render"); + + // The avatar is present and still shows the resolved image ... + const decorative = identity.querySelector('[aria-hidden="true"]'); + assert.ok(decorative, "the avatar must be hidden from the accessible tree"); + const image = decorative.querySelector("img"); + assert.ok(image, "hiding the avatar must not stop it rendering visually"); + assert.equal(image.getAttribute("src"), AGENT_AVATAR_URL); + + // ... but every name-bearing node it contains is inside the hidden subtree, + // so the accessible tree is left with exactly one copy of the name. + const named = [...identity.querySelectorAll("img[alt], [aria-label]")]; + assert.ok(named.length > 0, "the avatar should still expose a raw alt/label"); + for (const node of named) { + assert.ok( + node.closest('[aria-hidden="true"]') !== null, + `${node.tagName} carrying an accessible name escapes the hidden avatar subtree`, + ); + } + + // The one remaining accessible identity is the visible name text. + assert.equal(identity.textContent, "Test Agent"); +}); + +test("conversation labels the agent turn with a berd-style identity row", async () => { + // The single biggest divergence from berd was that agent prose carried no + // attribution at all. berd puts a 20px round avatar + the agent name at + // `text-xs` above every reply (MessageBubble.tsx:961-981). + const { container } = await renderTranscript("conversation"); + const identity = container.querySelector( + '[data-testid="transcript-assistant-identity"]', + ); + assert.ok(identity, "conversation should label the agent turn"); + assert.match(identity.textContent, /Test Agent/); + assert.match(identity.className, /text-xs/); + assert.match(identity.className, /gap-1(?!\d)/); + // 20px avatar, berd's size (UserAvatar `size="xs"` → `h-5 w-5`). + assert.ok( + identity.querySelector(".h-5.w-5"), + "identity row should carry a 20px avatar", + ); + // The prose itself stays unboxed and full-width. + const message = container.querySelector( + '[data-testid="transcript-assistant-message"]', + ); + assert.doesNotMatch(message.innerHTML, /rounded-2xl/); +}); + +test("the conversation identity row resolves the agent avatar from the profiles lookup", async () => { + // Regression guard for the primary channel flow. `ChannelAgentSessionAgent` + // (useChannelAgentSessions.ts:21-29) has no avatar field at all, so when the + // focus conversation is opened from a channel the panel passes + // `agentAvatarUrl: null` — which is exactly this mount. The row must still + // show the configured avatar by resolving it out of the `profiles` lookup the + // panel already hands down, the same way the ToolItem row + // (ToolItem.tsx:45-52) and the panel header above it already do. Before the + // fix, every channel-opened session fell back to initials. + const { container } = await renderTranscript("conversation", { + agentAvatarUrl: null, + profiles: AGENT_PROFILES, + }); + const identity = container.querySelector( + '[data-testid="transcript-assistant-identity"]', + ); + assert.ok(identity, "the identity row should render"); + const image = identity.querySelector("img"); + assert.ok( + image, + "the profile avatar must win over the caller's null agent record avatar", + ); + assert.equal(image.getAttribute("src"), AGENT_AVATAR_URL); +}); + +test("the conversation identity row resolves the agent name the same way the header does", async () => { + // The row sits directly under the panel header, which labels the same agent + // through `resolveUserLabel` (AgentSessionThreadPanel.tsx:244-249). Reading + // the raw `agentName` prop instead let the two disagree whenever the relay + // profile's display name differed from the caller's agent record. + const { container } = await renderTranscript("conversation", { + agentName: "stale-record-name", + profiles: { + [AGENT.agentPubkey]: { + displayName: "Profile Display Name", + avatarUrl: null, + nip05Handle: null, + ownerPubkey: null, + }, + }, + }); + const identity = container.querySelector( + '[data-testid="transcript-assistant-identity"]', + ); + assert.equal(identity.textContent, "Profile Display Name"); +}); + +test("the conversation identity row keeps the caller's avatar when the lookup has none", async () => { + // The managed-agent path is the other direction: a locally managed agent can + // carry an avatar its relay profile never published. The prop stays the + // fallback, so resolving profile-first must not drop it. + const localAvatar = "https://cdn.example.test/local-managed.png"; + const { container } = await renderTranscript("conversation", { + agentAvatarUrl: localAvatar, + profiles: { + [AGENT.agentPubkey]: { + displayName: "Test Agent", + avatarUrl: null, + nip05Handle: null, + ownerPubkey: null, + }, + }, + }); + const image = container + .querySelector('[data-testid="transcript-assistant-identity"]') + .querySelector("img"); + assert.ok(image, "the caller-supplied avatar should still render"); + assert.equal(image.getAttribute("src"), localAvatar); +}); + +test("conversation frames fenced code with berd's header row", async () => { + // berd puts the language in a real header row above the frame, with the copy + // action opposite it (`code-block.tsx` CodeBlockHeader:388-402), and the code + // itself in a 10px-radius, page-background, borderless-shadow frame + // (:528-529). Buzz's `rounded-lg` (`--radius: 0.625rem`) is exactly berd's + // `rounded-[0.625rem]`. + const { container } = await renderTranscriptWithCodeChrome("conversation", { + items: fencedCodeItems(), + }); + const header = container.querySelector( + '[data-testid="markdown-code-block-header"]', + ); + assert.ok(header, "focus mode should render a code-block header row"); + // Language sits in the header, not inside the frame. + assert.match(header.textContent, /^ts/); + assert.match(header.className, /justify-between/); + assert.match(header.className, /items-end/); + assert.match(header.className, /min-h-7/); + assert.ok( + header.querySelector('[aria-label="Copy code block"]'), + "the copy action is a flow sibling of the language label", + ); + + const frame = container.querySelector("pre"); + assert.ok(frame, "the code frame should render"); + assert.match(frame.className, /rounded-lg/); + assert.match(frame.className, /bg-background/); + assert.match(frame.className, /border-border\/80/); + assert.doesNotMatch( + frame.className, + /shadow/, + "berd's code frame carries no shadow", + ); + // Guards against the default recipe leaking in: it uses a 16px radius, a + // muted fill, `pr-12` to clear an absolutely-positioned copy button, and an + // inline `borderRadius` style. + assert.doesNotMatch(frame.className, /rounded-2xl/); + assert.doesNotMatch(frame.className, /bg-muted/); + assert.doesNotMatch(frame.className, /pr-12/); + assert.equal(frame.style.borderRadius, ""); + // Line numbers come from `.code-block-lines [data-line]` in markdown.css, so + // the frame only has to keep emitting per-line elements under that class. + const code = frame.querySelector("code.code-block-lines"); + assert.ok(code, "the code element keeps the line-number class"); + assert.equal(code.querySelectorAll("[data-line]").length, 2); +}); + +test("conversation applies the code recipe to a fenced human prompt too", async () => { + // Regression guard for a real bug quality caught. The provider was first + // mounted inside `MessageActivity`, which only handles assistant items — the + // user bubble returns before it, so a fence inside a prompt kept the legacy + // 16px muted frame nested inside the new 12px bubble. The recipe is a + // property of the *surface*, not of a role, so the provider now sits at the + // transcript boundary and both roles inherit it. + const { container } = await renderTranscriptWithCodeChrome("conversation", { + items: fencedCodePromptItems(), + }); + const bubble = container.querySelector( + '[data-testid="transcript-user-message"]', + ); + assert.ok(bubble, "the prompt should render"); + assert.ok( + bubble.querySelector('[data-testid="markdown-code-block-header"]'), + "a fence inside the prompt gets berd's header row", + ); + const frame = bubble.querySelector("pre"); + assert.match(frame.className, /rounded-lg/); + assert.doesNotMatch( + frame.className, + /rounded-2xl/, + "the legacy 16px frame must not nest inside the 12px bubble", + ); + assert.doesNotMatch(frame.className, /pr-12/); +}); + +test("the default transcript variant keeps the legacy code chrome", async () => { + // The markdown renderer is shared with channel messages, so `focusProse` is + // opt-in per surface. Rendering the same fenced block through the `default` + // transcript variant must still produce the original chrome: no header row, + // 16px radius, muted fill, and the absolutely-positioned copy button. + // + // This proves the *variant gate*, not the channel-message row itself — those + // rows are covered by the markdown tests in `shared/ui/markdown`. + const { container } = await renderTranscriptWithCodeChrome("default", { + items: fencedCodeItems(), + }); + // `assert.ok(x === null)` rather than `assert.equal(x, null)`: on failure the + // latter serializes the whole matched jsdom element (and its ancestors) to + // build a diff, which exhausts memory instead of printing the message. + assert.ok( + container.querySelector('[data-testid="markdown-code-block-header"]') === + null, + "the default recipe has no header row", + ); + const frame = container.querySelector("pre"); + assert.match(frame.className, /rounded-2xl/); + assert.match(frame.className, /bg-muted\/60/); + assert.match(frame.className, /pr-12/); + assert.match(frame.className, /shadow-xs/); + const copy = container.querySelector('[aria-label="Copy code block"]'); + assert.ok(copy, "the default copy button still renders"); + assert.match(copy.className, /absolute/); +}); + +test("the identity row is conversation-only", async () => { + // `default`/`compactPreview` markup is pinned byte-for-byte, so the identity + // row must not leak into them. The fixture comparison would catch this too; + // this asserts it directly so the failure names the cause. + for (const variant of ["default", "compactPreview"]) { + const { container } = await renderTranscript(variant); + assert.ok( + container.querySelector( + '[data-testid="transcript-assistant-identity"]', + ) === null, + `${variant} must not render the identity row`, + ); + cleanup(); + } +}); 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/activityRenderClasses/MessageActivity.tsx b/desktop/src/features/agents/ui/activityRenderClasses/MessageActivity.tsx index 6b42a637a59..9b1f8d588ed 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}