diff --git a/e2e/full-chat.spec.ts b/e2e/full-chat.spec.ts
new file mode 100644
index 0000000..60f6661
--- /dev/null
+++ b/e2e/full-chat.spec.ts
@@ -0,0 +1,251 @@
+import { expect, test, type Page } from "@playwright/test";
+
+// The whole-chat composition lives in the story, so the spec drives the
+// static Storybook build the accessibility sweep also serves.
+const STORIES = "http://localhost:61000";
+
+/** Where the page clock starts when a test drives it. */
+const CLOCK_START = Date.parse("2026-03-04T12:00:00Z");
+/** The story replays an answer one frame every this many milliseconds. */
+const REPLAY_STEP_MS = 120;
+/** More frames than the answer takes to reach its call. */
+const MAX_REPLAY_STEPS = 20;
+
+const open = async (page: Page, story: "settle" | "stick") => {
+ await page.goto(
+ `${STORIES}/iframe.html?id=chat-full-chat--${story}&viewMode=story&globals=theme:light`,
+ );
+ await page.locator("body.sb-show-main").waitFor({ state: "attached" });
+};
+
+const focused = (page: Page) =>
+ page.evaluate(() => {
+ const active = document.activeElement;
+ return {
+ body: active === null || active === document.body,
+ slot: active instanceof HTMLElement ? active.dataset["slot"] : undefined,
+ text: active?.textContent ?? "",
+ };
+ });
+
+for (const [story, decision, outcome] of [
+ ["settle", "Approve", "Approved once"],
+ ["stick", "Deny", "Denied"],
+] as const) {
+ test(`${story}: deciding the waiting call by keyboard keeps focus on its run`, async ({
+ page,
+ }) => {
+ await open(page, story);
+ const approval = page.locator(
+ "[data-slot='tool-approval'][data-status='pending']",
+ );
+ await expect(approval).toBeVisible();
+ await approval.getByRole("button", { name: decision, exact: true }).focus();
+ await page.keyboard.press("Enter");
+
+ // The call folds into the run before it, and its buttons go with it.
+ await expect(approval).toHaveCount(0);
+ const active = await focused(page);
+ expect(active.body).toBe(false);
+ expect(active.slot).toBe("activity-trigger");
+ expect(active.text).toContain("Worked through 2 steps");
+
+ // The run opens from where focus landed and shows the outcome.
+ await page.keyboard.press("Enter");
+ const run = page.locator("[data-slot='activity-content']").last();
+ await expect(run.getByText("mail__send").first()).toBeVisible();
+ await expect(run.getByText(outcome)).toBeVisible();
+ });
+}
+
+test("an answer's sources show as links under it", async ({ page }) => {
+ await open(page, "settle");
+ const sources = page
+ .getByRole("log", { name: "Conversation" })
+ .getByRole("list", { name: "Sources" });
+ await expect(sources).toHaveCount(1);
+ await expect(
+ sources.getByRole("link", { name: /Retry cap change/ }),
+ ).toHaveAttribute("href", "https://example.com/changes/retry-cap");
+ await expect(sources.locator("[data-slot='source']")).toHaveCount(2);
+});
+
+test("every message reads as sent before now, in order", async ({ page }) => {
+ await open(page, "settle");
+ const times = await page
+ .getByRole("log", { name: "Conversation" })
+ .locator("time[datetime]")
+ .evaluateAll((elements) =>
+ elements.map((element) => element.getAttribute("datetime") ?? ""),
+ );
+ expect(times.length).toBeGreaterThan(0);
+ const stamps = times.map((time) => Date.parse(time));
+ // The story's now is where the test clock starts.
+ expect(stamps.filter((stamp) => !(stamp < CLOCK_START))).toEqual([]);
+ expect(stamps).toEqual([...stamps].sort((a, b) => a - b));
+});
+
+test("a refused file is read out from a status line already in place", async ({
+ page,
+}) => {
+ await open(page, "settle");
+ const box = page.locator("[data-slot='attachment-drop-zone']");
+ // The box's own line, not the form's: in the accessibility tree while
+ // empty, so filling it is announced.
+ const status = box.getByRole("status").and(box.locator("p"));
+ await expect(status).toHaveText("");
+ await page.locator("input[type='file']").setInputFiles({
+ name: "export.bin",
+ mimeType: "application/octet-stream",
+ buffer: Buffer.alloc(6 * 1024 * 1024),
+ });
+ await expect(status).toHaveText("export.bin is over 5 MB");
+});
+
+test("two files of one name both go with the message", async ({ page }) => {
+ await open(page, "settle");
+ await page.locator("input[type='file']").setInputFiles([
+ { name: "notes.txt", mimeType: "text/plain", buffer: Buffer.from("a") },
+ { name: "notes.txt", mimeType: "text/plain", buffer: Buffer.from("bb") },
+ ]);
+ const send = page
+ .getByRole("main")
+ .getByRole("button", { name: "Send", exact: true });
+ await send.click();
+ const sent = page
+ .getByRole("log", { name: "Conversation" })
+ .locator("[data-item-id='sent-1']");
+ await expect(sent.locator("[data-slot='attachment']")).toHaveCount(2);
+});
+
+test("a send takes the attached file into the message and clears the box", async ({
+ page,
+}) => {
+ await open(page, "settle");
+ await page.locator("input[type='file']").setInputFiles({
+ name: "notes.txt",
+ mimeType: "text/plain",
+ buffer: Buffer.from("Follow-ups for next week"),
+ });
+ const box = page.locator("[data-slot='attachment-drop-zone']");
+ await expect(
+ box.getByRole("button", { name: "Remove notes.txt" }),
+ ).toBeVisible();
+
+ const message = page.getByRole("textbox", { name: "Message" });
+ await message.fill("Here are my notes");
+ await message.press("Enter");
+
+ await expect(message).toHaveValue("");
+ await expect(box.locator("[data-slot='attachments']")).toHaveCount(0);
+ const log = page.getByRole("log", { name: "Conversation" });
+ const sent = log.locator("[data-item-id='sent-1']");
+ await expect(sent.getByText("Here are my notes")).toBeVisible();
+ await expect(sent.getByText("notes.txt")).toBeVisible();
+
+ // The answer streams in, then the box can send again.
+ const answer = log.locator("[data-item-id='answer-1']");
+ await expect(answer.getByText(/^Done\. I read what you sent/)).toBeVisible();
+ await expect(
+ page.getByRole("main").getByRole("button", { name: "Send", exact: true }),
+ ).toBeVisible();
+
+ // Under settle, the sent message rests at the top of the view.
+ await expect
+ .poll(() =>
+ log.evaluate((element) => {
+ const item = element.querySelector("[data-item-id='sent-1']");
+ if (item === null) throw new Error("no sent item");
+ return Math.abs(
+ item.getBoundingClientRect().top -
+ element.getBoundingClientRect().top,
+ );
+ }),
+ )
+ .toBeLessThanOrEqual(1);
+});
+
+test("a file sends on its own, with no text", async ({ page }) => {
+ await open(page, "settle");
+ const send = page
+ .getByRole("main")
+ .getByRole("button", { name: "Send", exact: true });
+ await expect(send).toBeDisabled();
+ await page.locator("input[type='file']").setInputFiles({
+ name: "runs.csv",
+ mimeType: "text/csv",
+ buffer: Buffer.from("run,status\n1,ok\n"),
+ });
+ const message = page.getByRole("textbox", { name: "Message" });
+ await expect(message).toHaveValue("");
+ await expect(send).toBeEnabled();
+ await send.click();
+
+ const box = page.locator("[data-slot='attachment-drop-zone']");
+ await expect(box.locator("[data-slot='attachments']")).toHaveCount(0);
+ const sent = page
+ .getByRole("log", { name: "Conversation" })
+ .locator("[data-item-id='sent-1']");
+ await expect(sent.getByText("runs.csv")).toBeVisible();
+ await expect(sent.locator("[data-slot='message-content']")).toHaveCount(0);
+});
+
+test("Stop settles a call that was running as failed", async ({ page }) => {
+ // The answer replays on timers, and its call runs for a single frame. On
+ // the page's clock, held still once the story has loaded, that frame
+ // stays put until the test moves the clock on.
+ await page.clock.install({ time: CLOCK_START });
+ await open(page, "settle");
+ await page.clock.pauseAt(CLOCK_START + 60_000);
+ const message = page.getByRole("textbox", { name: "Message" });
+ await message.fill("Check the runs");
+ await message.press("Enter");
+
+ const answer = page.locator("[data-item-id='answer-1']");
+ const trigger = answer.locator("[data-slot='activity-trigger']");
+ // One replay frame at a time until the live line names the running call.
+ for (let step = 0; step < MAX_REPLAY_STEPS; step += 1) {
+ await page.clock.runFor(REPLAY_STEP_MS);
+ if ((await trigger.textContent())?.includes("runs__list…") === true) break;
+ }
+ await expect(trigger).toContainText("runs__list…");
+ await page.getByRole("main").getByRole("button", { name: "Stop" }).click();
+ // With the clock still held, move it on by that many frames again. A
+ // frame still queued after Stop would land now and undo the failed call.
+ await page.clock.runFor(REPLAY_STEP_MS * MAX_REPLAY_STEPS);
+ await page.clock.resume();
+
+ await expect(trigger).toContainText("1 failed");
+ await expect(answer.locator("[data-slot='message']")).toHaveAttribute(
+ "data-status",
+ "sent",
+ );
+ await trigger.click();
+ await expect(answer.locator("[data-slot='tool']")).toHaveAttribute(
+ "data-status",
+ "error",
+ );
+ await expect(
+ page.getByRole("main").getByRole("button", { name: "Send", exact: true }),
+ ).toBeVisible();
+});
+
+test("a reply in the thread tab adds to the count under its parent", async ({
+ page,
+}) => {
+ await open(page, "settle");
+ const log = page.getByRole("log", { name: "Conversation" });
+ await expect(log.getByRole("button", { name: /2 replies/ })).toBeVisible();
+ const reply = page.getByRole("textbox", { name: "Reply" });
+ await reply.fill("Add owners to the follow-ups");
+ await reply.press("Enter");
+ // The count reaches 4 as soon as the answer's first, empty frame lands,
+ // so wait for the answer's text. The count then shows the final frame
+ // took the streaming frame's place rather than adding a reply.
+ await expect(
+ page
+ .getByRole("tabpanel")
+ .getByText("Added it to the follow-ups, with an owner and a due date."),
+ ).toBeVisible();
+ await expect(log.getByRole("button", { name: /4 replies/ })).toBeVisible();
+});
diff --git a/stories/chat/full-chat.stories.tsx b/stories/chat/full-chat.stories.tsx
new file mode 100644
index 0000000..b7f95cc
--- /dev/null
+++ b/stories/chat/full-chat.stories.tsx
@@ -0,0 +1,1262 @@
+import {
+ AtSign,
+ Check,
+ Copy,
+ MessageSquare,
+ Paperclip,
+ SquareSlash,
+} from "lucide-react";
+import {
+ Fragment,
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useLayoutEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import type { Dispatch, ReactNode, SetStateAction } from "react";
+
+import {
+ useAttachments,
+ type AttachmentItem,
+} from "../../src/hooks/use-attachments.js";
+import { useCopy } from "../../src/hooks/use-copy.js";
+import { useHistoryWindow } from "../../src/hooks/use-history-window.js";
+import { useMediaViewer } from "../../src/hooks/use-media-viewer.js";
+import { panelTabIds, useTabs } from "../../src/hooks/use-tabs.js";
+import type { AttachmentRejection } from "../../src/lib/attachment-limits.js";
+import type { PartBlock, PartFile } from "../../src/lib/chat-parts.js";
+import {
+ groupActivity,
+ type ActivityStep,
+ type ConversationPartGroup,
+} from "../../src/lib/conversation-activity.js";
+import type {
+ ConversationMessage,
+ ConversationPart,
+ ConversationReaction,
+ PartImage,
+ PartTool,
+} from "../../src/lib/conversation.js";
+import type { PromptToken } from "../../src/lib/prompt-tokens.js";
+import { Activity } from "../../src/ui/activity.js";
+import { AttachmentDropZone } from "../../src/ui/attachment-drop-zone.js";
+import {
+ Attachment,
+ AttachmentInfo,
+ AttachmentPreview,
+ AttachmentRemove,
+ Attachments,
+} from "../../src/ui/attachments.js";
+import {
+ Conversation,
+ ConversationContent,
+ ConversationItem,
+ ConversationScrollButton,
+ type ConversationScrollPolicy,
+} from "../../src/ui/conversation.js";
+import { ConversationEarlier } from "../../src/ui/conversation-earlier.js";
+import {
+ ConversationMap,
+ type ConversationMapTurn,
+} from "../../src/ui/conversation-map.js";
+import { ConversationThread } from "../../src/ui/conversation-thread.js";
+import {
+ GeneratedImages,
+ generatedImageViewerItems,
+} from "../../src/ui/generated-images.js";
+import { MediaViewer } from "../../src/ui/media-viewer.js";
+import {
+ Message,
+ MessageAvatar,
+ MessageContent,
+ MessageFooter,
+ MessageHeader,
+ type MessageFrom,
+} from "../../src/ui/message.js";
+import { MessageAction, MessageActions } from "../../src/ui/message-actions.js";
+import { MessageFold } from "../../src/ui/message-fold.js";
+import { MessageReactions } from "../../src/ui/message-reactions.js";
+import { MessageReplies } from "../../src/ui/message-replies.js";
+import {
+ Panel,
+ PanelActions,
+ PanelClose,
+ PanelContent,
+ PanelHandle,
+ PanelHeader,
+ PanelPane,
+} from "../../src/ui/panel.js";
+import { PanelTab, PanelTabs } from "../../src/ui/panel-tabs.js";
+import {
+ PromptInput,
+ PromptInputSubmit,
+ PromptInputSuggestions,
+ PromptInputTextarea,
+ type PromptInputStatus,
+} from "../../src/ui/prompt-input.js";
+import {
+ PromptInputAddMenu,
+ PromptInputModeSelect,
+ PromptInputModelSelect,
+ PromptInputNote,
+ PromptInputToolbar,
+ type PromptInputModelOption,
+ type PromptInputModeOption,
+} from "../../src/ui/prompt-input-toolbar.js";
+import { Source, Sources } from "../../src/ui/sources.js";
+import type { ToolApprovalDecision } from "../../src/ui/tool-approval.js";
+import { ToolApproval } from "../../src/ui/tool-approval.js";
+import { ToolGroup } from "../../src/ui/tool-group.js";
+import {
+ SAMPLE_NOW,
+ assistant,
+ generatedImages,
+ longChat,
+ shortChat,
+ toolStorm,
+ withThread,
+ you,
+ type ConversationSample,
+} from "./samples/conversations.js";
+import { playTurn } from "./samples/play-turn.js";
+
+export default { title: "Chat / Full chat" };
+
+const NOW = Date.parse(SAMPLE_NOW);
+
+function stampedAt(ms: number): string {
+ return new Date(ms).toISOString();
+}
+
+/**
+ * The samples each keep their own clock, so laid end to end their times
+ * would jump back. Respaced evenly between `startMs` and `NOW`, they read in
+ * order after the long history, and none reads as later than now.
+ */
+function restamp(
+ messages: readonly ConversationMessage[],
+ startMs: number,
+): readonly ConversationMessage[] {
+ const step = Math.floor((NOW - startMs) / (messages.length + 1));
+ return messages.map((message, index) => ({
+ ...message,
+ at: stampedAt(startMs + (index + 1) * step),
+ }));
+}
+
+function threadOf(sample: ConversationSample) {
+ if (sample.thread === undefined)
+ throw new Error(`the ${sample.title} sample has no thread`);
+ return sample.thread;
+}
+
+const sampleThread = threadOf(withThread);
+
+/**
+ * The long history, then a short exchange that cites its sources, a thread,
+ * generated images and a tool storm that ends on a call waiting for
+ * approval. The images sample's last turn is still generating, so it is
+ * left out and the chat opens at rest.
+ */
+const RECENT = restamp(
+ [
+ ...shortChat.messages,
+ ...withThread.messages,
+ ...generatedImages.messages.slice(0, 6),
+ ...toolStorm.messages,
+ ],
+ Date.parse(longChat.messages.at(-1)?.at ?? SAMPLE_NOW),
+);
+
+function findMessage(
+ messages: readonly ConversationMessage[],
+ id: string,
+): ConversationMessage {
+ const message = messages.find((candidate) => candidate.id === id);
+ if (message === undefined) throw new Error(`no sample message ${id}`);
+ return message;
+}
+
+const threadParentAt = Date.parse(
+ findMessage(RECENT, sampleThread.parentId).at,
+);
+
+/** The thread's replies, a few seconds after their parent. */
+const THREAD_REPLIES = sampleThread.replies.map((reply, index) => ({
+ ...reply,
+ at: stampedAt(threadParentAt + (index + 1) * 10_000),
+}));
+
+/** The parent's reply summary is derived from the thread's replies as they change. */
+const MESSAGES: readonly ConversationMessage[] = [
+ ...longChat.messages,
+ ...RECENT,
+];
+
+/**
+ * A stand-in markdown renderer: paragraphs, `- ` lists and `**bold**`. A
+ * real host passes its own renderer and sanitiser through `render`.
+ */
+function markdown(text: string): ReactNode {
+ const inline = (line: string) =>
+ line
+ .split(/(\*\*[^*]+\*\*)/)
+ .map((piece, index) =>
+ piece.startsWith("**") && piece.endsWith("**") ? (
+ {piece.slice(2, -2)}
+ ) : (
+ {piece}
+ ),
+ );
+ return (
+
+ {text.split(/\n{2,}/).map((block, index) => {
+ const lines = block.split("\n");
+ if (lines.every((line) => line.startsWith("- "))) {
+ return (
+
+ {lines.map((line, item) => (
+ {inline(line.slice(2))}
+ ))}
+
+ );
+ }
+ return
{inline(block)}
;
+ })}
+
+ );
+}
+
+function textOf(message: ConversationMessage): string {
+ return message.parts
+ .flatMap((part) => (part.kind === "text" ? [part.text] : []))
+ .join("\n\n");
+}
+
+function fromOf(message: ConversationMessage): MessageFrom {
+ if (message.author.id === you.id) return "own";
+ return message.author.kind;
+}
+
+/** The first line of a message's text, without markdown punctuation. */
+function firstLine(message: ConversationMessage): string {
+ const line = textOf(message)
+ .split("\n")
+ .find((candidate) => candidate.trim() !== "");
+ return (line ?? "")
+ .replace(/[*_`#>]/g, "")
+ .replace(/^\s*(?:[-*]|\d+\.)\s+/, "")
+ .trim();
+}
+
+/** A person's message starts a turn; the first reply with text previews it. */
+function turnsOf(
+ messages: readonly ConversationMessage[],
+): ConversationMapTurn[] {
+ const turns: ConversationMapTurn[] = [];
+ for (const message of messages) {
+ const said = firstLine(message);
+ const last = turns.at(-1);
+ if (message.author.kind === "person") {
+ turns.push({
+ id: message.id,
+ label: said === "" ? "Shared a file" : said,
+ });
+ } else if (
+ last !== undefined &&
+ last.preview === undefined &&
+ said !== ""
+ ) {
+ turns[turns.length - 1] = { ...last, preview: said };
+ }
+ }
+ return turns;
+}
+
+/** The ids of the tool calls a step or part carries. */
+function toolCallIds(part: ConversationPart): readonly string[] {
+ if (part.kind === "tool-trace") return [part.toolCallId];
+ if (part.kind === "tool-batch")
+ return part.tools.map((tool) => tool.toolCallId);
+ return [];
+}
+
+function holdsCall(group: ConversationPartGroup, toolCallId: string): boolean {
+ const parts: readonly (ConversationPart | ActivityStep)[] =
+ group.kind === "activity" ? group.steps : [group.part];
+ return parts.some((part) => toolCallIds(part).includes(toolCallId));
+}
+
+/**
+ * Where focus goes once a decided call has moved: the first call still
+ * waiting in the same batch, else the run's trigger or the batch's header.
+ */
+function nearestControl(group: Element): HTMLElement | null {
+ return (
+ group.querySelector("[data-slot='tool-approval'] button") ??
+ group.querySelector(
+ "[data-slot='activity-trigger'], [data-slot='tool-group-header']",
+ )
+ );
+}
+
+/** The person's answer recorded on the call; an approved call has then run. */
+function settle(tool: PartTool, decision: ToolApprovalDecision): PartTool {
+ const approval = {
+ id: tool.approval?.id ?? `approval-${tool.toolCallId}`,
+ timeoutAt: tool.approval?.timeoutAt ?? null,
+ status: decision.outcome,
+ scope: decision.scope,
+ };
+ const { output: _output, ...rest } = tool;
+ return decision.outcome === "approved"
+ ? {
+ ...rest,
+ status: "output-available",
+ output: `Ran ${tool.name}`,
+ approval,
+ }
+ : { ...rest, status: "output-denied", approval };
+}
+
+function decideCall(
+ part: ConversationPart,
+ toolCallId: string,
+ decision: ToolApprovalDecision,
+): ConversationPart {
+ if (part.kind === "tool-trace" && part.toolCallId === toolCallId)
+ return settle(part, decision);
+ if (part.kind === "tool-batch")
+ return {
+ ...part,
+ tools: part.tools.map((tool) =>
+ tool.toolCallId === toolCallId ? settle(tool, decision) : tool,
+ ),
+ };
+ return part;
+}
+
+function toggleReaction(
+ reactions: readonly ConversationReaction[],
+ emoji: string,
+): readonly ConversationReaction[] {
+ return reactions.flatMap((reaction) => {
+ if (reaction.emoji !== emoji) return [reaction];
+ const count = reaction.count + (reaction.mine ? -1 : 1);
+ return count === 0 ? [] : [{ emoji, count, mine: !reaction.mine }];
+ });
+}
+
+function blockTitle(block: PartBlock["block"]): string {
+ const { data } = block;
+ if (
+ typeof data === "object" &&
+ data !== null &&
+ "title" in data &&
+ typeof data.title === "string"
+ )
+ return data.title;
+ return block.type;
+}
+
+type ChatActions = {
+ readonly decide: (
+ messageId: string,
+ toolCallId: string,
+ decision: ToolApprovalDecision,
+ ) => void;
+ readonly openImage: (
+ partId: string,
+ index: number,
+ opener: HTMLElement,
+ ) => void;
+ readonly react: (messageId: string, emoji: string) => void;
+ readonly openThread: (parentId: string) => void;
+ /** The thread shown in the panel, by parent id. */
+ readonly shownThread: string | null;
+};
+
+const ChatActionsContext = createContext(null);
+
+function useChatActions(): ChatActions {
+ const actions = useContext(ChatActionsContext);
+ if (actions === null) throw new Error("outside the full chat");
+ return actions;
+}
+
+/**
+ * A message's parts as they render: runs of thinking and tool work fold,
+ * calls waiting on a person stand out with their buttons, and the rest
+ * shows as itself.
+ *
+ * Deciding a call folds it into the run before it, so its buttons unmount.
+ * Focus would fall to the page; it goes to the run's trigger instead.
+ */
+function MessageParts({ message }: { readonly message: ConversationMessage }) {
+ const { decide, openImage } = useChatActions();
+ const own = message.author.id === you.id;
+ const streaming = message.status === "streaming";
+ const groups = groupActivity(message.parts);
+ const root = useRef(null);
+ const decided = useRef(null);
+
+ useLayoutEffect(() => {
+ const toolCallId = decided.current;
+ if (toolCallId === null) return;
+ decided.current = null;
+ const active = document.activeElement;
+ if (active !== null && active !== document.body) return;
+ const group = groups.find((candidate) => holdsCall(candidate, toolCallId));
+ const element =
+ group === undefined
+ ? null
+ : root.current?.querySelector(`[data-group-key='${group.key}']`);
+ if (element !== null && element !== undefined)
+ nearestControl(element)?.focus();
+ });
+
+ const onDecide = (toolCallId: string, decision: ToolApprovalDecision) => {
+ decided.current = toolCallId;
+ decide(message.id, toolCallId, decision);
+ };
+ const files = message.parts.filter(
+ (part): part is PartFile => part.kind === "file",
+ );
+
+ return (
+
+ {groups.map((group, index) => {
+ if (group.kind === "activity")
+ return (
+
+ );
+ const { part } = group;
+ switch (part.kind) {
+ case "text":
+ return (
+
+ );
+ case "tool-trace":
+ return (
+
onDecide(part.toolCallId, decision)}
+ />
+ );
+ case "tool-batch":
+ return (
+
+ onDecide(tool.toolCallId, decision)
+ }
+ />
+ );
+ case "image":
+ return (
+ openImage(part.id, index, opener)}
+ />
+ );
+ case "sources":
+ return (
+
+ {part.sources.map((source, position) => (
+
+ {source.title}
+
+ ))}
+
+ );
+ case "block":
+ return (
+
+
{blockTitle(part.block)}
+
{part.block.type}
+
+ );
+ default:
+ return null;
+ }
+ })}
+ {files.length === 0 ? null : (
+
+ {files.map((file, position) => (
+
+
+
+
+ ))}
+
+ )}
+
+ );
+}
+
+function CopyAction({ text }: { readonly text: string }) {
+ const { copied, copy } = useCopy();
+ return (
+ void copy(text)}
+ >
+ {copied ? : }
+
+ );
+}
+
+/** One message, the same in the main conversation and in a thread. */
+function ChatEntry({
+ message,
+ newest = false,
+ inThread = false,
+}: {
+ readonly message: ConversationMessage;
+ /** The newest answer, which opens unfolded. */
+ readonly newest?: boolean;
+ readonly inThread?: boolean;
+}) {
+ const { react, openThread, shownThread } = useChatActions();
+ const from = fromOf(message);
+ const own = from === "own";
+ const text = textOf(message);
+ const replies = inThread ? undefined : message.replies;
+ const settled = message.status !== "streaming";
+ const footer =
+ own || inThread ? null : (
+ <>
+ {message.reactions === undefined ? null : (
+ react(message.id, emoji)}
+ />
+ )}
+ {replies === undefined ? null : (
+ openThread(message.id)}
+ />
+ )}
+ {text === "" || !settled ? null : (
+
+
+
+ )}
+ >
+ );
+ return (
+
+ {own ? null : }
+ {own ? null : (
+
+ )}
+ {own ? (
+
+ ) : (
+
+
+
+ )}
+ {footer === null ? null : {footer} }
+
+ );
+}
+
+/**
+ * The log: the latest stretch of history mounted, more as the reader
+ * scrolls up, and a map of the mounted turns beside it. Sits inside
+ * `Conversation`, which the history window and the map both follow.
+ */
+function ChatLog({
+ messages,
+}: {
+ readonly messages: readonly ConversationMessage[];
+}) {
+ const history = useHistoryWindow({
+ items: messages,
+ windowSize: 60,
+ step: 30,
+ });
+ const newestAnswer = messages.findLast(
+ (message) => message.author.kind === "agent",
+ )?.id;
+ return (
+ <>
+
+
+
+ {history.items.map((message) => (
+
+
+
+ ))}
+
+
+ >
+ );
+}
+
+const TOKENS: readonly PromptToken[] = [
+ {
+ id: "reviewer",
+ kind: "mention",
+ token: "@Reviewer",
+ detail: "Checks work before it ships",
+ },
+ {
+ id: "planner",
+ kind: "mention",
+ token: "@Planner",
+ detail: "Breaks a goal into steps",
+ },
+ {
+ id: "summarize",
+ kind: "command",
+ token: "/summarize",
+ detail: "Summarize the thread",
+ },
+];
+
+const MODELS: readonly PromptInputModelOption[] = [
+ { id: "swift", name: "Swift 2", short: "Swift", detail: "Quick answers" },
+ {
+ id: "deep",
+ name: "Deep 4",
+ short: "Deep",
+ detail: "Careful, slower reasoning",
+ },
+];
+
+const MODES: readonly PromptInputModeOption[] = [
+ { id: "ask", label: "Ask", detail: "Answers without taking action" },
+ { id: "agent", label: "Agent", detail: "Acts on your behalf" },
+];
+
+const MAX_FILES = 10;
+const MAX_FILE_BYTES = 5 * 1024 * 1024;
+
+function refusal({ file, reason }: AttachmentRejection): string {
+ switch (reason) {
+ case "too-large":
+ return `${file.name} is over 5 MB`;
+ case "duplicate":
+ return `${file.name} is already attached`;
+ case "too-many":
+ return `${file.name} is past the limit of ${MAX_FILES}`;
+ }
+}
+
+/**
+ * The message box: files dropped anywhere on it or added from the `+` menu
+ * wait above it, and go with the next message.
+ */
+function Composer({
+ status,
+ onSend,
+ onStop,
+}: {
+ readonly status: PromptInputStatus;
+ readonly onSend: (text: string, files: readonly AttachmentItem[]) => void;
+ readonly onStop: () => void;
+}) {
+ const [draft, setDraft] = useState("");
+ const [attachments, setAttachments] = useState([]);
+ const [notice, setNotice] = useState("");
+ const [mode, setMode] = useState("agent");
+ const [model, setModel] = useState("deep");
+ const textarea = useRef(null);
+ const picker = useRef(null);
+ const { add, release } = useAttachments({
+ attachments,
+ maxFiles: MAX_FILES,
+ maxFileBytes: MAX_FILE_BYTES,
+ });
+
+ const take = (files: Iterable | ArrayLike) => {
+ const { accepted, rejected } = add(files);
+ setAttachments((current) => [...current, ...accepted]);
+ setNotice(rejected.map(refusal).join("; "));
+ };
+ const startToken = (prefix: string) => {
+ setDraft(
+ (current) =>
+ (current === "" || current.endsWith(" ") ? current : `${current} `) +
+ prefix,
+ );
+ textarea.current?.focus();
+ };
+ const send = () => {
+ // The sent message shows these previews now, so the hook must not
+ // revoke them when they leave the box.
+ release(attachments.map((item) => item.id));
+ onSend(draft.trim(), attachments);
+ setDraft("");
+ setAttachments([]);
+ setNotice("");
+ };
+
+ return (
+ {
+ if (files.length === 0 && folders.length > 0)
+ setNotice(`${folders.join(", ")} has no files to add`);
+ else take(files);
+ }}
+ className="flex flex-col gap-2 px-4 pb-4"
+ >
+ {attachments.length === 0 ? null : (
+
+ {attachments.map((item) => (
+
+
+
+
+ setAttachments((current) =>
+ current.filter((held) => held.id !== item.id),
+ )
+ }
+ />
+
+ ))}
+
+ )}
+ {
+ const { files } = event.currentTarget;
+ if (files !== null) take(files);
+ event.currentTarget.value = "";
+ }}
+ />
+ 0}
+ tokens={TOKENS}
+ status={status}
+ >
+
+
+
+ ,
+ onSelect: () => picker.current?.click(),
+ },
+ {
+ id: "mention",
+ label: "Mention an agent",
+ icon: ,
+ onSelect: () => startToken("@"),
+ },
+ {
+ id: "command",
+ label: "Use a command",
+ icon: ,
+ onSelect: () => startToken("/"),
+ },
+ ]}
+ />
+
+
+
+
+
+ {notice}
+
+
+ Agents can make mistakes. Check important work.
+
+
+ );
+}
+
+const ANSWERS = [
+ "Done. I read what you sent and updated the summary on the workflow.",
+ "I checked the latest runs against it. Nothing else needs your sign-off.",
+ "Noted. I'll fold this into next week's report and flag anything that changes.",
+];
+
+/** The answer to the reader's `count`th message, replayed as it arrives. */
+function answerFor(count: number, files: number): ConversationMessage {
+ const id = `answer-${count}`;
+ return {
+ id,
+ author: assistant,
+ at: SAMPLE_NOW,
+ status: "sent",
+ parts: [
+ {
+ kind: "reasoning",
+ text: "Read what came with the message, then check the latest runs before answering.",
+ durationMs: 2_400,
+ },
+ {
+ kind: "tool-trace",
+ toolCallId: `${id}-read`,
+ name: files > 0 ? "files__read" : "runs__list",
+ status: "output-available",
+ input: files > 0 ? { files } : { limit: 20 },
+ output: files > 0 ? `Read ${files} files` : "20 runs",
+ },
+ {
+ kind: "text",
+ text: ANSWERS[(count - 1) % ANSWERS.length] ?? "",
+ },
+ ],
+ };
+}
+
+/** A call cut off by Stop: it never finished, so it reads as failed. */
+function stopTool(tool: PartTool): PartTool {
+ return tool.status === "running" || tool.status === "pending"
+ ? { ...tool, status: "error", output: "Stopped" }
+ : tool;
+}
+
+/** An answer as it stood when the reader pressed Stop, no longer arriving. */
+function stopped(message: ConversationMessage): ConversationMessage {
+ return {
+ ...message,
+ status: "sent",
+ parts: message.parts.map((part) =>
+ part.kind === "tool-trace"
+ ? stopTool(part)
+ : part.kind === "tool-batch"
+ ? { ...part, tools: part.tools.map(stopTool) }
+ : part,
+ ),
+ };
+}
+
+function upsert(
+ messages: readonly ConversationMessage[],
+ frame: ConversationMessage,
+): readonly ConversationMessage[] {
+ return messages.some((message) => message.id === frame.id)
+ ? messages.map((message) => (message.id === frame.id ? frame : message))
+ : [...messages, frame];
+}
+
+const THREAD_ANSWER =
+ "Added it to the follow-ups, with an owner and a due date.";
+
+/**
+ * A thread's tab: the parent, its replies and the thread's own input. Kept
+ * mounted while the panel shows something else, so the draft stays. The
+ * replies live with the chat, so the count under the parent keeps up.
+ */
+function ThreadTab({
+ parent,
+ replies,
+ onRepliesChange,
+ labelledBy,
+ focusRequest,
+ scrollPolicy,
+}: {
+ readonly parent: ConversationMessage;
+ readonly replies: readonly ConversationMessage[];
+ readonly onRepliesChange: Dispatch<
+ SetStateAction
+ >;
+ /** The tab that shows the thread, which names its log. */
+ readonly labelledBy: string;
+ /** Bumped each time the reader opens this thread, to focus its input. */
+ readonly focusRequest: number;
+ readonly scrollPolicy: ConversationScrollPolicy;
+}) {
+ const [draft, setDraft] = useState("");
+ const [sentId, setSentId] = useState();
+ const [status, setStatus] = useState("ready");
+ const input = useRef(null);
+ const stop = useRef<(() => void) | null>(null);
+ const streamingId = useRef(null);
+ const stopAnswer = useCallback(() => {
+ stop.current?.();
+ const id = streamingId.current;
+ streamingId.current = null;
+ onRepliesChange((current) =>
+ current.map((reply) =>
+ reply.id === id && reply.status === "streaming"
+ ? stopped(reply)
+ : reply,
+ ),
+ );
+ }, [onRepliesChange]);
+ // Closing the tab mid-answer stops it where it stands.
+ useEffect(() => stopAnswer, [stopAnswer]);
+ useEffect(() => {
+ if (focusRequest > 0) input.current?.focus();
+ }, [focusRequest]);
+
+ const send = () => {
+ // The replies outlive the tab, so the count comes from them.
+ const count =
+ replies.filter((reply) => reply.id.startsWith(`${parent.id}-sent-`))
+ .length + 1;
+ const sent: ConversationMessage = {
+ id: `${parent.id}-sent-${count}`,
+ author: you,
+ at: SAMPLE_NOW,
+ status: "sent",
+ parts: [{ kind: "text", text: draft.trim() }],
+ };
+ const answer: ConversationMessage = {
+ id: `${parent.id}-answer-${count}`,
+ author: assistant,
+ at: SAMPLE_NOW,
+ status: "sent",
+ parts: [{ kind: "text", text: THREAD_ANSWER }],
+ };
+ onRepliesChange((current) => [...current, sent]);
+ setSentId(sent.id);
+ setDraft("");
+ setStatus("streaming");
+ streamingId.current = answer.id;
+ stop.current = playTurn(
+ answer,
+ (frame) => {
+ onRepliesChange((current) => upsert(current, frame));
+ if (frame === answer) {
+ streamingId.current = null;
+ setStatus("ready");
+ }
+ },
+ { stepMs: 120 },
+ );
+ };
+
+ return (
+ }
+ sentId={sentId}
+ scrollPolicy={scrollPolicy}
+ aria-labelledby={labelledBy}
+ >
+
+
+ {
+ stopAnswer();
+ setStatus("ready");
+ }}
+ />
+
+
+ );
+}
+
+type Tab = { readonly value: string; readonly title: string };
+
+function threadTab(parent: ConversationMessage): Tab {
+ return {
+ value: `thread:${parent.id}`,
+ title: `Thread: ${firstLine(parent).slice(0, 40)}`,
+ };
+}
+
+/** The image parts' viewer items in chat order, and where each part starts. */
+function viewerItemsOf(messages: readonly ConversationMessage[]) {
+ const offsets = new Map();
+ const parts = messages
+ .flatMap((message) => message.parts)
+ .filter((part): part is PartImage => part.kind === "image");
+ const items = parts.flatMap((part, index) => {
+ const start = parts
+ .slice(0, index)
+ .reduce((total, earlier) => total + earlier.images.length, 0);
+ offsets.set(part.id, start);
+ return generatedImageViewerItems(part);
+ });
+ return { items, offsets };
+}
+
+/**
+ * The whole chat from the samples: the conversation with its history
+ * window and map, messages with their agent steps, images and sources, the
+ * message box with its toolbar and attachments, and a panel holding a
+ * thread in a tab. Nothing moves until the reader acts: sending streams an
+ * answer, and deciding the waiting call folds it into the run before it.
+ */
+function FullChat({
+ scrollPolicy,
+}: {
+ readonly scrollPolicy: ConversationScrollPolicy;
+}) {
+ const [messages, setMessages] = useState(MESSAGES);
+ const [sentId, setSentId] = useState();
+ const [status, setStatus] = useState("ready");
+ const [sends, setSends] = useState(0);
+ const stop = useRef<(() => void) | null>(null);
+ const streamingId = useRef(null);
+ // Previews handed over from the message box; the sent messages show them
+ // until the chat goes away.
+ const released = useRef([]);
+ useEffect(
+ () => () => {
+ stop.current?.();
+ for (const url of released.current) URL.revokeObjectURL(url);
+ },
+ [],
+ );
+
+ const parent = findMessage(MESSAGES, sampleThread.parentId);
+ const { tabs, value, open, tabsProps, paneProps } = useTabs({
+ defaultTabs: [threadTab(parent)],
+ });
+ const [closed, setClosed] = useState(false);
+ const [focusRequest, setFocusRequest] = useState(0);
+ const [threadReplies, setThreadReplies] =
+ useState(THREAD_REPLIES);
+ // The parent's reply summary follows the thread as replies arrive.
+ const shownMessages = useMemo(
+ () =>
+ messages.map((message) =>
+ message.id === parent.id
+ ? {
+ ...message,
+ replies: {
+ count: threadReplies.length,
+ lastAt: threadReplies.at(-1)?.at ?? message.at,
+ },
+ }
+ : message,
+ ),
+ [messages, parent.id, threadReplies],
+ );
+ const shown = closed ? null : value;
+
+ const { items, offsets } = useMemo(() => viewerItemsOf(messages), [messages]);
+ const viewer = useMediaViewer({ items });
+
+ const actions: ChatActions = {
+ decide: (messageId, toolCallId, decision) =>
+ setMessages((current) =>
+ current.map((message) =>
+ message.id === messageId
+ ? {
+ ...message,
+ parts: message.parts.map((part) =>
+ decideCall(part, toolCallId, decision),
+ ),
+ }
+ : message,
+ ),
+ ),
+ react: (messageId, emoji) =>
+ setMessages((current) =>
+ current.map((message) =>
+ message.id === messageId
+ ? {
+ ...message,
+ reactions: toggleReaction(message.reactions ?? [], emoji),
+ }
+ : message,
+ ),
+ ),
+ openImage: (partId, index, opener) =>
+ viewer.open((offsets.get(partId) ?? 0) + index, opener),
+ openThread: (parentId) => {
+ setClosed(false);
+ open(threadTab(findMessage(MESSAGES, parentId)));
+ setFocusRequest((count) => count + 1);
+ },
+ shownThread: shown === threadTab(parent).value ? parent.id : null,
+ };
+
+ const send = (text: string, files: readonly AttachmentItem[]) => {
+ const count = sends + 1;
+ setSends(count);
+ released.current.push(
+ ...files.flatMap((file) => (file.url === undefined ? [] : [file.url])),
+ );
+ const sent: ConversationMessage = {
+ id: `sent-${count}`,
+ author: you,
+ at: SAMPLE_NOW,
+ status: "sent",
+ parts: [
+ ...(text === "" ? [] : [{ kind: "text" as const, text }]),
+ ...files.map((file): PartFile => ({
+ kind: "file",
+ name: file.name,
+ mediaType: file.mediaType,
+ ...(file.url === undefined ? {} : { url: file.url }),
+ })),
+ ],
+ };
+ const answer = answerFor(count, files.length);
+ setMessages((current) => [...current, sent]);
+ setSentId(sent.id);
+ setStatus("streaming");
+ streamingId.current = answer.id;
+ stop.current = playTurn(
+ answer,
+ (frame) => {
+ setMessages((current) => upsert(current, frame));
+ if (frame === answer) {
+ streamingId.current = null;
+ setStatus("ready");
+ }
+ },
+ { stepMs: 120 },
+ );
+ };
+
+ const stopAnswer = () => {
+ stop.current?.();
+ const id = streamingId.current;
+ streamingId.current = null;
+ setMessages((current) =>
+ current.map((message) =>
+ message.id === id && message.status === "streaming"
+ ? stopped(message)
+ : message,
+ ),
+ );
+ setStatus("ready");
+ };
+
+ return (
+
+
+
+
+
+
+
+
+ {shown === null ? null : (
+
+
+
+
+ {tabs.map((tab) => (
+ }
+ title={tab.title}
+ >
+ {tab.title}
+
+ ))}
+
+
+ setClosed(true)} />
+
+
+
+ {tabs.map((tab) => (
+
+
+
+ ))}
+
+
+ )}
+
+
+
+ );
+}
+
+/** The sent message glides to the top and the answer writes in below it. */
+export const Settle = () => ;
+
+/** The view follows the end as the answer arrives, until the reader scrolls away. */
+export const Stick = () => ;