From e9ced227f39b44268e969d85f32bf0589fe0c3af Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Thu, 22 Oct 2026 12:51:04 +0530 Subject: [PATCH 1/7] test(stories): add a story of the whole chat Compose the new chat family from the shared samples into one story, in both scroll policies: a conversation with agent steps, approvals and generated images, a prompt input with its toolbar and attachments, the conversation map and history window, and a panel with a thread tab. A browser spec drives the story to check that sending settles, approvals keep keyboard focus after they fold, and attachments move into the sent message. --- e2e/full-chat.spec.ts | 178 ++++ stories/chat/full-chat.stories.tsx | 1260 ++++++++++++++++++++++++++++ 2 files changed, 1438 insertions(+) create mode 100644 e2e/full-chat.spec.ts create mode 100644 stories/chat/full-chat.stories.tsx diff --git a/e2e/full-chat.spec.ts b/e2e/full-chat.spec.ts new file mode 100644 index 00000000..3f8bf3a9 --- /dev/null +++ b/e2e/full-chat.spec.ts @@ -0,0 +1,178 @@ +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"; + +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("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 }) => { + await open(page, "settle"); + const message = page.getByRole("textbox", { name: "Message" }); + await message.fill("Check the runs"); + await message.press("Enter"); + // The call runs for one frame of the replay, so Stop is pressed in the + // page the moment its live line names it. + await page.waitForFunction( + () => { + const trigger = document.querySelector( + "[data-item-id='answer-1'] [data-slot='activity-trigger']", + ); + if (trigger?.textContent?.includes("runs__list…") !== true) return false; + document + .querySelector( + "main [data-slot='prompt-input-submit']", + ) + ?.click(); + return true; + }, + undefined, + { polling: "raf" }, + ); + + const answer = page.locator("[data-item-id='answer-1']"); + const trigger = answer.locator("[data-slot='activity-trigger']"); + 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 reply and the answer that streams in after it. + 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 00000000..d22cd387 --- /dev/null +++ b/stories/chat/full-chat.stories.tsx @@ -0,0 +1,1260 @@ +import { + AtSign, + Check, + Copy, + MessageSquare, + Paperclip, + SquareSlash, +} from "lucide-react"; +import { + Fragment, + createContext, + 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, + 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); + +/** How far apart the recent messages sit once they share one timeline. */ +const RECENT_STEP_MS = 30_000; + +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 after `startMs`, they read in order after + * the long history. + */ +function restamp( + messages: readonly ConversationMessage[], + startMs: number, +): readonly ConversationMessage[] { + return messages.map((message, index) => ({ + ...message, + at: stampedAt(startMs + (index + 1) * RECENT_STEP_MS), + })); +} + +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 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( + [ + ...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: "Done", 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, image, 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) => ( + + + + + ))} + + )} +
+ ); +} + +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 = () => { + stop.current?.(); + const id = streamingId.current; + streamingId.current = null; + onRepliesChange((current) => + current.map((reply) => + reply.id === id && reply.status === "streaming" + ? stopped(reply) + : reply, + ), + ); + }; + // Closing the tab mid-answer stops it where it stands. + const stopOnClose = useRef(stopAnswer); + stopOnClose.current = stopAnswer; + useEffect(() => () => stopOnClose.current(), []); + 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 = () => ; From 115c5585d3cd2cee9c75ae2baab4654756455bbe Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Tue, 27 Oct 2026 19:25:52 +0545 Subject: [PATCH 2/7] test(stories): compose sources in the full chat; steady the Stop test No mounted message carried a sources part, so the Sources family never rendered in the whole-chat story. Start the recent stretch with the short-chat sample, whose answer cites two sources, and check them in the browser spec. The Stop test had to hit a call that runs for one 120 ms replay frame, clicking from inside a raf-polled waitForFunction. Under CPU load it missed the frame, or clicked as the settled frame landed. Hold the page clock once the story has loaded and step the replay a frame at a time, so the running frame stays put while Stop is pressed. Also name the GeneratedImages onOpen index for what it is, and derive an approved call's output from its name instead of a fixed "Done". --- e2e/full-chat.spec.ts | 51 +++++++++++++++++++----------- stories/chat/full-chat.stories.tsx | 18 ++++++++--- 2 files changed, 46 insertions(+), 23 deletions(-) diff --git a/e2e/full-chat.spec.ts b/e2e/full-chat.spec.ts index 3f8bf3a9..ae1583d5 100644 --- a/e2e/full-chat.spec.ts +++ b/e2e/full-chat.spec.ts @@ -4,6 +4,13 @@ import { expect, test, type Page } from "@playwright/test"; // 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`, @@ -51,6 +58,18 @@ for (const [story, decision, outcome] of [ }); } +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("a send takes the attached file into the message and clears the box", async ({ page, }) => { @@ -124,31 +143,27 @@ test("a file sends on its own, with no text", async ({ page }) => { }); 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"); - // The call runs for one frame of the replay, so Stop is pressed in the - // page the moment its live line names it. - await page.waitForFunction( - () => { - const trigger = document.querySelector( - "[data-item-id='answer-1'] [data-slot='activity-trigger']", - ); - if (trigger?.textContent?.includes("runs__list…") !== true) return false; - document - .querySelector( - "main [data-slot='prompt-input-submit']", - ) - ?.click(); - return true; - }, - undefined, - { polling: "raf" }, - ); 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(); + await page.clock.resume(); + await expect(trigger).toContainText("1 failed"); await expect(answer.locator("[data-slot='message']")).toHaveAttribute( "data-status", diff --git a/stories/chat/full-chat.stories.tsx b/stories/chat/full-chat.stories.tsx index d22cd387..ab43d03d 100644 --- a/stories/chat/full-chat.stories.tsx +++ b/stories/chat/full-chat.stories.tsx @@ -115,6 +115,7 @@ import { assistant, generatedImages, longChat, + shortChat, toolStorm, withThread, you, @@ -157,12 +158,14 @@ function threadOf(sample: ConversationSample) { const sampleThread = threadOf(withThread); /** - * The long history, then 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. + * 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, @@ -312,7 +315,12 @@ function settle(tool: PartTool, decision: ToolApprovalDecision): PartTool { }; const { output: _output, ...rest } = tool; return decision.outcome === "approved" - ? { ...rest, status: "output-available", output: "Done", approval } + ? { + ...rest, + status: "output-available", + output: `Ran ${tool.name}`, + approval, + } : { ...rest, status: "output-denied", approval }; } @@ -467,7 +475,7 @@ function MessageParts({ message }: { readonly message: ConversationMessage }) { openImage(part.id, image, opener)} + onOpen={(index, opener) => openImage(part.id, index, opener)} /> ); case "sources": From 9765e62aaf2756f1cd72877e31aa50fb44d6fd09 Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Tue, 27 Oct 2026 19:25:52 +0545 Subject: [PATCH 3/7] refactor(stories): stop a closed thread's answer without a render ref The full-chat ThreadTab kept its stop function in a ref written during render, so unmounting could call the latest one. Make the function stable with useCallback instead and return it from the effect. --- stories/chat/full-chat.stories.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/stories/chat/full-chat.stories.tsx b/stories/chat/full-chat.stories.tsx index ab43d03d..cff8cf9e 100644 --- a/stories/chat/full-chat.stories.tsx +++ b/stories/chat/full-chat.stories.tsx @@ -9,6 +9,7 @@ import { import { Fragment, createContext, + useCallback, useContext, useEffect, useLayoutEffect, @@ -947,7 +948,7 @@ function ThreadTab({ const input = useRef(null); const stop = useRef<(() => void) | null>(null); const streamingId = useRef(null); - const stopAnswer = () => { + const stopAnswer = useCallback(() => { stop.current?.(); const id = streamingId.current; streamingId.current = null; @@ -958,11 +959,9 @@ function ThreadTab({ : reply, ), ); - }; + }, [onRepliesChange]); // Closing the tab mid-answer stops it where it stands. - const stopOnClose = useRef(stopAnswer); - stopOnClose.current = stopAnswer; - useEffect(() => () => stopOnClose.current(), []); + useEffect(() => stopAnswer, [stopAnswer]); useEffect(() => { if (focusRequest > 0) input.current?.focus(); }, [focusRequest]); From f90f79032a9e8cf2bddd78471cf28b09f469039d Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Wed, 28 Oct 2026 14:55:44 +0545 Subject: [PATCH 4/7] test(stories): prove stop and thread replies in the full chat e2e The Stop test resumed the page clock right after Stop. That lets the clock run in real time, so a replay frame that Stop failed to cancel could land after the asserts and the test still passed. Move the paused clock on by a full run of frames first, so any leaked frame lands before the asserts. The thread reply test checked for "4 replies" as soon as the reply was sent. The count reaches 4 with the answer's first, empty frame, so it passed even if the answer never streamed. Wait for the answer's text first, which also shows the final frame replaces the streaming one. --- e2e/full-chat.spec.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/e2e/full-chat.spec.ts b/e2e/full-chat.spec.ts index ae1583d5..acd9d8d1 100644 --- a/e2e/full-chat.spec.ts +++ b/e2e/full-chat.spec.ts @@ -162,6 +162,9 @@ test("Stop settles a call that was running as failed", async ({ page }) => { } 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"); @@ -188,6 +191,13 @@ test("a reply in the thread tab adds to the count under its parent", async ({ const reply = page.getByRole("textbox", { name: "Reply" }); await reply.fill("Add owners to the follow-ups"); await reply.press("Enter"); - // The reply and the answer that streams in after it. + // 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(); }); From 235bf2980f7c04b027694c38acdc152b27e8bdf3 Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Wed, 28 Oct 2026 20:05:57 +0545 Subject: [PATCH 5/7] fix(stories): keep the full chat's file notice in the accessibility tree The composer's refusal line is a status region, but empty:hidden took it out of the accessibility tree while it had no text. A refused file then brought the region and its text in the same update, which screen readers generally do not announce. The line now stays mounted with its reserved height, as in the Attachments story, so the refusal is read out as a change to it. --- e2e/full-chat.spec.ts | 17 +++++++++++++++++ stories/chat/full-chat.stories.tsx | 5 +---- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/e2e/full-chat.spec.ts b/e2e/full-chat.spec.ts index acd9d8d1..200e2b80 100644 --- a/e2e/full-chat.spec.ts +++ b/e2e/full-chat.spec.ts @@ -70,6 +70,23 @@ test("an answer's sources show as links under it", async ({ page }) => { await expect(sources.locator("[data-slot='source']")).toHaveCount(2); }); +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("a send takes the attached file into the message and clears the box", async ({ page, }) => { diff --git a/stories/chat/full-chat.stories.tsx b/stories/chat/full-chat.stories.tsx index cff8cf9e..8750bfd1 100644 --- a/stories/chat/full-chat.stories.tsx +++ b/stories/chat/full-chat.stories.tsx @@ -835,10 +835,7 @@ function Composer({ -

