Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,8 @@ let createRootRoute;
let createRouter;
let RouterProvider;
let AgentSessionTranscriptList;
let ThemeProvider;
let TooltipProvider;
let resetActiveAgentTurnsStore;
let syncAgentTurnsFromEvents;

Expand Down Expand Up @@ -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: () =>
Comment thread
baxen marked this conversation as resolved.
Outdated
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
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -16,13 +18,24 @@ export function MessageActivity(props: ActivityRenderClassItemProps) {
return null;
}

return <MessageItem item={props.item} profiles={props.profiles} />;
return (
<MessageItem
agentAvatarUrl={props.agentAvatarUrl}
agentName={props.agentName}
item={props.item}
profiles={props.profiles}
/>
);
}

function MessageItem({
agentAvatarUrl,
agentName,
item,
profiles,
}: {
agentAvatarUrl: string | null;
agentName: string;
item: Extract<TranscriptItem, { type: "message" }>;
profiles?: UserProfileLookup;
}) {
Expand Down Expand Up @@ -55,6 +68,29 @@ function MessageItem({
data-testid="transcript-assistant-message"
>
<div className="group relative flex w-full min-w-0 flex-col items-start gap-1">
{isConversation ? (
// berd labels every agent turn with a small identity row above the
// prose — 20px round avatar + name at `text-xs`, `mb-0.5`, `gap-1`
// (MessageBubble.tsx:961-981). Without it the reply reads as
// unattributed body text in a full-cover view, which was the largest
// single divergence from berd. Only the conversation variant gets it:
// the other variants' markup is pinned by the byte-for-byte fixture.
<div
className="mb-0.5 flex items-center gap-1 text-xs"
data-testid="transcript-assistant-identity"
>
{/* `size="xs"` is already 20px (`h-5 w-5`) in UserAvatar. */}
<UserAvatar
avatarUrl={agentAvatarUrl}
className="shrink-0"
displayName={agentName}
size="xs"
/>
<span className="min-w-0 truncate font-normal text-foreground">
{agentName}
</span>
</div>
) : null}
<div
className={
isCompactPreview
Expand All @@ -63,19 +99,25 @@ function MessageItem({
}
title={formatTranscriptTimestampTitle(item.timestamp)}
>
<Markdown
className={
isCompactPreview
? "text-xs leading-4"
: isConversation
? // Focus mode reads as prose: no box, comfortable line
// height, and full-fidelity markdown/code from the shared
// renderer.
"leading-relaxed"
: "leading-5"
}
content={text || " "}
/>
{/* A context provider renders no DOM, so wrapping unconditionally
keeps `default`/`compactPreview` markup byte-identical. */}
<CodeBlockVariantContext.Provider
value={isConversation ? "focusProse" : "default"}
>
<Markdown
className={
isCompactPreview
? "text-xs leading-4"
: isConversation
? // Focus mode reads as prose: no box, comfortable line
// height, and full-fidelity markdown/code from the shared
// renderer.
"leading-relaxed"
: "leading-5"
}
content={text || " "}
/>
</CodeBlockVariantContext.Provider>
</div>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)}
>
Expand All @@ -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}
>
<Markdown
className={isCompactPreview ? "text-xs leading-4" : "leading-5"}
className={cn(
isCompactPreview
? "text-xs leading-4"
: isConversation
? "leading-normal"
: "leading-5",
)}
content={text || " "}
mediaInset
/>
Expand Down
Loading
Loading