Files
coder/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx
T
2026-05-20 21:58:19 +01:00

2499 lines
64 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Meta, StoryObj } from "@storybook/react-vite";
import {
expect,
fireEvent,
fn,
screen,
spyOn,
userEvent,
waitFor,
within,
} from "storybook/test";
import type * as TypesGen from "#/api/typesGenerated";
import { getChatFileURL } from "../../utils/chatAttachments";
import { encodeInlineTextAttachment } from "../../utils/fetchTextAttachment";
import { ConversationTimeline } from "./ConversationTimeline";
import { parseMessagesWithMergedTools } from "./messageParsing";
import type { ParsedMessageEntry } from "./types";
// 1×1 solid coral (#FF6B6B) PNG encoded as base64.
const TEST_PNG_B64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4n539HwAHFwLVF8kc1wAAAABJRU5ErkJggg==";
const buildMessages = (messages: TypesGen.ChatMessage[]) =>
parseMessagesWithMergedTools(messages);
const baseMessage = {
chat_id: "story-chat",
created_at: "2026-03-10T00:00:00.000Z",
} as const;
const askUserQuestionPayload = {
questions: [
{
header: "Implementation Approach",
question: "How should we structure the database migration?",
options: [
{
label: "Single migration",
description:
"One migration file with all changes. Simpler but harder to roll back.",
},
{
label: "Incremental migrations",
description:
"Split into multiple sequential migrations. More flexible rollback.",
},
],
},
{
header: "Release Plan",
question: "Which rollout path should we use for the new agent workflow?",
options: [
{
label: "Internal dry run",
description:
"Ship to the team first and confirm the migration flow before broader rollout.",
},
{
label: "Small beta",
description:
"Start with a limited set of workspaces so we can gather feedback quickly.",
},
],
},
],
};
const askUserQuestionSubmittedResponse = [
"1. Implementation Approach: Incremental migrations",
"2. Release Plan: Small beta",
].join("\n");
type AttachmentResponse = {
status: number;
body: string;
contentType?: string;
};
const FAILED_ATTACHMENT_API_MESSAGE = "Failed to get chat file.";
const UNDISPLAYABLE_REMOTE_ATTACHMENT_MESSAGE =
"File exists but could not be displayed.";
const ATTACHMENT_RESPONSES = new Map<string, AttachmentResponse>([
[
"storybook-test-text",
{
status: 200,
body: "Quarterly revenue increased 18% year over year after the new pricing rollout stabilized customer expansion.",
},
],
[
"storybook-json-text",
{ status: 200, body: '{"status":"ok","items":[1,2,3]}' },
],
[
"storybook-text-only",
{
status: 200,
body: "Runbook note: restart the worker after updating the queue configuration to pick up the new concurrency limits.",
},
],
[
"storybook-text-1",
{
status: 200,
body: "First context file: deployment checklist and rollback instructions for the release candidate.",
},
],
[
"storybook-text-2",
{
status: 200,
body: "Second context file: service logs showing a transient timeout while the cache warmed up.",
},
],
[
"storybook-text-3",
{
status: 200,
body: "Third context file: local development configuration overrides for reproducing the issue.",
},
],
["storybook-expired-image", { status: 404, body: "" }],
["storybook-undisplayable-image", { status: 200, body: "" }],
[
"storybook-failed-image",
{
status: 500,
body: JSON.stringify({
message: FAILED_ATTACHMENT_API_MESSAGE,
detail: "db: connection reset",
}),
contentType: "application/json",
},
],
["storybook-expired-text", { status: 404, body: "" }],
[
"storybook-failed-text",
{
status: 500,
body: JSON.stringify({
message: FAILED_ATTACHMENT_API_MESSAGE,
detail: "db: connection reset",
}),
contentType: "application/json",
},
],
["storybook-text-error", { body: "Temporary failure", status: 503 }],
]);
let attachmentFetchCounts = new Map<string, number>();
const recordAttachmentFetch = (fileId: string) => {
attachmentFetchCounts.set(
fileId,
(attachmentFetchCounts.get(fileId) ?? 0) + 1,
);
};
const getAttachmentFetchCount = (fileId: string) =>
attachmentFetchCounts.get(fileId) ?? 0;
const mockAttachmentFetch = () => {
const originalFetch = globalThis.fetch;
spyOn(globalThis, "fetch").mockImplementation(async (input, init) => {
const url =
typeof input === "string"
? input
: input instanceof URL
? input.toString()
: input.url;
for (const [fileId, response] of ATTACHMENT_RESPONSES) {
if (url.endsWith(fileId)) {
recordAttachmentFetch(fileId);
return new Response(response.body, {
status: response.status,
headers: response.contentType
? { "Content-Type": response.contentType }
: undefined,
});
}
}
return originalFetch(input, init);
});
};
const buildTextPart = (text: string): TypesGen.ChatTextPart => ({
type: "text",
text,
});
const buildFilePart = (
part: Omit<TypesGen.ChatFilePart, "type">,
): TypesGen.ChatFilePart => ({
type: "file",
...part,
});
const buildTextAttachmentPart = (fileId: string): TypesGen.ChatFilePart =>
buildFilePart({ file_id: fileId, media_type: "text/plain" });
const buildImageAttachmentPart = (
fileId: string,
mediaType = "image/png",
): TypesGen.ChatFilePart =>
buildFilePart({ file_id: fileId, media_type: mediaType });
const buildInlineAttachmentPart = (
mediaType: string,
data: string,
): TypesGen.ChatFilePart => buildFilePart({ media_type: mediaType, data });
const buildUserMessage = ({
id = 1,
text,
files = [],
createdAt = baseMessage.created_at,
}: {
id?: number;
text?: string;
files?: TypesGen.ChatFilePart[];
createdAt?: string;
}): TypesGen.ChatMessage => ({
...baseMessage,
created_at: createdAt,
id,
role: "user",
content: [...(text ? [buildTextPart(text)] : []), ...files],
});
const buildStoryArgs = (...messages: TypesGen.ChatMessage[]) => ({
...defaultArgs,
parsedMessages: buildMessages(messages),
});
const LONG_USER_MESSAGE = [
"This is a deliberately long user message that should stay pinned to the",
"right edge while the bubble stops short of filling the entire timeline",
"column. It gives the Storybook test enough content to exercise the",
"maximum width cap.",
].join(" ");
const findAttachmentTile = async (
canvas: ReturnType<typeof within>,
label: string,
) => {
const tile = await canvas.findByRole("img", { name: label });
expect(canvas.getByText(label)).toBeInTheDocument();
return tile;
};
const expectNoCopyMessageButtonForElement = (element: HTMLElement) => {
const messageRow = element.closest(
'[data-role="user"], [data-role="assistant"]',
);
expect(messageRow).not.toBeNull();
const messageWrapper = messageRow?.parentElement;
expect(messageWrapper).not.toBeNull();
expect(
within(messageWrapper as HTMLElement).queryByRole("button", {
name: "Copy message",
}),
).not.toBeInTheDocument();
};
const hoverAndExpectTooltip = async (
element: HTMLElement,
text: RegExp | string,
) => {
await userEvent.hover(element);
const tooltip = await screen.findByRole("tooltip");
expect(tooltip).toHaveTextContent(text);
return tooltip;
};
const waitForTooltipWrappedAttachmentTile = async (
canvas: ReturnType<typeof within>,
label: string,
) => {
await waitFor(() =>
expect(canvas.getByRole("img", { name: label })).toHaveAttribute(
"data-state",
),
);
return canvas.getByRole("img", { name: label });
};
const defaultArgs: Omit<
React.ComponentProps<typeof ConversationTimeline>,
"parsedMessages"
> = {
subagentTitles: new Map(),
};
const meta: Meta<typeof ConversationTimeline> = {
title: "pages/AgentsPage/ChatConversation/ConversationTimeline",
component: ConversationTimeline,
decorators: [
(Story) => (
<div className="mx-auto w-full max-w-3xl py-6">
<Story />
</div>
),
],
beforeEach: () => {
attachmentFetchCounts = new Map();
mockAttachmentFetch();
},
};
export default meta;
type Story = StoryObj<typeof ConversationTimeline>;
/**
* User bubbles should stay right-aligned, shrink to fit short content,
* and cap long content so the timeline keeps some breathing room.
*/
export const UserMessageBubbleAlignment: Story = {
args: buildStoryArgs(
buildUserMessage({
text: LONG_USER_MESSAGE,
}),
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const messageText = canvas.getByText(/deliberately long user message/i);
const userRow = messageText.closest('[data-role="user"]');
expect(userRow).not.toBeNull();
const bubble = userRow?.firstElementChild;
expect(bubble).not.toBeNull();
await userEvent.hover(userRow?.parentElement as HTMLElement);
const actions = await canvas.findByTestId("message-actions");
const rowRect = (userRow as HTMLElement).getBoundingClientRect();
const bubbleRect = (bubble as HTMLElement).getBoundingClientRect();
const actionsRect = actions.getBoundingClientRect();
expect(bubbleRect.width).toBeLessThanOrEqual(rowRect.width * 0.81);
expect(Math.abs(rowRect.right - bubbleRect.right)).toBeLessThanOrEqual(2);
expect(Math.abs(rowRect.right - actionsRect.right)).toBeLessThanOrEqual(2);
},
};
/** Regression guard: a single image attachment must not be duplicated. */
export const UserMessageWithSingleImage: Story = {
args: {
...defaultArgs,
parsedMessages: buildMessages([
{
...baseMessage,
id: 1,
role: "user",
content: [
{ type: "text", text: "Check this screenshot" },
{
type: "file",
media_type: "image/png",
data: TEST_PNG_B64,
},
],
},
{
...baseMessage,
id: 2,
role: "assistant",
content: [
{
type: "text",
text: "I can see the screenshot. It looks like a settings panel.",
},
],
},
]),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const images = canvas.getAllByRole("img", { name: "Attached image" });
expect(images).toHaveLength(1);
expectNoCopyMessageButtonForElement(images[0]);
},
};
/** Ensures N images in yields exactly N thumbnails with no duplication. */
export const UserMessageWithMultipleImages: Story = {
args: {
...defaultArgs,
parsedMessages: buildMessages([
{
...baseMessage,
id: 1,
role: "user",
content: [
{ type: "text", text: "Here are three screenshots" },
{
type: "file",
media_type: "image/png",
data: TEST_PNG_B64,
},
{
type: "file",
media_type: "image/jpeg",
data: TEST_PNG_B64,
},
{
type: "file",
media_type: "image/webp",
data: TEST_PNG_B64,
},
],
},
]),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const images = canvas.getAllByRole("img", { name: "Attached image" });
expect(images).toHaveLength(3);
expectNoCopyMessageButtonForElement(images[0]);
},
};
/** File-id images use a server URL instead of inline base64 data. */
export const UserMessageWithFileIdImage: Story = {
args: buildStoryArgs(
buildUserMessage({
text: "Uploaded via file ID",
files: [buildImageAttachmentPart("storybook-test-image")],
}),
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const images = canvas.getAllByRole("img", { name: "Attached image" });
expect(images).toHaveLength(1);
// Verify file_id path is used, not a base64 data URI.
expect(images[0]).toHaveAttribute(
"src",
getChatFileURL("storybook-test-image"),
);
expectNoCopyMessageButtonForElement(images[0]);
},
};
/** File-id images that probe as 404 render an expired placeholder. */
export const UserMessageWithExpiredImage: Story = {
args: buildStoryArgs(
buildUserMessage({
text: "This upload has expired",
files: [buildImageAttachmentPart("storybook-expired-image")],
}),
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const image = canvas.getByRole("img", { name: "Attached image" });
fireEvent.error(image);
const expiredTile = await findAttachmentTile(canvas, "Image expired");
expect(canvas.getByText("This upload has expired")).toBeInTheDocument();
expect(
canvas.queryByRole("button", { name: "View Attached image" }),
).not.toBeInTheDocument();
expectNoCopyMessageButtonForElement(expiredTile);
// The tooltip explains the retention policy generically so the
// copy survives any operator-chosen retention window.
await hoverAndExpectTooltip(
expiredTile,
/deleted after the retention window/i,
);
},
};
/** Duplicate expired file IDs reuse the first probe result page-wide. */
export const UserMessageWithRepeatedExpiredImage: Story = {
args: buildStoryArgs(
buildUserMessage({
id: 1,
text: "First reference to the expired upload",
files: [buildImageAttachmentPart("storybook-expired-image")],
}),
buildUserMessage({
id: 2,
text: "Second reference to the same expired upload",
files: [buildImageAttachmentPart("storybook-expired-image")],
}),
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const images = canvas.getAllByRole("img", { name: "Attached image" });
expect(images).toHaveLength(2);
fireEvent.error(images[0]);
fireEvent.error(images[1]);
await waitFor(() =>
expect(
canvas.getAllByRole("img", { name: "Image expired" }),
).toHaveLength(2),
);
expect(getAttachmentFetchCount("storybook-expired-image")).toBe(1);
expect(
canvas.queryByRole("button", { name: "View Attached image" }),
).not.toBeInTheDocument();
for (const tile of canvas.getAllByRole("img", { name: "Image expired" })) {
expectNoCopyMessageButtonForElement(tile);
}
},
};
/** Duplicate file IDs with a non-expired probe reuse the first result. */
export const UserMessageWithRepeatedFailedImage: Story = {
args: buildStoryArgs(
buildUserMessage({
id: 1,
text: "First reference to the failed upload",
files: [buildImageAttachmentPart("storybook-failed-image")],
}),
buildUserMessage({
id: 2,
text: "Second reference to the same failed upload",
files: [buildImageAttachmentPart("storybook-failed-image")],
}),
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const images = canvas.getAllByRole("img", { name: "Attached image" });
expect(images).toHaveLength(2);
fireEvent.error(images[0]);
fireEvent.error(images[1]);
await waitFor(() =>
expect(
canvas.getAllByRole("img", { name: "Image failed to load" }),
).toHaveLength(2),
);
expect(getAttachmentFetchCount("storybook-failed-image")).toBe(1);
expect(
canvas.queryByRole("button", { name: "View Attached image" }),
).not.toBeInTheDocument();
const tiles = await waitFor(() => {
const t = canvas.getAllByRole("img", { name: "Image failed to load" });
for (const tile of t) {
expect(tile).toHaveAttribute("data-state");
}
return t;
});
for (const tile of tiles) {
expectNoCopyMessageButtonForElement(tile);
await hoverAndExpectTooltip(tile, FAILED_ATTACHMENT_API_MESSAGE);
}
},
};
/** File-id images that fail with a non-404 status render a generic failure tile. */
export const UserMessageWithFailedRemoteImage: Story = {
args: buildStoryArgs(
buildUserMessage({
text: "This image failed to load",
files: [buildImageAttachmentPart("storybook-failed-image")],
}),
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const image = canvas.getByRole("img", { name: "Attached image" });
fireEvent.error(image);
const failedTile = await findAttachmentTile(canvas, "Image failed to load");
expect(canvas.getByText("This image failed to load")).toBeInTheDocument();
expect(
canvas.queryByRole("button", { name: "View Attached image" }),
).not.toBeInTheDocument();
expectNoCopyMessageButtonForElement(failedTile);
// When the probe returns a structured error body, the tooltip
// surfaces the API's message so the viewer has something
// actionable instead of a bare "failed to load". The label
// doesn't change when the probe settles (still "Image failed
// to load"), and the tile's DOM node is replaced when the
// Tooltip wrapper mounts, so re-query each time and wait for
// the Radix-stamped data-state attribute before hovering.
await hoverAndExpectTooltip(
await waitForTooltipWrappedAttachmentTile(canvas, "Image failed to load"),
FAILED_ATTACHMENT_API_MESSAGE,
);
},
};
/** A successful follow-up probe still maps to the generic failure tile. */
export const UserMessageWithUndisplayableRemoteImage: Story = {
args: buildStoryArgs(
buildUserMessage({
text: "This image exists but cannot be displayed",
files: [buildImageAttachmentPart("storybook-undisplayable-image")],
}),
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const image = canvas.getByRole("img", { name: "Attached image" });
fireEvent.error(image);
const failedTile = await findAttachmentTile(canvas, "Image failed to load");
expectNoCopyMessageButtonForElement(failedTile);
await hoverAndExpectTooltip(
await waitForTooltipWrappedAttachmentTile(canvas, "Image failed to load"),
UNDISPLAYABLE_REMOTE_ATTACHMENT_MESSAGE,
);
},
};
/** Invalid inline image data skips the probe and renders the generic failure tile. */
export const UserMessageWithInvalidInlineImage: Story = {
args: buildStoryArgs(
buildUserMessage({
text: "Inline image data is corrupt",
files: [buildInlineAttachmentPart("image/png", "not-valid-base64")],
}),
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const image = canvas.getByRole("img", { name: "Attached image" });
fireEvent.error(image);
const failedTile = await findAttachmentTile(canvas, "Image failed to load");
expect(
canvas.getByText("Inline image data is corrupt"),
).toBeInTheDocument();
expect(
canvas.queryByRole("button", { name: "View Attached image" }),
).not.toBeInTheDocument();
expectNoCopyMessageButtonForElement(failedTile);
},
};
export const UserMessageWithTextAttachment: Story = {
args: buildStoryArgs(
buildUserMessage({
text: "Here is some context from our docs:",
files: [buildTextAttachmentPart("storybook-test-text")],
}),
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const textButton = await canvas.findByRole("button", {
name: "View text attachment",
});
expect(textButton).toBeInTheDocument();
expect(textButton).toHaveTextContent(/Pasted text/i);
expect(
canvas.queryByRole("button", { name: "Copy message" }),
).not.toBeInTheDocument();
await userEvent.click(textButton);
expect(
await canvas.findByText(/Quarterly revenue increased 18%/i),
).toBeInTheDocument();
},
};
export const UserMessageWithJSONAttachment: Story = {
args: {
...defaultArgs,
parsedMessages: parseMessagesWithMergedTools([
{
...baseMessage,
id: 1,
role: "user",
content: [
{ type: "text", text: "Here is the structured report." },
{
type: "file",
file_id: "storybook-json-text",
media_type: "application/json",
name: "report.json",
},
],
},
]),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const textButton = await canvas.findByRole("button", {
name: "View report.json",
});
expect(textButton).toHaveTextContent("report.json");
expect(
canvas.queryByRole("button", { name: "Copy message" }),
).not.toBeInTheDocument();
await userEvent.click(textButton);
expect(await canvas.findByText(/"status":"ok"/i)).toBeInTheDocument();
},
};
export const UserMessageWithDownloadableFile: Story = {
args: {
...defaultArgs,
parsedMessages: parseMessagesWithMergedTools([
{
...baseMessage,
id: 1,
role: "user",
content: [
{ type: "text", text: "I attached the deployment report." },
{
type: "file",
media_type: "application/pdf",
file_id: "storybook-user-deployment-report",
name: "deployment-report.pdf",
},
],
},
]),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const downloadLink = canvas.getByRole("link", {
name: "Download deployment-report.pdf",
});
expect(downloadLink).toHaveAttribute(
"href",
"/api/experimental/chats/files/storybook-user-deployment-report",
);
expect(canvas.getByText("deployment-report.pdf")).toBeInTheDocument();
expect(
canvas.queryByRole("button", { name: "Copy message" }),
).not.toBeInTheDocument();
},
};
export const UserMessageWithMultipleTextAttachments: Story = {
args: buildStoryArgs(
buildUserMessage({
createdAt: "2025-01-15T10:00:00Z",
text: "Here are several context files:",
files: [
buildTextAttachmentPart("storybook-text-1"),
buildTextAttachmentPart("storybook-text-2"),
buildTextAttachmentPart("storybook-text-3"),
],
}),
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const textButtons = await canvas.findAllByRole("button", {
name: "View text attachment",
});
expect(textButtons).toHaveLength(3);
expectNoCopyMessageButtonForElement(textButtons[0]);
},
};
export const UserMessageWithTextAttachmentOnly: Story = {
args: buildStoryArgs(
buildUserMessage({
files: [buildTextAttachmentPart("storybook-text-only")],
}),
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const textButton = await canvas.findByRole("button", {
name: "View text attachment",
});
expect(textButton).toHaveTextContent(/Pasted text/i);
expectNoCopyMessageButtonForElement(textButton);
await userEvent.click(textButton);
expect(
await canvas.findByText(/Runbook note: restart the worker/i),
).toBeInTheDocument();
},
};
export const UserMessageWithExpiredTextAttachment: Story = {
args: buildStoryArgs(
buildUserMessage({
text: "This pasted context has expired",
files: [buildTextAttachmentPart("storybook-expired-text")],
}),
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const textButton = await canvas.findByRole("button", {
name: "View text attachment",
});
expectNoCopyMessageButtonForElement(textButton);
await userEvent.click(textButton);
const expiredTile = await findAttachmentTile(canvas, "Attachment expired");
expect(
canvas.getByText("This pasted context has expired"),
).toBeInTheDocument();
expect(
canvas.queryByRole("button", { name: "View text attachment" }),
).not.toBeInTheDocument();
await hoverAndExpectTooltip(
expiredTile,
/deleted after the retention window/i,
);
},
};
export const UserMessageWithFailedTextAttachment: Story = {
args: buildStoryArgs(
buildUserMessage({
text: "This pasted context failed to load",
files: [buildTextAttachmentPart("storybook-failed-text")],
}),
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const textButton = await canvas.findByRole("button", {
name: "View text attachment",
});
expectNoCopyMessageButtonForElement(textButton);
await userEvent.click(textButton);
await findAttachmentTile(canvas, "Attachment failed to load");
expect(
canvas.getByText("This pasted context failed to load"),
).toBeInTheDocument();
expect(
canvas.queryByRole("button", { name: "View text attachment" }),
).not.toBeInTheDocument();
await hoverAndExpectTooltip(
await waitForTooltipWrappedAttachmentTile(
canvas,
"Attachment failed to load",
),
FAILED_ATTACHMENT_API_MESSAGE,
);
},
};
export const UserMessageWithInlineTextAttachment: Story = {
args: {
...defaultArgs,
parsedMessages: parseMessagesWithMergedTools([
{
...baseMessage,
id: 1,
role: "user",
content: [
{ type: "text", text: "Here is inline context:" },
{
type: "file",
media_type: "text/plain",
data: encodeInlineTextAttachment(
"Inline deployment note: verify the feature flag before rollout.",
),
},
],
},
]),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const textButton = await canvas.findByRole("button", {
name: "View text attachment",
});
expect(textButton).toHaveTextContent(/Pasted text/i);
expectNoCopyMessageButtonForElement(textButton);
await userEvent.click(textButton);
expect(
await canvas.findByText(/Inline deployment note/i),
).toBeInTheDocument();
},
};
/**
* Non-JSON error bodies (a bare `Temporary failure` text body with status 503)
* still surface the shared failure tile, and the raw body must not leak into
* the message stream where it would look like assistant content.
*/
export const UserMessageWithFailedTextAttachmentNonJSONBody: Story = {
args: {
...defaultArgs,
parsedMessages: parseMessagesWithMergedTools([
{
...baseMessage,
id: 1,
role: "user",
content: [
{ type: "text", text: "The preview fetch will fail." },
{
type: "file",
file_id: "storybook-text-error",
media_type: "text/plain",
name: "preview.txt",
},
],
},
]),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const textButton = await canvas.findByRole("button", {
name: "View preview.txt",
});
expectNoCopyMessageButtonForElement(textButton);
await userEvent.click(textButton);
await findAttachmentTile(canvas, "Attachment failed to load");
expect(
canvas.queryByRole("button", { name: "View preview.txt" }),
).not.toBeInTheDocument();
expect(canvas.queryByText(/Temporary failure/i)).not.toBeInTheDocument();
},
};
/** Visual regression: text and image attachments render at the same height. */
export const UserMessageWithMixedAttachments: Story = {
args: buildStoryArgs(
buildUserMessage({
text: "Here is a screenshot and some context",
files: [
buildInlineAttachmentPart("image/png", TEST_PNG_B64),
buildTextAttachmentPart("storybook-test-text"),
],
}),
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const images = canvas.getAllByRole("img", { name: "Attached image" });
expect(images).toHaveLength(1);
const textButtons = await canvas.findAllByRole("button", {
name: "View text attachment",
});
expect(textButtons).toHaveLength(1);
expectNoCopyMessageButtonForElement(images[0]);
},
};
/** Text-only messages must not produce spurious image thumbnails. */
export const UserMessageTextOnly: Story = {
args: {
...defaultArgs,
parsedMessages: buildMessages([
{
...baseMessage,
id: 1,
role: "user",
content: [{ type: "text", text: "Just a plain text message" }],
},
]),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const images = canvas.queryAllByRole("img", { name: "Attached image" });
expect(images).toHaveLength(0);
expect(canvas.getByText("Just a plain text message")).toBeInTheDocument();
},
};
/** Assistant-side images go through BlockList, not the user path. */
export const AssistantMessageWithImage: Story = {
args: {
...defaultArgs,
parsedMessages: buildMessages([
{
...baseMessage,
id: 1,
role: "assistant",
content: [
{ type: "text", text: "Here is the generated image:" },
{
type: "file",
media_type: "image/png",
data: TEST_PNG_B64,
name: "generated-image.png",
},
],
},
]),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const images = canvas.getAllByRole("img", { name: "generated-image.png" });
expect(images).toHaveLength(1);
expect(images[0]).toHaveAttribute(
"src",
`data:image/png;base64,${TEST_PNG_B64}`,
);
expect(
canvas.queryByRole("link", { name: "Download generated-image.png" }),
).not.toBeInTheDocument();
expect(
canvas.queryByRole("button", { name: "Copy message" }),
).not.toBeInTheDocument();
const viewButton = canvas.getByRole("button", {
name: "View generated-image.png",
});
viewButton.focus();
expect(viewButton).toHaveFocus();
await waitFor(() => {
expect(
canvas.getByRole("link", { name: "Download generated-image.png" }),
).toBeVisible();
});
},
};
export const AssistantMessageWithUnnamedDownloadableFile: Story = {
args: {
...defaultArgs,
parsedMessages: buildMessages([
{
...baseMessage,
id: 1,
role: "assistant",
content: [
{ type: "text", text: "I attached the file without a custom name." },
{
type: "file",
media_type: "application/pdf",
file_id: "storybook-unnamed-report",
},
],
},
]),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const downloadLink = canvas.getByRole("link", {
name: "Download Attached file",
});
expect(downloadLink).toBeInTheDocument();
expect(downloadLink).toHaveAttribute("download", "attachment.pdf");
expect(canvas.getByText("Attached file")).toBeInTheDocument();
expect(
canvas.queryByRole("button", { name: "Copy message" }),
).not.toBeInTheDocument();
},
};
/** Images and file-references coexist without interfering. */
export const UserMessageWithImagesAndFileRefs: Story = {
args: {
...defaultArgs,
parsedMessages: buildMessages([
{
...baseMessage,
id: 1,
role: "user",
content: [
{ type: "text", text: "Look at these files" },
{
type: "file",
media_type: "image/png",
data: TEST_PNG_B64,
},
{
type: "file-reference",
file_name: "src/main.go",
start_line: 10,
end_line: 25,
content: 'func main() {\n\tfmt.Println("hello")\n}',
},
],
},
]),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const images = canvas.getAllByRole("img", { name: "Attached image" });
expect(images).toHaveLength(1);
expect(canvas.getByText(/main\.go/)).toBeInTheDocument();
expectNoCopyMessageButtonForElement(images[0]);
},
};
/** File references render inline with text, matching the chat input style. */
export const UserMessageWithInlineFileRef: Story = {
args: {
...defaultArgs,
parsedMessages: buildMessages([
{
...baseMessage,
id: 1,
role: "user",
content: [
{ type: "text", text: "Can you refactor " },
{
type: "file-reference",
file_name: "site/src/components/Button.tsx",
start_line: 42,
end_line: 42,
content: "export const Button = ...",
},
{ type: "text", text: " to use the new API?" },
],
},
{
...baseMessage,
id: 2,
role: "assistant",
content: [
{
type: "text",
text: "Sure, I'll update that component.",
},
],
},
]),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText(/Button\.tsx/)).toBeInTheDocument();
expect(canvas.getByText(/Can you refactor/)).toBeInTheDocument();
expect(canvas.getByText(/to use the new API/)).toBeInTheDocument();
},
};
/** Multiple file references render inline, no separate section. */
export const UserMessageWithMultipleInlineFileRefs: Story = {
args: {
...defaultArgs,
parsedMessages: buildMessages([
{
...baseMessage,
id: 1,
role: "user",
content: [
{ type: "text", text: "Compare " },
{
type: "file-reference",
file_name: "api/handler.go",
start_line: 1,
end_line: 50,
content: "...",
},
{ type: "text", text: " with " },
{
type: "file-reference",
file_name: "api/handler_test.go",
start_line: 10,
end_line: 30,
content: "...",
},
],
},
]),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText(/handler\.go/)).toBeInTheDocument();
expect(canvas.getByText(/handler_test\.go/)).toBeInTheDocument();
},
};
export const MetadataOnlyUserMessageDoesNotLeaveStickyGap: Story = {
args: {
...defaultArgs,
parsedMessages: buildMessages([
{
...baseMessage,
id: 1,
role: "assistant",
content: [{ type: "text", text: "Before hidden metadata." }],
},
{
...baseMessage,
id: 2,
role: "user",
content: [
{
type: "context-file",
context_file_path: "/home/coder/coder/AGENTS.md",
},
],
},
{
...baseMessage,
id: 3,
role: "assistant",
content: [{ type: "text", text: "After hidden metadata." }],
},
]),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("Before hidden metadata.")).toBeVisible();
expect(canvas.getByText("After hidden metadata.")).toBeVisible();
expect(canvasElement.querySelectorAll("[data-user-sentinel]")).toHaveLength(
0,
);
},
};
/**
* Verifies the structural requirements for sticky user messages
* in the flat (section-less) message list:
* - Each user message renders a data-user-sentinel marker so
* the push-up logic can find the next user message via DOM
* traversal.
* - The user message container gets position:sticky.
* - Sentinels appear in the correct order (matching user
* message order).
*/
export const StickyUserMessageStructure: Story = {
args: {
...defaultArgs,
parsedMessages: buildMessages([
{
...baseMessage,
id: 1,
role: "user",
content: [{ type: "text", text: "First prompt" }],
},
{
...baseMessage,
id: 2,
role: "assistant",
content: [{ type: "text", text: "First response" }],
},
{
...baseMessage,
id: 3,
role: "user",
content: [{ type: "text", text: "Second prompt" }],
},
{
...baseMessage,
id: 4,
role: "assistant",
content: [{ type: "text", text: "Second response" }],
},
]),
},
play: async ({ canvasElement }) => {
// Each user message should produce a data-user-sentinel
// marker that the push-up scroll logic relies on.
const sentinels = canvasElement.querySelectorAll("[data-user-sentinel]");
expect(sentinels.length).toBe(2);
// Each sentinel should be immediately followed by a sticky
// container (the user message itself).
for (const sentinel of sentinels) {
const container = sentinel.nextElementSibling;
expect(container).not.toBeNull();
const style = window.getComputedStyle(container!);
expect(style.position).toBe("sticky");
}
// Sentinels must appear in DOM order matching the message
// order so nextElementSibling traversal finds the correct
// next user message.
const allElements = Array.from(
canvasElement.querySelectorAll("[data-user-sentinel], [class*='sticky']"),
);
const sentinelIndices = Array.from(sentinels).map((s) =>
allElements.indexOf(s),
);
// Sentinels should be in ascending DOM order.
expect(sentinelIndices[0]).toBeLessThan(sentinelIndices[1]);
// Both user messages should be visible.
const canvas = within(canvasElement);
expect(canvas.getByText("First prompt")).toBeVisible();
expect(canvas.getByText("Second prompt")).toBeVisible();
},
};
/**
* Each user message exposes left/right chevron buttons in its
* action row so users can jump the transcript between user prompts.
* Disabled at the ends of the conversation; otherwise the click
* smooth-scrolls the bubble's `data-user-sentinel` to the top of
* the scroller.
*/
export const UserMessageJumpArrows: Story = {
decorators: [
(Story) => (
<div
className="overflow-y-auto mx-auto w-full max-w-3xl"
style={{ height: 320 }}
>
<Story />
</div>
),
],
args: {
...defaultArgs,
parsedMessages: buildMessages([
{
...baseMessage,
id: 1,
role: "user",
content: [{ type: "text", text: "First prompt" }],
},
{
...baseMessage,
id: 2,
role: "assistant",
content: [
{
type: "text",
text: "a".repeat(800),
},
],
},
{
...baseMessage,
id: 3,
role: "user",
content: [{ type: "text", text: "Second prompt" }],
},
{
...baseMessage,
id: 4,
role: "assistant",
content: [
{
type: "text",
text: "b".repeat(800),
},
],
},
{
...baseMessage,
id: 5,
role: "user",
content: [{ type: "text", text: "Third prompt" }],
},
]),
onEditUserMessage: fn(),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Reveal the hover-only action rows so we can interact with
// the chevron buttons without dispatching real hover events.
for (const el of canvasElement.querySelectorAll("[class]")) {
if (
el instanceof HTMLElement &&
el.className.includes("group-hover/msg:opacity-100")
) {
el.style.opacity = "1";
}
}
const prevButtons = canvas.getAllByRole("button", {
name: "Jump to previous user message",
});
const nextButtons = canvas.getAllByRole("button", {
name: "Jump to next user message",
});
expect(prevButtons).toHaveLength(3);
expect(nextButtons).toHaveLength(3);
// First user prompt: previous disabled, next enabled.
expect(prevButtons[0]).toBeDisabled();
expect(nextButtons[0]).toBeEnabled();
// Middle user prompt: both directions enabled.
expect(prevButtons[1]).toBeEnabled();
expect(nextButtons[1]).toBeEnabled();
// Last user prompt: previous enabled, next disabled.
expect(prevButtons[2]).toBeEnabled();
expect(nextButtons[2]).toBeDisabled();
// Clicking Next on the first prompt scrolls the second user
// prompt's sentinel into view via its registered ref.
const sentinels = Array.from(
canvasElement.querySelectorAll<HTMLElement>("[data-user-sentinel]"),
);
expect(sentinels).toHaveLength(3);
const targetSpy = spyOn(sentinels[1], "scrollIntoView");
await userEvent.click(nextButtons[0]);
await waitFor(() => {
expect(targetSpy).toHaveBeenCalledTimes(1);
});
expect(targetSpy).toHaveBeenCalledWith({
behavior: "smooth",
block: "start",
});
},
};
/** Copy + edit actions appear below user messages on hover. */
export const UserMessageCopyButton: Story = {
args: {
...defaultArgs,
parsedMessages: buildMessages([
{
...baseMessage,
id: 1,
role: "user",
content: [{ type: "text", text: "Can you fix this bug?" }],
},
]),
onEditUserMessage: fn(),
},
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
// Force the hover-reveal toolbar visible for the screenshot.
for (const el of canvasElement.querySelectorAll("[class]")) {
if (
el instanceof HTMLElement &&
el.className.includes("group-hover/msg:opacity-100")
) {
el.style.opacity = "1";
}
}
const copyButton = canvas.getByRole("button", {
name: "Copy message",
});
expect(copyButton).toBeInTheDocument();
const editButton = canvas.getByRole("button", {
name: "Edit message",
});
expect(editButton).toBeInTheDocument();
// Behavioral: clicking edit fires onEditUserMessage with the
// correct message ID and text.
await userEvent.click(editButton);
expect(args.onEditUserMessage).toHaveBeenCalledWith(
1,
"Can you fix this bug?",
undefined,
);
// Behavioral: clicking copy writes the raw markdown to the
// clipboard.
const originalClipboard = navigator.clipboard;
const writeText = fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
value: { writeText },
writable: true,
configurable: true,
});
try {
await userEvent.click(copyButton);
expect(writeText).toHaveBeenCalledWith("Can you fix this bug?");
} finally {
Object.defineProperty(navigator, "clipboard", {
value: originalClipboard,
writable: true,
configurable: true,
});
}
},
};
/** Copy button is present on assistant messages on hover. */
export const AssistantMessageCopyButton: Story = {
args: {
...defaultArgs,
parsedMessages: buildMessages([
{
...baseMessage,
id: 1,
role: "user",
content: [{ type: "text", text: "Explain this code" }],
},
{
...baseMessage,
id: 2,
role: "assistant",
content: [
{
type: "text",
text: "This function handles **authentication** by checking the JWT token.\n\n```go\nfunc auth(r *http.Request) error {\n\treturn nil\n}\n```",
},
],
},
]),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Force the hover-reveal toolbar visible.
for (const el of canvasElement.querySelectorAll("[class]")) {
if (
el instanceof HTMLElement &&
el.className.includes("group-hover/msg:opacity-100")
) {
el.style.opacity = "1";
}
}
const actions = canvas.getAllByTestId("message-actions");
expect(actions.length).toBeGreaterThanOrEqual(1);
// The last message-actions belongs to the assistant.
const assistantActions = actions[actions.length - 1];
const copyBtn = within(assistantActions).getByRole("button", {
name: "Copy message",
});
expect(copyBtn).toBeInTheDocument();
},
};
/** Persisted ask-user-question answers survive reloads. */
export const AskUserQuestionSubmittedAnswer: Story = {
args: {
...defaultArgs,
isChatCompleted: true,
parsedMessages: buildMessages([
{
...baseMessage,
id: 1,
role: "user",
content: [{ type: "text", text: "Help me pick a rollout plan." }],
},
{
...baseMessage,
id: 2,
role: "assistant",
content: [
{
type: "tool-call",
tool_call_id: "ask-tool-1",
tool_name: "ask_user_question",
},
],
},
{
...baseMessage,
id: 3,
role: "tool",
content: [
{
type: "tool-result",
tool_call_id: "ask-tool-1",
result: {
output: JSON.stringify(askUserQuestionPayload),
},
},
],
},
{
...baseMessage,
id: 4,
role: "user",
content: [{ type: "text", text: askUserQuestionSubmittedResponse }],
},
]),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// The submitted-answer summary is hidden after the follow-up user message.
expect(
canvas.getByText("How should we structure the database migration?"),
).toBeInTheDocument();
expect(canvas.queryAllByRole("radio")).toHaveLength(0);
expect(
canvas.queryByRole("button", { name: "Submit" }),
).not.toBeInTheDocument();
const userMessages = canvasElement.querySelectorAll('[data-role="user"]');
const latestUserMessage = userMessages[userMessages.length - 1];
if (!(latestUserMessage instanceof HTMLElement)) {
throw new Error("Expected a submitted user message bubble.");
}
expect(
within(latestUserMessage).getByText(
/Implementation Approach: Incremental migrations/,
),
).toBeInTheDocument();
expect(
within(latestUserMessage).getByText(/Release Plan: Small beta/),
).toBeInTheDocument();
},
};
/** No copy button when assistant message has no markdown content. */
export const AssistantMessageNoCopyWhenToolOnly: Story = {
args: {
...defaultArgs,
parsedMessages: buildMessages([
{
...baseMessage,
id: 1,
role: "user",
content: [{ type: "text", text: "Run the tests" }],
},
{
...baseMessage,
id: 2,
role: "assistant",
content: [
{
type: "tool-call",
tool_call_id: "tool-1",
tool_name: "execute",
args: { command: "go test ./..." },
},
],
},
{
...baseMessage,
id: 3,
role: "tool",
content: [
{
type: "tool-result",
tool_call_id: "tool-1",
result: { output: "PASS" },
},
],
},
]),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Force the hover-reveal toolbar visible.
for (const el of canvasElement.querySelectorAll("[class]")) {
if (
el instanceof HTMLElement &&
el.className.includes("group-hover/msg:opacity-100")
) {
el.style.opacity = "1";
}
}
// Only the user message should have actions; the tool-only
// assistant message has no copyable content.
const actions = canvas.getAllByTestId("message-actions");
expect(actions).toHaveLength(1);
},
};
/** Copy button calls clipboard API with the raw markdown text. */
export const CopyButtonWritesToClipboard: Story = {
args: {
...defaultArgs,
parsedMessages: buildMessages([
{
...baseMessage,
id: 1,
role: "user",
content: [{ type: "text", text: "What is the answer?" }],
},
{
...baseMessage,
id: 2,
role: "assistant",
content: [{ type: "text", text: "Here is the **answer**." }],
},
]),
},
play: async ({ canvasElement }) => {
const originalClipboard = navigator.clipboard;
const writeText = fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
value: { writeText },
writable: true,
configurable: true,
});
try {
const canvas = within(canvasElement);
// Force the hover-reveal toolbar visible.
for (const el of canvasElement.querySelectorAll("[class]")) {
if (
el instanceof HTMLElement &&
el.className.includes("group-hover/msg:opacity-100")
) {
el.style.opacity = "1";
}
}
// Find the assistant's copy button (last message-actions).
const actions = canvas.getAllByTestId("message-actions");
const assistantActions = actions[actions.length - 1];
const copyBtn = within(assistantActions).getByRole("button", {
name: "Copy message",
});
await userEvent.click(copyBtn);
expect(writeText).toHaveBeenCalledWith("Here is the **answer**.");
} finally {
Object.defineProperty(navigator, "clipboard", {
value: originalClipboard,
writable: true,
configurable: true,
});
}
},
};
/** All messages get copy actions regardless of turn state. */
export const CopyButtonDuringActiveTurn: Story = {
args: {
...defaultArgs,
parsedMessages: buildMessages([
{
...baseMessage,
id: 1,
role: "user",
content: [{ type: "text", text: "Fix the bug" }],
},
{
...baseMessage,
id: 2,
role: "assistant",
content: [{ type: "text", text: "Let me look at the code." }],
},
]),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Force the hover-reveal toolbar visible.
for (const el of canvasElement.querySelectorAll("[class]")) {
if (
el instanceof HTMLElement &&
el.className.includes("group-hover/msg:opacity-100")
) {
el.style.opacity = "1";
}
}
// Both user and assistant messages should have actions.
const actions = canvas.getAllByTestId("message-actions");
expect(actions).toHaveLength(2);
},
};
/** All assistant messages with text content get a copy button. */
export const MultiAssistantTurnCopyButton: Story = {
args: {
...defaultArgs,
parsedMessages: buildMessages([
{
...baseMessage,
id: 1,
role: "user",
content: [{ type: "text", text: "Help me refactor" }],
},
{
...baseMessage,
id: 2,
role: "assistant",
content: [
{ type: "text", text: "Let me check the code first." },
{
type: "tool-call",
tool_call_id: "tool-1",
tool_name: "read_file",
args: { path: "main.go" },
},
],
},
{
...baseMessage,
id: 3,
role: "tool",
content: [
{
type: "tool-result",
tool_call_id: "tool-1",
result: { output: "package main" },
},
],
},
{
...baseMessage,
id: 4,
role: "assistant",
content: [
{ type: "text", text: "Here is the **refactored** version." },
],
},
]),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Force the hover-reveal toolbar visible.
for (const el of canvasElement.querySelectorAll("[class]")) {
if (
el instanceof HTMLElement &&
el.className.includes("group-hover/msg:opacity-100")
) {
el.style.opacity = "1";
}
}
// The first assistant message (id=2) is mid-chain so its
// actions are hidden. Only the user and the last assistant
// (id=4) get action bars.
const actions = canvas.getAllByTestId("message-actions");
expect(actions).toHaveLength(2);
},
};
/**
* Regression: thinking-only assistant messages must have consistent
* bottom spacing before the next user bubble. A spacer div fills the
* gap that would normally come from the invisible action bar.
*/
export const ThinkingOnlyAssistantSpacing: Story = {
args: {
...defaultArgs,
parsedMessages: buildMessages([
{
...baseMessage,
id: 1,
role: "user",
content: [{ type: "text", text: "Explain this code" }],
},
{
...baseMessage,
id: 2,
role: "assistant",
content: [
{
type: "reasoning",
text: "Let me think about this step by step. The user wants me to explain the code they shared.",
},
],
},
{
...baseMessage,
id: 3,
role: "user",
content: [{ type: "text", text: "Any progress?" }],
},
]),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// The thinking-only assistant message has no action bar, but
// it should still have visible text and a spacer element.
expect(canvas.getByText("Explain this code")).toBeInTheDocument();
expect(canvas.getByText("Any progress?")).toBeInTheDocument();
},
};
/**
* Regression: sources-only assistant messages must have consistent
* bottom spacing before the next user bubble. A spacer div fills the
* gap that would normally come from the hidden action bar.
*/
export const SourcesOnlyAssistantSpacing: Story = {
args: {
...defaultArgs,
parsedMessages: buildMessages([
{
...baseMessage,
id: 1,
role: "user",
content: [{ type: "text", text: "Can you share your sources?" }],
},
{
...baseMessage,
id: 2,
role: "assistant",
content: [
{
type: "source",
url: "https://example.com/docs",
title: "Documentation",
},
{
type: "source",
url: "https://example.com/api",
title: "API Reference",
},
],
},
{
...baseMessage,
id: 3,
role: "user",
content: [{ type: "text", text: "Thanks!" }],
},
]),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("Can you share your sources?")).toBeInTheDocument();
expect(canvas.getByText("Thanks!")).toBeInTheDocument();
await userEvent.click(
canvas.getByRole("button", { name: /searched 2 results/i }),
);
expect(
canvas.getByRole("link", { name: "Documentation" }),
).toBeInTheDocument();
expect(
canvas.getByRole("link", { name: "API Reference" }),
).toBeInTheDocument();
},
};
/**
* Regression: assistant messages whose only tool row resolves to null
* must not leave behind an empty transcript wrapper or an extra gap.
*/
export const HiddenAssistantToolMessageDoesNotRenderGap: Story = {
args: {
...defaultArgs,
parsedMessages: buildMessages([
{
...baseMessage,
id: 201,
role: "user",
content: [{ type: "text", text: "Run the command" }],
},
{
...baseMessage,
id: 202,
role: "assistant",
content: [{ type: "text", text: "Done." }],
},
{
...baseMessage,
id: 203,
role: "assistant",
content: [
{
type: "tool-call",
tool_call_id: "hidden-execute",
tool_name: "execute",
args: {},
},
],
},
{
...baseMessage,
id: 204,
role: "user",
content: [{ type: "text", text: "Thanks!" }],
},
]),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(
canvas.queryByText("Message has no renderable content."),
).not.toBeInTheDocument();
for (const el of canvasElement.querySelectorAll(
'[data-testid="message-actions"]',
)) {
if (el instanceof HTMLElement) {
el.style.opacity = "1";
}
}
const timeline = canvas.getByTestId("conversation-timeline");
const renderedRows = Array.from(
timeline.querySelectorAll('[data-role="user"], [data-role="assistant"]'),
);
expect(renderedRows).toHaveLength(3);
expect(renderedRows[1]).toHaveAttribute("data-role", "assistant");
expect(renderedRows[1]).toHaveTextContent("Done.");
expect(canvas.getAllByTestId("message-actions")).toHaveLength(3);
},
};
/**
* Regression: action bar must appear on the last *visible* assistant
* message even when invisible assistant messages (provider-executed
* tool-result-only) follow it before the next user turn.
*/
export const AssistantActionBarAfterHiddenMessages: Story = {
args: {
...defaultArgs,
parsedMessages: buildMessages([
{
...baseMessage,
id: 1,
role: "user",
content: [{ type: "text", text: "Help me refactor" }],
},
{
...baseMessage,
id: 2,
role: "assistant",
content: [
{ type: "text", text: "Here is the **refactored** version." },
],
},
{
...baseMessage,
id: 3,
role: "assistant",
content: [
{
type: "tool-result",
tool_call_id: "provider-tool-1",
result: { output: "done" },
provider_executed: true,
},
],
},
{
...baseMessage,
id: 4,
role: "user",
content: [{ type: "text", text: "Thanks!" }],
},
]),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Force the hover-reveal action bars visible using stable test IDs.
for (const el of canvasElement.querySelectorAll(
'[data-testid="message-actions"]',
)) {
if (el instanceof HTMLElement) {
el.style.opacity = "1";
}
}
// 2 user messages + 1 visible assistant = 3 action bars.
// The invisible provider-executed tool-result message (id=3)
// must not prevent the assistant (id=2) from showing its bar.
const actions = canvas.getAllByTestId("message-actions");
expect(actions).toHaveLength(3);
},
};
export const ToolDisplayModesFromPreferences: Story = {
parameters: {
queries: [
{
key: ["me", "preferences"],
data: {
task_notification_alert_dismissed: false,
thinking_display_mode: "auto" as const,
shell_tool_display_mode: "always_collapsed" as const,
code_diff_display_mode: "always_collapsed" as const,
agent_chat_send_shortcut: "enter" as const,
},
},
],
},
args: {
...defaultArgs,
parsedMessages: [
{
message: {
...baseMessage,
id: 1,
role: "assistant",
content: [],
},
parsed: {
markdown: "",
reasoning: "",
toolCalls: [],
toolResults: [],
tools: [
{
id: "execute-tool",
name: "execute",
args: { command: "pnpm test" },
result: { output: "tests passed" },
isError: false,
status: "completed",
},
{
id: "edit-tool",
name: "edit_files",
args: {
files: [
{
path: "src/config.ts",
edits: [
{
search: "const timeout = 30;",
replace: "const timeout = 60;",
},
],
},
],
},
result: { ok: true },
isError: false,
status: "completed",
},
],
blocks: [
{ type: "tool", id: "execute-tool" },
{ type: "tool", id: "edit-tool" },
],
sources: [],
},
},
] satisfies ParsedMessageEntry[],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const commandOutputButton = canvas.getByRole("button", {
name: "Expand command",
});
expect(commandOutputButton).toHaveTextContent("Ran pnpm test");
expect(canvas.queryByText("tests passed")).not.toBeInTheDocument();
expect(canvas.getByText(/Edited config\.ts/)).toBeVisible();
expect(canvas.queryAllByTestId("edit-file-diff")).toHaveLength(0);
expect(commandOutputButton).toHaveAttribute("aria-expanded", "false");
await userEvent.click(commandOutputButton);
await waitFor(() => {
expect(canvas.getByText("tests passed")).toBeVisible();
});
const editFilesButton = canvas.getByRole("button", {
name: /Edited config\.ts/,
});
expect(editFilesButton).toHaveAttribute("aria-expanded", "false");
await userEvent.click(editFilesButton);
await waitFor(() => {
expect(canvas.getAllByTestId("edit-file-diff")).toHaveLength(1);
});
},
};
/**
* A completed thinking block with always_expanded mode should show
* its content without user interaction.
*/
export const ThinkingBlockAlwaysExpanded: Story = {
parameters: {
queries: [
{
key: ["me", "preferences"],
data: {
task_notification_alert_dismissed: false,
thinking_display_mode: "always_expanded" as const,
shell_tool_display_mode: "auto" as const,
code_diff_display_mode: "auto" as const,
agent_chat_send_shortcut: "enter" as const,
},
},
],
},
args: {
...defaultArgs,
parsedMessages: buildMessages([
{
...baseMessage,
id: 1,
role: "assistant",
content: [
{
type: "reasoning",
text: "Let me think about this step by step.",
},
{
type: "text",
text: "Here is the answer.",
},
],
},
]),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("Thinking")).toBeInTheDocument();
await waitFor(() => {
expect(
canvas.getByText(/Let me think about this step by step/),
).toBeVisible();
});
},
};
/**
* A completed thinking block with always_collapsed mode should
* hide its content until the user clicks.
*/
export const ThinkingBlockAlwaysCollapsed: Story = {
parameters: {
queries: [
{
key: ["me", "preferences"],
data: {
task_notification_alert_dismissed: false,
thinking_display_mode: "always_collapsed" as const,
shell_tool_display_mode: "auto" as const,
code_diff_display_mode: "auto" as const,
agent_chat_send_shortcut: "enter" as const,
},
},
],
},
args: {
...defaultArgs,
parsedMessages: buildMessages([
{
...baseMessage,
id: 1,
role: "assistant",
content: [
{
type: "reasoning",
text: "Let me think about this step by step.",
},
{
type: "text",
text: "Here is the answer.",
},
],
},
]),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("Thinking")).toBeInTheDocument();
expect(
canvas.queryByText(/Let me think about this step by step/),
).not.toBeInTheDocument();
await userEvent.click(canvas.getByText("Thinking"));
await waitFor(() => {
expect(
canvas.getByText(/Let me think about this step by step/),
).toBeVisible();
});
},
};
/** Collapsed thinking should visually align with adjacent tool calls. */
export const ThinkingBlockWithToolCall: Story = {
parameters: {
queries: [
{
key: ["me", "preferences"],
data: {
task_notification_alert_dismissed: false,
thinking_display_mode: "always_collapsed" as const,
shell_tool_display_mode: "auto" as const,
code_diff_display_mode: "auto" as const,
agent_chat_send_shortcut: "enter" as const,
},
},
],
},
args: {
...defaultArgs,
parsedMessages: buildMessages([
{
...baseMessage,
id: 1,
role: "assistant",
content: [
{
type: "reasoning",
text: "I need to inspect the package metadata before answering.",
},
{
type: "tool-call",
tool_call_id: "tool-1",
tool_name: "read_file",
args: { path: "package.json" },
},
],
},
{
...baseMessage,
id: 2,
role: "tool",
content: [
{
type: "tool-result",
tool_call_id: "tool-1",
result: { content: '{"name":"coder"}' },
},
],
},
]),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const thinkingButton = canvas.getByRole("button", { name: /thinking/i });
expect(thinkingButton).toBeInTheDocument();
expect(
canvas.getByRole("button", { name: /read package\.json/i }),
).toBeInTheDocument();
const toolButton = canvas.getByRole("button", {
name: /read package\.json/i,
});
const thinkingContainer = thinkingButton.closest("[data-transcript-row]");
const toolContainer = toolButton.closest("[data-transcript-row]");
expect(thinkingContainer).toBeInstanceOf(HTMLElement);
expect(toolContainer).toBeInstanceOf(HTMLElement);
expect(toolContainer?.firstElementChild).not.toHaveAttribute("data-state");
expect(thinkingContainer?.firstElementChild).not.toHaveAttribute(
"data-state",
);
expect(
canvas.queryByTestId("assistant-bottom-spacer"),
).not.toBeInTheDocument();
},
};
/** Shell-style tool rows should keep the same collapsed height as Thinking. */
export const ThinkingBlockWithShellTools: Story = {
parameters: {
queries: [
{
key: ["me", "preferences"],
data: {
task_notification_alert_dismissed: false,
thinking_display_mode: "always_collapsed" as const,
shell_tool_display_mode: "always_collapsed" as const,
code_diff_display_mode: "auto" as const,
agent_chat_send_shortcut: "enter" as const,
},
},
],
},
args: {
...defaultArgs,
parsedMessages: buildMessages([
{
...baseMessage,
id: 1,
role: "assistant",
content: [
{
type: "reasoning",
text: "I should inspect the current chat spacing before patching it.",
},
{
type: "tool-call",
tool_call_id: "tool-1",
tool_name: "execute",
args: { command: "pnpm test" },
},
{
type: "tool-call",
tool_call_id: "tool-2",
tool_name: "process_output",
args: { process_id: "process-1" },
},
],
},
{
...baseMessage,
id: 2,
role: "tool",
content: [
{
type: "tool-result",
tool_call_id: "tool-1",
tool_name: "execute",
result: { output: "", wall_duration_ms: "667" },
},
],
},
{
...baseMessage,
id: 3,
role: "tool",
content: [
{
type: "tool-result",
tool_call_id: "tool-2",
tool_name: "process_output",
result: { output: "Spacing looks stable." },
},
],
},
]),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const thinkingButton = canvas.getByRole("button", { name: /thinking/i });
const executeButton = canvas.getByRole("button", {
name: /expand command/i,
});
const processOutputButton = canvas.getByRole("button", {
name: /expand process output/i,
});
const thinkingRow = thinkingButton.closest(
"[data-transcript-row]",
)?.firstElementChild;
const executeRow = executeButton.closest(
"[data-transcript-row]",
)?.firstElementChild;
const processOutputRow = processOutputButton.closest(
"[data-transcript-row]",
)?.firstElementChild;
expect(thinkingRow).toBeInstanceOf(HTMLElement);
expect(executeRow).toBeInstanceOf(HTMLElement);
expect(processOutputRow).toBeInstanceOf(HTMLElement);
const rowHeights = [thinkingRow, executeRow, processOutputRow].map((row) =>
Math.round((row as HTMLElement).getBoundingClientRect().height),
);
expect(new Set(rowHeights)).toHaveLength(1);
const wrappers = [
thinkingButton.closest("[data-transcript-row]"),
executeButton.closest("[data-transcript-row]"),
processOutputButton.closest("[data-transcript-row]"),
].map((row) => row as HTMLElement);
const gaps = [
Math.round(
wrappers[1].getBoundingClientRect().top -
wrappers[0].getBoundingClientRect().bottom,
),
Math.round(
wrappers[2].getBoundingClientRect().top -
wrappers[1].getBoundingClientRect().bottom,
),
];
expect(gaps).toEqual([8, 8]);
},
};
/**
* A completed thinking block with auto mode should be collapsed
* (non-streaming state means auto collapses).
*/
export const ThinkingBlockAutoMode: Story = {
parameters: {
queries: [
{
key: ["me", "preferences"],
data: {
task_notification_alert_dismissed: false,
thinking_display_mode: "auto" as const,
shell_tool_display_mode: "auto" as const,
code_diff_display_mode: "auto" as const,
agent_chat_send_shortcut: "enter" as const,
},
},
],
},
args: {
...defaultArgs,
parsedMessages: buildMessages([
{
...baseMessage,
id: 1,
role: "assistant",
content: [
{
type: "reasoning",
text: "Let me think about this step by step.",
},
{
type: "text",
text: "Here is the answer.",
},
],
},
]),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("Thinking")).toBeInTheDocument();
expect(
canvas.queryByText(/Let me think about this step by step/),
).not.toBeInTheDocument();
await userEvent.click(canvas.getByText("Thinking"));
await waitFor(() => {
expect(
canvas.getByText(/Let me think about this step by step/),
).toBeVisible();
});
},
};
/**
* A completed thinking block with preview mode should be collapsed
* (non-streaming state means preview collapses).
*/
export const ThinkingBlockPreviewMode: Story = {
parameters: {
queries: [
{
key: ["me", "preferences"],
data: {
task_notification_alert_dismissed: false,
thinking_display_mode: "preview" as const,
shell_tool_display_mode: "auto" as const,
code_diff_display_mode: "auto" as const,
agent_chat_send_shortcut: "enter" as const,
},
},
],
},
args: {
...defaultArgs,
parsedMessages: buildMessages([
{
...baseMessage,
id: 1,
role: "assistant",
content: [
{
type: "reasoning",
text: "Let me think about this step by step.",
},
{
type: "text",
text: "Here is the answer.",
},
],
},
]),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("Thinking")).toBeInTheDocument();
expect(
canvas.queryByText(/Let me think about this step by step/),
).not.toBeInTheDocument();
await userEvent.click(canvas.getByText("Thinking"));
await waitFor(() => {
expect(
canvas.getByText(/Let me think about this step by step/),
).toBeVisible();
});
},
};