+

{notice}

From 4e1cf1a8302baa101d672a7147bf303b96dd969e Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Wed, 28 Oct 2026 20:05:57 +0545 Subject: [PATCH 6/7] fix(stories): date the full chat's recent messages before now The recent samples were spaced 30 seconds apart after the long history, whose last message sits 7 minutes before the story's now. Seventeen steps ran past now, so the last answers read "in 30s" and "in 2m", and a message sent from the box read as older than the one above it. The step now divides the gap between the history and now, so every message sits before now, in order. --- e2e/full-chat.spec.ts | 15 +++++++++++++++ stories/chat/full-chat.stories.tsx | 10 ++++------ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/e2e/full-chat.spec.ts b/e2e/full-chat.spec.ts index 200e2b80..9a9430dc 100644 --- a/e2e/full-chat.spec.ts +++ b/e2e/full-chat.spec.ts @@ -70,6 +70,21 @@ test("an answer's sources show as links under it", async ({ page }) => { 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, }) => { diff --git a/stories/chat/full-chat.stories.tsx b/stories/chat/full-chat.stories.tsx index 8750bfd1..1ceb4ba6 100644 --- a/stories/chat/full-chat.stories.tsx +++ b/stories/chat/full-chat.stories.tsx @@ -128,25 +128,23 @@ export default { title: "Chat / Full chat" }; const NOW = Date.parse(SAMPLE_NOW); -/** How far apart the recent messages sit once they share one timeline. */ -const RECENT_STEP_MS = 30_000; - 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 after `startMs`, they read in order after - * the long history. + * 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) * RECENT_STEP_MS), + at: stampedAt(startMs + (index + 1) * step), })); } From 7718fd3cc7340f5b1053e64c8adcdaf977bc3d14 Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Wed, 28 Oct 2026 20:05:57 +0545 Subject: [PATCH 7/7] fix(stories): key the full chat's sent files by position Sent file tiles were keyed by name, but two files of one name and different sizes are both accepted, so a message could hold two tiles with the same key. They are now keyed by position and name. --- e2e/full-chat.spec.ts | 16 ++++++++++++++++ stories/chat/full-chat.stories.tsx | 4 ++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/e2e/full-chat.spec.ts b/e2e/full-chat.spec.ts index 9a9430dc..60f6661f 100644 --- a/e2e/full-chat.spec.ts +++ b/e2e/full-chat.spec.ts @@ -102,6 +102,22 @@ test("a refused file is read out from a status line already in place", async ({ 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, }) => { diff --git a/stories/chat/full-chat.stories.tsx b/stories/chat/full-chat.stories.tsx index 1ceb4ba6..b7f95cc7 100644 --- a/stories/chat/full-chat.stories.tsx +++ b/stories/chat/full-chat.stories.tsx @@ -507,8 +507,8 @@ function MessageParts({ message }: { readonly message: ConversationMessage }) { })} {files.length === 0 ? null : ( - {files.map((file) => ( - + {files.map((file, position) => ( +