diff --git a/AGENTS.md b/AGENTS.md index b1f11bd3db1..0c43bd473c4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -256,6 +256,24 @@ scripts build the required E2E bridge before running Playwright. See [TESTING.md](TESTING.md) for the full multi-agent E2E guide. +### Never assert a DOM element against `null` with `assert.equal` + +In the desktop jsdom tests (`desktop/src/**/*.test.mjs`), write + +```js +assert.ok(container.querySelector(sel) === null, "no header row"); +``` + +**not** `assert.equal(container.querySelector(sel), null, ...)`. Both pass +identically, but when the `assert.equal` form *fails*, node serializes the +matched element and its entire subtree to build a diff. On a real transcript +that exhausts memory and the runner dies with SIGKILL after ~100s instead of +printing the assertion message — so a genuine regression is unreadable and +looks like a hang or an OOM in unrelated code. The `assert.ok(x === null)` form +fails in milliseconds with the message you wrote. + +Comparing `getAttribute(...)` to `null` is fine — attributes are strings. + ### PR Screenshots > **Do NOT use `buzz upload`, the relay media endpoint, or any third-party diff --git a/desktop/src/features/agents/ui/AgentSessionTranscriptList.conversation.test.mjs b/desktop/src/features/agents/ui/AgentSessionTranscriptList.conversation.test.mjs index 80afc41e840..910932574f3 100644 --- a/desktop/src/features/agents/ui/AgentSessionTranscriptList.conversation.test.mjs +++ b/desktop/src/features/agents/ui/AgentSessionTranscriptList.conversation.test.mjs @@ -1,437 +1,41 @@ /** - * Rendering contract for the `conversation` transcript variant (focus mode). + * Rendering contract for the `conversation` transcript variant (focus mode): + * layout, prompt authorship, thoughts, plans, lifecycle chrome, and the + * byte-for-byte guarantee for the other variants. * * Mounts the shipping AgentSessionTranscriptList so the variant plumbing * (variant context + derived turn meta) is exercised end to end rather than * asserting against re-implemented render classes. * + * The identity row and code-block chrome live in + * `AgentSessionTranscriptList.conversationChrome.test.mjs`; shared jsdom setup, + * ambient-formatting pins, and render helpers live in the harness both import. + * * The byte-for-byte tests at the bottom are the important ones: `conversation` - * is purely additive, so the `default` and `compactPreview` markup for the same + * is purely additive, so `default` and `compactPreview` markup for the same * transcript must be byte-identical to the markup captured before the variant - * existed. That snapshot lives in - * AgentSessionTranscriptList.conversation.baseline.json and was produced by - * mounting `baselineItems()` — a transcript containing every renderable item - * kind across two sessions — on pre-change main (074561233) in a clean - * throwaway worktree. Regenerate it only when a deliberate change to the other - * variants is being made. + * existed. See the harness for how that fixture was produced. */ import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; -import { after, afterEach, before, test } from "node:test"; - -// The captured markup embeds formatted dates and times, so the fixture is only -// reproducible if every ambient formatting input is pinned. Two of them bite: -// -// - **Zone.** `formatTranscriptTimestampTitle` formats in the ambient zone -// ("… at 7:00:01 PM"), so a capture at UTC-7 fails against CI's UTC. -// - **Locale.** The session-boundary divider uses a bare `toLocaleString()` -// (`AgentSessionTranscriptChrome.tsx`), which is locale-sensitive as well as -// zone-sensitive: "6/14/2026, 7:05:00 PM" becomes "14.6.2026, 19:05:00" -// under de-DE. Node derives its default locale from LANG/LC_ALL, so this -// varies by machine independently of the zone. -// -// `TZ` can be set here because `Date` reads it lazily. The locale CANNOT: node -// resolves its default locale once at startup, so assigning `process.env.LANG` -// at runtime has no effect (verified — it silently keeps the startup locale). -// Pinning it therefore means overriding the two formatting surfaces the render -// path can reach: `Intl.DateTimeFormat` when constructed with no explicit -// locale, and `Date.prototype.toLocale*`, which does NOT route through -// `Intl.DateTimeFormat` and so needs its own patch. -// -// All of this must happen before the transcript modules are imported: their -// `Intl.DateTimeFormat` instances are module-level constants that resolve zone -// and locale once, at construction. -process.env.TZ = "UTC"; - -const FIXTURE_LOCALE = "en-US"; -const OriginalDateTimeFormat = Intl.DateTimeFormat; -// A plain function, not an arrow: the render path calls -// `new Intl.DateTimeFormat(...)`, and an arrow function is not a constructor. -// Returning a genuine instance keeps `new`, plain calls, and `instanceof` all -// working. -function LocalePinnedDateTimeFormat(locales, options) { - return new OriginalDateTimeFormat(locales ?? FIXTURE_LOCALE, options); -} -LocalePinnedDateTimeFormat.prototype = OriginalDateTimeFormat.prototype; -LocalePinnedDateTimeFormat.supportedLocalesOf = - OriginalDateTimeFormat.supportedLocalesOf.bind(OriginalDateTimeFormat); -Intl.DateTimeFormat = LocalePinnedDateTimeFormat; -for (const method of [ - "toLocaleString", - "toLocaleDateString", - "toLocaleTimeString", -]) { - const original = Date.prototype[method]; - Date.prototype[method] = function (locales, options) { - return original.call(this, locales ?? FIXTURE_LOCALE, options); - }; -} - -import { JSDOM } from "jsdom"; - -const BASELINE_MARKUP = JSON.parse( - readFileSync( - new URL( - "./AgentSessionTranscriptList.conversation.baseline.json", - import.meta.url, - ), - "utf8", - ), -); - -const dom = new JSDOM("", { - url: "http://localhost", -}); - -class NoopObserver { - disconnect() {} - observe() {} - unobserve() {} -} - -Object.assign(globalThis, { - Element: dom.window.Element, - Event: dom.window.Event, - HTMLElement: dom.window.HTMLElement, - IS_REACT_ACT_ENVIRONMENT: true, - IntersectionObserver: NoopObserver, - MutationObserver: dom.window.MutationObserver, - Node: dom.window.Node, - ResizeObserver: NoopObserver, - document: dom.window.document, - getComputedStyle: (...args) => dom.window.getComputedStyle(...args), - localStorage: dom.window.localStorage, - self: dom.window, - window: dom.window, -}); -Object.defineProperty(globalThis, "navigator", { - configurable: true, - value: dom.window.navigator, - writable: true, -}); -dom.window.matchMedia = () => ({ - matches: false, - addEventListener() {}, - removeEventListener() {}, -}); -dom.window.requestAnimationFrame = (callback) => setTimeout(callback, 0); -dom.window.cancelAnimationFrame = (id) => clearTimeout(id); -globalThis.requestAnimationFrame = dom.window.requestAnimationFrame; -globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame; - -let act; -let cleanup; -let render; -let createElement; -let useState; -let createMemoryHistory; -let createRootRoute; -let createRouter; -let RouterProvider; -let AgentSessionTranscriptList; -let resetActiveAgentTurnsStore; -let syncAgentTurnsFromEvents; - -const AGENT = { - agentAvatarUrl: null, - agentName: "Test Agent", - agentPubkey: "f".repeat(64), -}; -const AUTHOR = "a".repeat(64); -const AUTHOR_TRUNCATED = `${AUTHOR.slice(0, 8)}…${AUTHOR.slice(-4)}`; -/** - * What the transcript builder actually puts in a prompt item's `title`: a - * description of the trigger that started the turn, not an identity. Real values - * are "Prompt", "Buzz event", and title-cased event kinds like "@Mention" - * (`agentSessionTranscriptHelpers.ts` `parsePromptText`). The author row must - * never display this as a name. - */ -const TRIGGER_TITLE = "@Mention"; -const AUTHOR_PROFILES = { - [AUTHOR]: { - displayName: "Ada Lovelace", - avatarUrl: null, - nip05Handle: null, - ownerPubkey: null, - }, -}; - -function items() { - const shared = { channelId: "chan-1", sessionId: "sess-1", turnId: "turn-1" }; - return [ - { - ...shared, - id: "msg:user", - type: "message", - renderClass: "message", - role: "user", - title: TRIGGER_TITLE, - text: "please summarize the plan", - timestamp: "2026-06-14T19:00:00.000Z", - messageId: "event-1", - authorPubkey: AUTHOR, - }, - { - ...shared, - id: "thought:1", - type: "thought", - renderClass: "thought", - title: "Thinking", - text: "weighing the options", - timestamp: "2026-06-14T19:00:02.000Z", - }, - { - ...shared, - id: "plan:1", - type: "plan", - renderClass: "plan", - title: "Plan", - text: "- [x] read the transcript\n- [ ] write the summary (in progress)\n- [ ] ship it", - timestamp: "2026-06-14T19:00:07.000Z", - }, - { - ...shared, - id: "msg:assistant", - type: "message", - renderClass: "message", - role: "assistant", - title: "Test Agent", - text: "Here is the summary with `code`.", - timestamp: "2026-06-14T19:00:09.000Z", - }, - ]; -} - -/** - * Everything the legacy variants can render, in one transcript. - * - * The byte-for-byte contract covers `default`/`compactPreview` for EVERY item - * kind, so the baseline input has to contain every kind rather than the happy - * path: prompt (with prompt context and setup lifecycle so the ingress chrome - * renders), assistant message, thought, plan, a tool item, ordinary lifecycle - * status, error, permission — across two sessions so a session-boundary divider - * is forced too. Where `compactPreview` deliberately suppresses a kind, that - * absence is captured in the fixture and is therefore also protected. - * - * Single tool item on purpose: a run of three would collapse into a grouped - * summary and the leaf tool row would never be captured. - */ -function baselineItems() { - const first = { channelId: "chan-1", sessionId: "sess-1", turnId: "turn-1" }; - const second = { channelId: "chan-1", sessionId: "sess-2", turnId: "turn-2" }; - return [ - { - ...first, - id: "life:setup", - type: "lifecycle", - renderClass: "status", - title: "Turn started", - text: "1 trigger", - timestamp: "2026-06-14T19:00:00.000Z", - acpSource: "turn_started", - }, - { - ...first, - id: "meta:context", - type: "metadata", - renderClass: "raw-rail", - title: "Prompt context", - sections: [{ title: "Channel", body: "engineering" }], - timestamp: "2026-06-14T19:00:00.500Z", - acpSource: "session/prompt:context", - }, - { - ...first, - id: "msg:user", - type: "message", - renderClass: "message", - role: "user", - title: "Ada", - text: "please summarize the plan", - timestamp: "2026-06-14T19:00:01.000Z", - messageId: "event-1", - authorPubkey: AUTHOR, - acpSource: "session/prompt:user", - }, - { - ...first, - id: "thought:1", - type: "thought", - renderClass: "thought", - title: "Thinking", - text: "weighing the options", - timestamp: "2026-06-14T19:00:02.000Z", - }, - { - ...first, - id: "plan:1", - type: "plan", - renderClass: "plan", - title: "Plan", - text: "- [x] read the transcript\n- [ ] write the summary (in progress)\n- [ ] ship it", - timestamp: "2026-06-14T19:00:03.000Z", - }, - { - ...first, - id: "tool:1", - type: "tool", - renderClass: "shell", - descriptor: { - renderClass: "shell", - label: "Ran a command", - preview: "cargo test", - tone: "neutral", - source: "shell", - }, - title: "Ran a command", - toolName: "shell", - buzzToolName: null, - status: "completed", - args: { command: "cargo test" }, - result: "ok", - isError: false, - timestamp: "2026-06-14T19:00:04.000Z", - startedAt: "2026-06-14T19:00:04.000Z", - completedAt: "2026-06-14T19:00:05.000Z", - }, - { - ...first, - id: "life:permission", - type: "lifecycle", - renderClass: "permission", - title: "Permission requested", - text: "write src/main.rs\nOptions: Allow, Deny", - outcome: "Approved (once)", - timestamp: "2026-06-14T19:00:06.000Z", - }, - { - ...first, - id: "life:status", - type: "lifecycle", - renderClass: "status", - title: "Context compacted", - text: "", - timestamp: "2026-06-14T19:00:07.000Z", - }, - { - ...first, - id: "msg:assistant", - type: "message", - renderClass: "message", - role: "assistant", - title: "Test Agent", - text: "Here is the summary with `code`.", - timestamp: "2026-06-14T19:00:08.000Z", - }, - { - ...first, - id: "life:error", - type: "lifecycle", - renderClass: "error", - title: "Turn failed", - text: "the harness exited", - timestamp: "2026-06-14T19:00:09.000Z", - }, - // Second session run: forces a session-boundary divider between the runs. - { - ...second, - id: "msg:user2", - type: "message", - renderClass: "message", - role: "user", - title: "Ada", - text: "next task", - timestamp: "2026-06-14T19:05:00.000Z", - messageId: "event-2", - authorPubkey: AUTHOR, - acpSource: "session/prompt:user", - }, - { - ...second, - id: "msg:assistant2", - type: "message", - renderClass: "message", - role: "assistant", - title: "Test Agent", - text: "on it", - timestamp: "2026-06-14T19:05:01.000Z", - }, - ]; -} - -async function renderTranscript(variant, overrides = {}) { - const rootRoute = createRootRoute({ - component: () => - createElement(AgentSessionTranscriptList, { - ...AGENT, - emptyDescription: "nothing yet", - items: items(), - variant, - ...overrides, - }), - }); - const router = createRouter({ - history: createMemoryHistory({ initialEntries: ["/"] }), - routeTree: rootRoute, - }); - await router.load(); - return render(createElement(RouterProvider, { router })); -} - -/** - * Same mount, but the caller can swap the list props afterwards. Needed for the - * contracts that are only visible across a rerender: a streaming thought - * folding once the turn moves on, and a plan mutating in place. - */ -async function renderRerenderableTranscript(variant, initialOverrides = {}) { - let applyProps; - const Harness = () => { - const [overrides, setOverrides] = useState(initialOverrides); - applyProps = setOverrides; - return createElement(AgentSessionTranscriptList, { - ...AGENT, - emptyDescription: "nothing yet", - items: items(), - variant, - ...overrides, - }); - }; - const rootRoute = createRootRoute({ component: Harness }); - const router = createRouter({ - history: createMemoryHistory({ initialEntries: ["/"] }), - routeTree: rootRoute, - }); - await router.load(); - const utils = render(createElement(RouterProvider, { router })); - return { - ...utils, - async setOverrides(next) { - await act(async () => { - applyProps(next); - }); - }, - }; -} - -before(async () => { - ({ act, cleanup, render } = await import("@testing-library/react")); - ({ createElement, useState } = await import("react")); - ({ createMemoryHistory, createRootRoute, createRouter, RouterProvider } = - await import("@tanstack/react-router")); - ({ AgentSessionTranscriptList } = await import( - "./AgentSessionTranscriptList.tsx" - )); - ({ resetActiveAgentTurnsStore, syncAgentTurnsFromEvents } = await import( - "../activeAgentTurnsStore.ts" - )); -}); - -afterEach(() => { - cleanup?.(); - resetActiveAgentTurnsStore?.(); -}); -after(() => dom.window.close()); +import { test } from "node:test"; + +import { + act, + AGENT, + AUTHOR, + AUTHOR_PROFILES, + AUTHOR_TRUNCATED, + BASELINE_MARKUP, + baselineItems, + cleanup, + domWindow, + FIXTURE_LOCALE, + items, + renderRerenderableTranscript, + renderTranscript, + syncAgentTurnsFromEvents, +} from "./AgentSessionTranscriptList.conversationHarness.mjs"; test("conversation marks the transcript container and centers a reading column", async () => { const { container } = await renderTranscript("conversation"); @@ -456,12 +60,40 @@ test("conversation renders the prompt as a filled right-aligned bubble with an a '[data-testid="transcript-user-message"]', ); assert.match(row.className, /justify-end/); - const bubble = row.querySelector(".rounded-2xl"); + // berd's user-turn recipe: soft tint, no border, `px-4 py-2`, and a 12px + // radius (berd's `rounded-sm` on its own scale = Buzz's `rounded-xl`). + const bubble = row.querySelector(".rounded-xl"); + assert.ok(bubble, "the prompt bubble should take berd's 12px radius"); assert.match(bubble.className, /bg-muted\/60/); + assert.match(bubble.className, /px-4/); + assert.match(bubble.className, /py-2(?!\.)/); + assert.match( + bubble.className, + /border-0/, + "berd never draws a border on the user turn", + ); + assert.doesNotMatch( + bubble.className, + /rounded-2xl/, + "the old 16px pill radius should be gone", + ); // Focus mode shows the whole prompt rather than clamping it. assert.doesNotMatch(bubble.className, /max-h-36/); }); +test("conversation caps the prompt bubble at a fixed measure, not a percentage", async () => { + // berd caps the user turn with `--chat-user-message-max-width: 640px`. A + // percentage cap re-wraps the prompt every time the cover view is resized; + // a fixed measure holds one stable line length, which is the point of the + // recipe. Guards against a silent revert to `max-w-[85%]`. + const { container } = await renderTranscript("conversation"); + const column = container.querySelector( + '[data-testid="transcript-user-message-author"]', + ).parentElement; + assert.match(column.className, /max-w-prompt-bubble/); + assert.doesNotMatch(column.className, /max-w-\[\d+%\]/); +}); + test("conversation never shows the trigger title as the prompt author when the sender is unresolved", async () => { // Regression guard. The label chain's last fallback used to be the prompt // item's `title`, which is a description of the trigger ("@Mention", @@ -600,7 +232,7 @@ test("conversation folds the thought when the turn moves on, even after the brow // event follows rather than causes that state. await act(async () => { disclosure.open = true; - disclosure.dispatchEvent(new dom.window.Event("toggle")); + disclosure.dispatchEvent(new domWindow.Event("toggle")); }); assert.equal( disclosure.open, @@ -657,7 +289,7 @@ test("conversation keeps a reader-opened thought open after the turn moves on", await act(async () => { disclosure.open = true; - disclosure.dispatchEvent(new dom.window.Event("toggle")); + disclosure.dispatchEvent(new domWindow.Event("toggle")); }); await setOverrides({ ...settledItems, items: items() }); diff --git a/desktop/src/features/agents/ui/AgentSessionTranscriptList.conversationChrome.test.mjs b/desktop/src/features/agents/ui/AgentSessionTranscriptList.conversationChrome.test.mjs new file mode 100644 index 00000000000..65b28bbab75 --- /dev/null +++ b/desktop/src/features/agents/ui/AgentSessionTranscriptList.conversationChrome.test.mjs @@ -0,0 +1,274 @@ +/** + * Presentation contract for the `conversation` transcript variant's *chrome*: + * the agent identity row above each reply, and the focus code-block recipe. + * + * Split out of `AgentSessionTranscriptList.conversation.test.mjs` to stay under + * the repo's hard 1000-line/file ceiling (AGENTS.md). The shared jsdom setup, + * ambient-formatting pins, and render helpers live in the harness so the two + * suites cannot drift apart. + */ + +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + AGENT, + AGENT_AVATAR_URL, + AGENT_PROFILES, + cleanup, + fencedCodeItems, + fencedCodePromptItems, + renderTranscript, + renderTranscriptWithCodeChrome, +} from "./AgentSessionTranscriptList.conversationHarness.mjs"; + +test("the conversation identity row announces the agent exactly once", async () => { + // `UserAvatar` names itself — either an `${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/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 b819050b528..9b1f8d588ed 100644 --- a/desktop/src/features/agents/ui/activityRenderClasses/MessageActivity.tsx +++ b/desktop/src/features/agents/ui/activityRenderClasses/MessageActivity.tsx @@ -1,5 +1,10 @@ -import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import { + resolveUserLabel, + type UserProfileLookup, +} from "@/features/profile/lib/identity"; +import { normalizePubkey } from "@/shared/lib/pubkey"; import { Markdown } from "@/shared/ui/markdown"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; import { useAgentSessionTranscriptVariant } from "../agentSessionTranscriptContext"; import { formatTranscriptTimestampTitle } from "../agentSessionUtils"; import type { TranscriptItem } from "../agentSessionTypes"; @@ -16,13 +21,27 @@ export function MessageActivity(props: ActivityRenderClassItemProps) { return null; } - return ; + return ( + + ); } function MessageItem({ + agentAvatarUrl, + agentName, + agentPubkey, item, profiles, }: { + agentAvatarUrl: string | null; + agentName: string; + agentPubkey: string; item: Extract; profiles?: UserProfileLookup; }) { @@ -32,6 +51,25 @@ function MessageItem({ const isAssistant = item.role === "assistant"; const text = item.text.trim(); const messageLink = getTranscriptMessageLink(item); + // The identity row must resolve the agent through the profiles lookup first, + // exactly as `ToolItem` (ToolItem.tsx:45-52) and the panel header + // (AgentSessionThreadPanel.tsx:243-249) already do for the same agent on the + // same surface. The `agentAvatarUrl`/`agentName` props only carry what the + // *caller's* agent record holds, and the primary channel flow's record + // (`ChannelAgentSessionAgent`) has no avatar field at all — so relying on the + // prop alone showed initials for every channel-opened session while the + // managed-agent panel showed the real avatar. Resolving here keeps the row + // agreeing with the header directly above it; the props stay as the fallback + // for callers that have an avatar the lookup does not (a locally managed + // agent whose avatar was never published to a relay profile). + const agentProfile = profiles?.[normalizePubkey(agentPubkey)] ?? null; + const resolvedAgentAvatarUrl = agentProfile?.avatarUrl ?? agentAvatarUrl; + const resolvedAgentName = resolveUserLabel({ + pubkey: agentPubkey, + fallbackName: agentName, + profiles, + preferResolvedSelfLabel: true, + }); if (!isAssistant) { return ( @@ -55,6 +93,44 @@ function MessageItem({ data-testid="transcript-assistant-message" >
+ {isConversation ? ( + // berd labels every agent turn with a small identity row above the + // prose — 20px round avatar + name at `text-xs`, `mb-0.5`, `gap-1` + // (MessageBubble.tsx:961-981). Without it the reply reads as + // unattributed body text in a full-cover view, which was the largest + // single divergence from berd. Only the conversation variant gets it: + // the other variants' markup is pinned by the byte-for-byte fixture. +
+ {/* + * The avatar is decorative here: `UserAvatar` exposes either an + * image named `${displayName} avatar` or its fallback initials, and + * the agent's name already follows as visible text, so an + * unhidden avatar makes a screen reader announce the same identity + * twice for every agent turn. The visible name is the row's single + * accessible identity. Hidden at this call site rather than by + * teaching the shared `UserAvatar` a decorative mode: other rows + * that pair an avatar with adjacent name text (for example + * `ForumPostCard.tsx:91-99`) have the same shape and would want the + * same treatment, but changing the shared component's accessible + * name affects all 45 of its call sites and is not this PR's scope. + * + * `size="xs"` is already 20px (`h-5 w-5`) in UserAvatar. + */} + + + {resolvedAgentName} + +
+ ) : null}
@@ -176,16 +178,28 @@ export function UserMessageBubble({ messageLink && "group/bubble cursor-pointer transition-colors hover:border-border hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", isCompactPreview && "p-2 text-xs leading-4", - // A filled surface (not just a hairline) is what makes the prompt - // read as the human's turn at a glance in focus mode. - isConversation && "border-transparent bg-muted/60 px-4 py-2.5", + // berd's user-turn recipe (MessageBubble.tsx:990): a soft tint, no + // border at all, `px-4 py-2`, and a tighter radius than a chat + // "pill". berd's `rounded-sm` is 12px on its own scale + // (globals.css `--radius-sm: 12px`), NOT Tailwind's stock 2px — + // Buzz's `rounded-xl` is the exact 12px equivalent here. + // `leading-normal` overrides the `leading-relaxed` base, as berd + // does, so the prompt sits tighter than the agent's prose. + isConversation && + "rounded-xl border-0 bg-muted/60 px-4 py-2 leading-normal", bubbleClassName, )} ref={bubbleRef} {...bubbleLinkProps} > diff --git a/desktop/src/shared/ui/markdown/CodeBlock.tsx b/desktop/src/shared/ui/markdown/CodeBlock.tsx index 9954b03aa6c..30748d236e5 100644 --- a/desktop/src/shared/ui/markdown/CodeBlock.tsx +++ b/desktop/src/shared/ui/markdown/CodeBlock.tsx @@ -19,6 +19,31 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import { getReactNodeText } from "./utils"; +/** + * Code-block presentation, supplied by the rendering surface. + * + * `focusProse` applies berd's recipe (`code-block.tsx` CodeBlockHeader:388-402, + * viewport:528-529): the language in a real header row above the frame with the + * copy action opposite it, a 10px radius, page-background fill behind a subtle + * border, and no shadow. + * + * It is opt-in because this renderer is shared with channel messages — + * restyling the default would change every code block in the app. The variant + * is a property of the *surface*, not of a role: `AgentSessionTranscriptList` + * provides it once at the transcript boundary, so a fence in a human prompt + * gets the same chrome as one in an agent reply. + * + * Delivered through context rather than a `MarkdownCodeBlock` prop for the same + * reason as `VideoReviewMarkdownContext`: the component map handed to + * react-markdown must stay module-stable. A prop would have to be threaded + * through `createMarkdownComponents`, which would also mean partitioning the + * parsed-node cache that identifies that map by variant string + * (`nodeCache.ts`). A context read at render time needs neither. + */ +export const CodeBlockVariantContext = React.createContext< + "default" | "focusProse" +>("default"); + let shikiHighlighter: HighlighterGeneric | null = null; let shikiInitPromise: Promise | null = null; @@ -75,6 +100,8 @@ export function MarkdownCodeBlock({ const [isCopying, setIsCopying] = React.useState(false); const codeBlockRef = React.useRef(null); const code = React.useMemo(() => getCodeBlockText(children), [children]); + const isFocusProse = + React.useContext(CodeBlockVariantContext) === "focusProse"; useSmoothCorners(codeBlockRef); const handleCopy = React.useCallback( @@ -96,6 +123,52 @@ export function MarkdownCodeBlock({ [code], ); + const focusProseCopyButton = ( + + + + + Copy code + + ); + + if (isFocusProse) { + return ( +
+
+ {language} + {focusProseCopyButton} +
+
+          {children}
+        
+
+ ); + } + + // Unchanged default path: this renderer is shared with channel messages, so + // its markup stays byte-identical (including class order) to keep every other + // code block in the app exactly as it was. return (