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 ( + + ); + } + 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 = () => ;