// @vitest-environment jsdom import { act, StrictMode, useState, type ReactElement } from "react"; import { flushSync } from "react-dom"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { buildAgentMentionHref, buildSkillMentionHref, } from "@paperclipai/shared"; import { parseRunnerGoalCommand, TaskChatComposer } from "./TaskChatComposer"; import { QuestionForm } from "./QuestionForm"; import { DRAFT_DEBOUNCE_MS } from "../../lib/composer-draft"; import { loadDraftSubmission, saveDraft, saveDraftSubmission, } from "../../lib/composer-draft"; import { CommentSubmissionUnknownError } from "../../lib/comment-submit-result"; /** * MDXEditor-in-jsdom weight (mirrors MarkdownEditor.test.tsx): the real editor * is mocked with a contenteditable bridge — typing is simulated by setting * textContent and dispatching an input event, imperative setMarkdown / * insertMarkdown mutate the same content, and imagePlugin's config is captured * so the inline upload handler can be exercised directly. */ const mdxEditorMockState = vi.hoisted(() => ({ imagePluginOptions: null as { imageUploadHandler?: (file: File) => Promise; } | null, })); vi.mock("@mdxeditor/editor", async () => { const React = await import("react"); function setForwardedRef( ref: React.ForwardedRef, value: T | null, ) { if (typeof ref === "function") { ref(value); return; } if (ref) { (ref as React.MutableRefObject).current = value; } } interface MockHandle { setMarkdown: (value: string) => void; insertMarkdown: (value: string) => void; focus: (callback?: () => void) => void; } const MDXEditor = React.forwardRef(function MockMDXEditor( { markdown, onChange, readOnly, contentEditableClassName, }: { markdown: string; onChange?: (value: string) => void; readOnly?: boolean; contentEditableClassName?: string; }, forwardedRef: React.ForwardedRef, ) { const editableRef = React.useRef(null); const contentRef = React.useRef(markdown); const onChangeRef = React.useRef(onChange); onChangeRef.current = onChange; const handle = React.useMemo( () => ({ setMarkdown: (value: string) => { contentRef.current = value; if (editableRef.current) editableRef.current.textContent = value; }, insertMarkdown: (value: string) => { const next = contentRef.current ? `${contentRef.current}${value}` : value; contentRef.current = next; if (editableRef.current) editableRef.current.textContent = next; onChangeRef.current?.(next); }, focus: (callback?: () => void) => { editableRef.current?.focus(); callback?.(); }, }), [], ); React.useEffect(() => { if (editableRef.current && contentRef.current) { editableRef.current.textContent = contentRef.current; } setForwardedRef(forwardedRef, handle); return () => setForwardedRef(forwardedRef, null); }, [forwardedRef, handle]); return (
{ const next = e.currentTarget.textContent ?? ""; contentRef.current = next; onChangeRef.current?.(next); }} /> ); }); return { CodeMirrorEditor: () => null, MDXEditor, codeBlockPlugin: () => ({}), codeMirrorPlugin: () => ({}), createRootEditorSubscription$: Symbol("createRootEditorSubscription$"), headingsPlugin: () => ({}), imagePlugin: (options: { imageUploadHandler?: (file: File) => Promise; }) => { mdxEditorMockState.imagePluginOptions = options; return {}; }, linkDialogPlugin: () => ({}), linkPlugin: () => ({}), listsPlugin: () => ({}), markdownShortcutPlugin: () => ({}), quotePlugin: () => ({}), realmPlugin: (plugin: unknown) => plugin, tablePlugin: () => ({}), thematicBreakPlugin: () => ({}), }; }); vi.mock("../../lib/mention-deletion", () => ({ mentionDeletionPlugin: () => ({}), })); vi.mock("../../lib/paste-normalization", () => ({ pasteNormalizationPlugin: () => ({}), })); const SLASH_HREF = buildSkillMentionHref("skill-1", "deploy"); vi.mock("../../context/EditorAutocompleteContext", () => ({ useEditorAutocomplete: () => ({ slashCommands: [ { id: "skill:skill-1", kind: "skill", skillId: "skill-1", key: "deploy", name: "Deploy", slug: "deploy", description: null, href: SLASH_HREF, aliases: ["deploy", "Deploy"], }, ], }), })); let container: HTMLDivElement; let root: Root | null = null; let originalRangeRect: typeof Range.prototype.getBoundingClientRect; beforeEach(() => { localStorage.clear(); container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container); mdxEditorMockState.imagePluginOptions = null; // jsdom ranges have zero-size rects; the mention menu measures the caret. originalRangeRect = Range.prototype.getBoundingClientRect; Range.prototype.getBoundingClientRect = () => ({ x: 32, y: 24, width: 12, height: 18, top: 24, right: 44, bottom: 42, left: 32, toJSON: () => ({}), }); }); afterEach(() => { flushSync(() => root?.unmount()); root = null; container.remove(); Range.prototype.getBoundingClientRect = originalRangeRect; window.getSelection()?.removeAllRanges(); }); function render(ui: ReactElement) { flushSync(() => root!.render(ui)); } async function flushAsync() { await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); } function editable() { return container.querySelector('[data-testid="mdx-editor"]')!; } function sendButton() { return container.querySelector( '[data-testid="task-chat-composer-send"]', )!; } /** Simulate typing: set the contenteditable's text and fire an input event. */ function typeText(value: string) { const el = editable(); flushSync(() => { el.textContent = value; el.dispatchEvent(new Event("input", { bubbles: true })); }); } function pressKey( key: string, modifiers: { metaKey?: boolean; ctrlKey?: boolean; shiftKey?: boolean } = {}, ) { flushSync(() => { editable().dispatchEvent( new KeyboardEvent("keydown", { key, ...modifiers, bubbles: true, cancelable: true, }), ); }); } function pasteFiles(files: File[]) { const paste = new Event("paste", { bubbles: true, cancelable: true }); Object.defineProperty(paste, "clipboardData", { value: { files, types: ["Files"] }, }); flushSync(() => { editable().dispatchEvent(paste); }); return paste; } /** Place the caret at the end of the editable's first text node and announce it. */ async function placeCaretAtEnd() { const textNode = editable().firstChild; expect(textNode?.nodeType).toBe(Node.TEXT_NODE); const selection = window.getSelection(); const range = document.createRange(); range.setStart(textNode!, textNode!.textContent!.length); range.collapse(true); selection?.removeAllRanges(); selection?.addRange(range); flushSync(() => { document.dispatchEvent(new Event("selectionchange")); }); // Mention detection defers via requestAnimationFrame after input events. await flushAsync(); await flushAsync(); } function autocompleteOption(matchText: string) { const menu = document.body.querySelector( '[data-testid="mention-autocomplete-menu"]', ); expect(menu).toBeTruthy(); const option = Array.from( menu!.querySelectorAll('button[type="button"]'), ).find((node) => node.textContent?.includes(matchText)); expect(option).toBeTruthy(); return option!; } describe("TaskChatComposer", () => { it.each(["success", "unknown"])( "does not overwrite another retained attempt after an older %s", async (outcome) => { const key = "exact-attempt-settlement"; let settle!: () => void; const onAdd = vi.fn().mockReturnValue( new Promise((resolve, reject) => { settle = outcome === "success" ? resolve : () => reject(new CommentSubmissionUnknownError()); }), ); render( , ); typeText("Older request"); pressKey("Enter", { metaKey: true }); await flushAsync(); const newer = { version: 1, draftKey: key, attemptId: "aaf8228f-0be7-45ae-a104-6fbe0af6f1d3", reviewed: false, }; localStorage.setItem(key, "Newer retained draft"); localStorage.setItem(`${key}:attachments:v1`, "newer receipt sentinel"); localStorage.setItem(`${key}:submission:v1`, JSON.stringify(newer)); settle(); await flushAsync(); await flushAsync(); window.dispatchEvent(new Event("beforeunload")); expect(localStorage.getItem(`${key}:submission:v1`)).toBe( JSON.stringify(newer), ); expect(localStorage.getItem(key)).toBe("Newer retained draft"); expect(localStorage.getItem(`${key}:attachments:v1`)).toBe( "newer receipt sentinel", ); }, ); it("fences an unknown save in memory with disabled storage until a successful explicit review and local discard", async () => { const storage = vi .spyOn(Storage.prototype, "setItem") .mockImplementation(() => { throw new Error("Storage disabled"); }); const onAdd = vi .fn() .mockRejectedValue(new CommentSubmissionUnknownError()); const review = vi .fn() .mockRejectedValueOnce(new Error("Refresh unavailable")) .mockResolvedValue(undefined); try { render( , ); typeText("Do not replay this unknown save"); pressKey("Enter", { metaKey: true }); await flushAsync(); await flushAsync(); expect(sendButton().disabled).toBe(true); expect(container.textContent).toContain( "couldn’t confirm whether this comment was saved", ); pressKey("Enter", { metaKey: true }); await flushAsync(); expect(onAdd).toHaveBeenCalledTimes(1); const reviewButton = () => Array.from(container.querySelectorAll("button")).find( (button) => button.textContent === "Review conversation", )!; flushSync(() => reviewButton().click()); await flushAsync(); expect(container.textContent).toContain( "Couldn’t refresh the conversation", ); expect(container.textContent).not.toContain( "Discard draft and start new", ); flushSync(() => reviewButton().click()); await flushAsync(); expect(review).toHaveBeenCalledTimes(2); const discard = Array.from(container.querySelectorAll("button")).find( (button) => button.textContent === "Discard draft and start new", )!; flushSync(() => discard.click()); expect(editable().textContent).toBe(""); expect(onAdd).toHaveBeenCalledTimes(1); } finally { storage.mockRestore(); } }); it("restores an in-flight submission as uncertain only for its exact task key", async () => { const key = "uncertain-task-one"; const attemptId = "9af8228f-0be7-45ae-a104-6fbe0af6f1d3"; saveDraft(key, "Retained unknown draft"); saveDraftSubmission(key, { attemptId, reviewed: false }); const onAdd = vi.fn(); render( , ); expect(editable().textContent).toBe("Retained unknown draft"); expect(sendButton().disabled).toBe(true); expect(loadDraftSubmission(key)?.attemptId).toBe(attemptId); render( , ); await flushAsync(); typeText("Separate task draft"); await flushAsync(); expect(sendButton().disabled).toBe(false); expect(loadDraftSubmission(key)?.attemptId).toBe(attemptId); expect(onAdd).not.toHaveBeenCalled(); }); it("retains explicit upload receipt IDs across a failed send without uploading again", async () => { const onAdd = vi .fn() .mockRejectedValueOnce(new Error("try again")) .mockResolvedValue(undefined); const id = "9af8228f-0be7-45ae-a104-6fbe0af6f1d3"; const onAttachImage = vi.fn().mockResolvedValue({ id, contentPath: `/api/attachments/${id}/content`, originalFilename: "notes.txt", }); render( , ); typeText("Inspect the new file."); pasteFiles([new File(["new bytes"], "notes.txt", { type: "text/plain" })]); await flushAsync(); flushSync(() => sendButton().click()); await flushAsync(); expect(onAdd.mock.calls[0]?.[3]).toEqual([id]); flushSync(() => sendButton().click()); await flushAsync(); expect(onAdd.mock.calls[1]?.[3]).toEqual([id]); expect(onAttachImage).toHaveBeenCalledTimes(1); }); it("holds submission while an inline image upload is pending and binds its receipt", async () => { const onAdd = vi.fn().mockResolvedValue(undefined); const id = "5e5f9946-c706-4785-8988-d4d6f0f499ab"; let resolveUpload!: (value: unknown) => void; const onAttachImage = vi.fn().mockReturnValue( new Promise((resolve) => { resolveUpload = resolve; }), ); render( , ); typeText("Inspect the picture."); const handler = mdxEditorMockState.imagePluginOptions!.imageUploadHandler!; const upload = handler( new File(["png"], "image.png", { type: "image/png" }), ); await flushAsync(); expect(sendButton().disabled).toBe(true); pressKey("Enter", { metaKey: true }); expect(onAdd).not.toHaveBeenCalled(); resolveUpload({ id, contentPath: `/api/attachments/${id}/content`, originalFilename: "image.png", }); const url = await upload; typeText(`Inspect the picture.\n\n![image](${url})`); await flushAsync(); flushSync(() => sendButton().click()); await flushAsync(); expect(onAdd.mock.calls[0]?.[3]).toEqual([id]); }); it("does not infer binding from arbitrary pasted attachment Markdown", async () => { const onAdd = vi.fn().mockResolvedValue(undefined); render(); typeText( "[old file](/api/attachments/9af8228f-0be7-45ae-a104-6fbe0af6f1d3/content)", ); flushSync(() => sendButton().click()); await flushAsync(); expect(onAdd.mock.calls[0]).toHaveLength(3); }); it("submits multiple retained file and image receipts but not removed selections", async () => { const onAdd = vi.fn().mockResolvedValue(undefined); const receipts = [ "9af8228f-0be7-45ae-a104-6fbe0af6f1d3", "5e5f9946-c706-4785-8988-d4d6f0f499ab", "975687b9-d594-46a1-8b9e-707de6480fd7", "0aab5c84-8722-40f6-ae89-4b35040cedcf", ]; const onAttachImage = vi.fn().mockImplementation(async (file: File) => { const id = receipts[onAttachImage.mock.calls.length - 1]!; return { id, contentPath: `/api/attachments/${id}/content`, originalFilename: file.name, }; }); render( , ); pasteFiles([ new File(["one"], "one.txt", { type: "text/plain" }), new File(["two"], "two.txt", { type: "text/plain" }), ]); await flushAsync(); const handler = mdxEditorMockState.imagePluginOptions!.imageUploadHandler!; const retainedImage = await handler( new File(["png"], "three.png", { type: "image/png" }), ); await handler(new File(["png"], "removed.png", { type: "image/png" })); typeText(`Only this picture remains ![three](${retainedImage})`); await flushAsync(); flushSync(() => container .querySelector( 'button[aria-label="Remove two.txt"]', )! .click(), ); flushSync(() => sendButton().click()); await flushAsync(); expect(onAdd.mock.calls[0]?.[3]).toEqual([receipts[0], receipts[2]]); expect(onAdd.mock.calls[0]?.[0]).not.toContain("two.txt"); expect(onAdd.mock.calls[0]?.[0]).not.toContain("removed.png"); }); it("restores file and inline image receipts after a fresh composer mount", async () => { const onAdd = vi.fn().mockResolvedValue(undefined); const ids = [ "9af8228f-0be7-45ae-a104-6fbe0af6f1d3", "5e5f9946-c706-4785-8988-d4d6f0f499ab", ]; const onAttachImage = vi.fn().mockImplementation(async (file: File) => { const id = ids[onAttachImage.mock.calls.length - 1]!; return { id, contentPath: `/api/attachments/${id}/content`, originalFilename: file.name, }; }); const props = { onAdd, workMode: "standard" as const, onAttachImage, draftKey: "receipt-reload", }; render(); pasteFiles([new File(["text"], "file.txt", { type: "text/plain" })]); await flushAsync(); const url = await mdxEditorMockState.imagePluginOptions! .imageUploadHandler!( new File(["png"], "image.png", { type: "image/png" }), ); typeText(`Inspect both files ![image](${url})`); await flushAsync(); render(
); render(); await flushAsync(); expect(container.textContent).toContain("file.txt"); expect(editable().textContent).toContain(url); flushSync(() => sendButton().click()); await flushAsync(); expect(onAdd.mock.calls[0]?.[3]).toEqual(ids); expect(onAttachImage).toHaveBeenCalledTimes(2); expect(localStorage.getItem("receipt-reload:attachments:v1")).toBeNull(); }); it("removes a pending inline image without a late insertion or restored receipt", async () => { const onAdd = vi.fn().mockResolvedValue(undefined); let resolveUpload!: (value: unknown) => void; const onAttachImage = vi.fn().mockReturnValue( new Promise((resolve) => { resolveUpload = resolve; }), ); render( , ); typeText("Keep this text"); const upload = mdxEditorMockState.imagePluginOptions!.imageUploadHandler!( new File(["png"], "pending.png", { type: "image/png" }), ); const rejected = expect(upload).rejects.toThrow("Attachment was removed"); await flushAsync(); expect(container.textContent).toContain("pending.png"); expect(sendButton().disabled).toBe(true); flushSync(() => container .querySelector( 'button[aria-label="Remove pending.png"]', )! .click(), ); resolveUpload({ id: "9af8228f-0be7-45ae-a104-6fbe0af6f1d3", contentPath: "/api/attachments/9af8228f-0be7-45ae-a104-6fbe0af6f1d3/content", }); await rejected; await flushAsync(); expect(editable().textContent).toBe("Keep this text"); expect(localStorage.getItem("removed-pending:attachments:v1")).toBeNull(); flushSync(() => sendButton().click()); await flushAsync(); expect(onAdd.mock.calls[0]).toHaveLength(3); }); it("adds 10px to the composer's original 8px interior padding", () => { render( {}} workMode="standard" />); const composer = container.querySelector( '[data-testid="task-chat-composer-input"]', )?.parentElement; expect(composer?.className).toContain("p-(--sz-18px)"); expect(composer?.className).not.toContain("p-2"); }); it("leaves an 8px token gap between the editor and action row", () => { render( {}} workMode="planning" onWorkModeChange={async () => {}} />, ); const actions = container.querySelector( '[data-testid="task-chat-composer-actions"]', ); expect(actions?.classList).toContain("mt-2"); expect(actions?.classList).not.toContain("mt-1"); }); it("renders a light card shell while preserving the borderless dark treatment", () => { render( {}} workMode="planning" onWorkModeChange={async () => {}} enableReassign currentAssigneeValue="agent:runner" reassignOptions={[{ id: "agent:runner", label: "Runner" }]} />, ); const composer = container.firstElementChild as HTMLElement; const mode = container.querySelector( '[data-testid="task-chat-composer-mode"]', )!; const runner = container.querySelector( '[data-testid="task-chat-composer-assignee"]', )!; expect(composer.classList).toContain("border"); expect(composer.classList).toContain("border-border"); expect(composer.classList).toContain("bg-card"); expect(composer.classList).toContain("shadow-(--shadow-task-composer)"); expect(composer.classList).toContain("dark:border-0"); expect(composer.classList).toContain("dark:bg-muted"); expect(composer.classList).toContain("dark:shadow-none"); expect(composer.className).not.toContain("focus-within:ring"); expect(mode.classList).not.toContain("border"); expect(mode.className).not.toContain("ring-"); expect(runner.classList).toContain("border-0"); expect(runner.classList).not.toContain("border"); expect(runner.className).not.toContain("ring-2"); }); it("scopes the wrapping placeholder override to the task-chat composer", () => { render(); expect(container.firstElementChild?.classList).toContain( "paperclip-task-chat-composer", ); }); it("uses a compact mobile editor that can grow with the message", () => { render(); expect(editable().dataset.contentClassName).toContain("min-h-(--sz-48px)"); expect(editable().dataset.contentClassName).toContain("max-h-(--sz-28dvh)"); }); it("submits the trimmed body on Cmd+Enter and clears the draft", async () => { const onAdd = vi.fn().mockResolvedValue(undefined); render(); expect(sendButton().disabled).toBe(true); typeText(" hello there "); expect(sendButton().disabled).toBe(false); pressKey("Enter", { metaKey: true }); await flushAsync(); await flushAsync(); expect(onAdd).toHaveBeenCalledWith("hello there", undefined, undefined); expect(editable().textContent).toBe(""); }); it("submits on Ctrl+Enter", async () => { const onAdd = vi.fn().mockResolvedValue(undefined); render(); typeText("hello"); pressKey("Enter", { ctrlKey: true }); await flushAsync(); expect(onAdd).toHaveBeenCalledWith("hello", undefined, undefined); }); it("does not submit on plain Enter or Shift+Enter (newline stays with the editor)", async () => { const onAdd = vi.fn().mockResolvedValue(undefined); render(); typeText("line one"); pressKey("Enter"); pressKey("Enter", { shiftKey: true }); await flushAsync(); expect(onAdd).not.toHaveBeenCalled(); expect(editable().textContent).toBe("line one"); }); it("cycles the pending mode with Shift+Tab and applies it on submit", async () => { const onAdd = vi.fn().mockResolvedValue(undefined); const onWorkModeChange = vi.fn().mockResolvedValue(undefined); render( , ); const chip = container.querySelector( '[data-testid="task-chat-composer-mode"]', )!; expect(chip.getAttribute("data-pending-work-mode")).toBe("standard"); expect(chip.textContent).toContain("Auto"); pressKey("Tab", { shiftKey: true }); expect(chip.getAttribute("data-pending-work-mode")).toBe("planning"); expect(chip.textContent).toContain("Plan"); typeText("do the plan"); pressKey("Enter", { metaKey: true }); await flushAsync(); expect(onWorkModeChange).toHaveBeenCalledWith("planning"); expect(onAdd).toHaveBeenCalledWith("do the plan", undefined, undefined); }); it("cycles Auto, Plan, and Ask modes with Cmd+Period while focused", () => { const onWorkModeChange = vi.fn().mockResolvedValue(undefined); render( , ); const chip = container.querySelector( '[data-testid="task-chat-composer-mode"]', )!; editable().focus(); expect(chip.getAttribute("aria-keyshortcuts")).toContain("Meta+Period"); expect(chip.getAttribute("data-pending-work-mode")).toBe("standard"); const cycleMode = () => { const event = new KeyboardEvent("keydown", { key: ".", code: "Period", metaKey: true, bubbles: true, cancelable: true, }); flushSync(() => editable().dispatchEvent(event)); expect(event.defaultPrevented).toBe(true); }; cycleMode(); expect(chip.getAttribute("data-pending-work-mode")).toBe("planning"); expect(chip.textContent).toContain("Plan"); cycleMode(); expect(chip.getAttribute("data-pending-work-mode")).toBe("ask"); expect(chip.textContent).toContain("Ask"); cycleMode(); expect(chip.getAttribute("data-pending-work-mode")).toBe("standard"); expect(chip.textContent).toContain("Auto"); expect(onWorkModeChange).not.toHaveBeenCalled(); }); it("uses the borderless Paper controls and inverse circular send button", () => { render( , ); const mode = container.querySelector( '[data-testid="task-chat-composer-mode"]', )!; const assignee = container.querySelector( '[data-testid="task-chat-composer-assignee"]', )!; const send = sendButton(); expect(mode.classList).not.toContain("border"); expect(mode.classList).toContain("border-0"); expect(mode.classList).toContain("status-chip"); expect(mode.style.getPropertyValue("--sc")).toBe("var(--tc-mode-agent)"); expect(assignee.classList).toContain("border-0"); expect(assignee.classList).toContain("shadow-none"); expect(send.classList).toContain("rounded-full"); expect(send.classList).toContain("bg-foreground"); expect(send.classList).toContain("text-background"); expect(send.classList).toContain("disabled:opacity-100"); }); it("passes reopen=true when the issue resumes-to-todo and the assignee is an agent", async () => { const onAdd = vi.fn().mockResolvedValue(undefined); render( , ); typeText("wake up"); pressKey("Enter", { metaKey: true }); await flushAsync(); expect(onAdd).toHaveBeenCalledWith("wake up", true, undefined); }); it("hides the attach button without an upload handler and shows it with one", () => { render(); expect( container.querySelector('[data-testid="task-chat-composer-attach"]'), ).toBeNull(); render( , ); expect( container.querySelector('[data-testid="task-chat-composer-attach"]'), ).not.toBeNull(); }); it("wires the editor's inline image upload to onAttachImage and returns the attachment URL", async () => { const onAttachImage = vi.fn().mockResolvedValue({ contentPath: "/attachments/shot.png", originalFilename: "shot.png", }); render( , ); const handler = mdxEditorMockState.imagePluginOptions?.imageUploadHandler; expect(handler).toBeTypeOf("function"); const file = new File(["png-bytes"], "shot.png", { type: "image/png" }); await expect(handler!(file)).resolves.toBe("/attachments/shot.png"); expect(onAttachImage).toHaveBeenCalledWith(file); // Inline images do not go through the attachment chip row. expect( container.querySelector('[data-testid="task-chat-composer-attachments"]'), ).toBeNull(); }); it("does not register the image plugin without an upload handler", () => { render(); expect(mdxEditorMockState.imagePluginOptions).toBeNull(); }); it("attaches pasted non-image files to the chip row and posts a link reference", async () => { const onAdd = vi.fn().mockResolvedValue(undefined); const onAttachImage = vi.fn().mockResolvedValue({ contentPath: "/attachments/notes.txt", originalFilename: "notes.txt", }); render( , ); const file = new File(["plain"], "notes.txt", { type: "text/plain" }); const paste = pasteFiles([file]); await flushAsync(); // The all-non-image paste is swallowed before the editor sees it. expect(paste.defaultPrevented).toBe(true); expect(onAttachImage).toHaveBeenCalledWith(file); const chips = container.querySelector( '[data-testid="task-chat-composer-attachments"]', ); expect(chips?.textContent).toContain("notes.txt"); // base/attachment chip: kind · size description and a settled state. expect(chips?.textContent).toContain("Text · 5 B"); expect( chips ?.querySelector('[data-slot="attachment"]') ?.getAttribute("data-state"), ).toBe("done"); // The editor stays prose-only; the reference rides along at submit time, // and an attached chip alone is enough to enable send. expect(editable().textContent ?? "").not.toContain("notes.txt"); const send = container.querySelector( '[data-testid="task-chat-composer-send"]', )!; expect(send.disabled).toBe(false); flushSync(() => send.click()); await flushAsync(); expect(onAdd).toHaveBeenCalledWith( "[notes.txt](/attachments/notes.txt)", undefined, undefined, ); // Chips clear once the message posts. expect( container.querySelector('[data-testid="task-chat-composer-attachments"]'), ).toBeNull(); }); it("appends file references after typed prose on submit", async () => { const onAdd = vi.fn().mockResolvedValue(undefined); const onAttachImage = vi.fn().mockResolvedValue({ contentPath: "/attachments/notes.txt", originalFilename: "notes.txt", }); render( , ); typeText("Please review this."); pasteFiles([new File(["plain"], "notes.txt", { type: "text/plain" })]); await flushAsync(); const send = container.querySelector( '[data-testid="task-chat-composer-send"]', )!; flushSync(() => send.click()); await flushAsync(); expect(onAdd).toHaveBeenCalledWith( "Please review this.\n\n[notes.txt](/attachments/notes.txt)", undefined, undefined, ); }); it("blocks send while a file upload is pending, then includes the file once it lands", async () => { const onAdd = vi.fn().mockResolvedValue(undefined); let resolveUpload!: (value: { contentPath: string; originalFilename: string; }) => void; const onAttachImage = vi.fn().mockReturnValue( new Promise<{ contentPath: string; originalFilename: string }>( (resolve) => { resolveUpload = resolve; }, ), ); render( , ); typeText("Here is the file."); pasteFiles([new File(["plain"], "notes.txt", { type: "text/plain" })]); await flushAsync(); // Text alone would enable send, but the in-flight upload must hold it — // otherwise the comment posts without the file the user selected. expect(sendButton().disabled).toBe(true); pressKey("Enter", { metaKey: true }); await flushAsync(); expect(onAdd).not.toHaveBeenCalled(); resolveUpload({ contentPath: "/attachments/notes.txt", originalFilename: "notes.txt", }); await flushAsync(); expect(sendButton().disabled).toBe(false); flushSync(() => sendButton().click()); await flushAsync(); expect(onAdd).toHaveBeenCalledWith( "Here is the file.\n\n[notes.txt](/attachments/notes.txt)", undefined, undefined, ); }); it("blocks send while a failed attachment chip remains, then sends after it is removed", async () => { const onAdd = vi.fn().mockResolvedValue(undefined); const onAttachImage = vi.fn().mockRejectedValue(new Error("Too large")); render( , ); typeText("Here is the file."); pasteFiles([new File(["plain"], "notes.txt", { type: "text/plain" })]); await flushAsync(); // Text alone would enable send, but the failed chip must hold it — // otherwise the comment posts without the file and the error chip clears. expect(sendButton().disabled).toBe(true); pressKey("Enter", { metaKey: true }); await flushAsync(); expect(onAdd).not.toHaveBeenCalled(); const remove = container.querySelector( 'button[aria-label="Remove notes.txt"]', ); flushSync(() => remove!.click()); expect(sendButton().disabled).toBe(false); flushSync(() => sendButton().click()); await flushAsync(); expect(onAdd).toHaveBeenCalledWith( "Here is the file.", undefined, undefined, ); }); it("removes an attachment chip via its remove button", async () => { const onAttachImage = vi.fn().mockResolvedValue({ contentPath: "/attachments/notes.txt", originalFilename: "notes.txt", }); render( , ); pasteFiles([new File(["plain"], "notes.txt", { type: "text/plain" })]); await flushAsync(); const remove = container.querySelector( 'button[aria-label="Remove notes.txt"]', ); expect(remove).not.toBeNull(); flushSync(() => remove!.click()); expect( container.querySelector('[data-testid="task-chat-composer-attachments"]'), ).toBeNull(); }); it("shows an error-state chip when a non-image upload fails", async () => { const onAttachImage = vi.fn().mockRejectedValue(new Error("Too large")); render( , ); pasteFiles([new File(["plain"], "notes.txt", { type: "text/plain" })]); await flushAsync(); const chips = container.querySelector( '[data-testid="task-chat-composer-attachments"]', ); expect( chips ?.querySelector('[data-slot="attachment"]') ?.getAttribute("data-state"), ).toBe("error"); expect(chips?.textContent).toContain("Too large"); }); it("leaves pasted images to the editor's image plugin (no chip, paste not swallowed)", async () => { const onAttachImage = vi.fn().mockResolvedValue({ contentPath: "/attachments/notes.txt", originalFilename: "notes.txt", }); render( , ); const image = new File(["png-bytes"], "shot.png", { type: "image/png" }); const text = new File(["plain"], "notes.txt", { type: "text/plain" }); const paste = pasteFiles([image, text]); await flushAsync(); // Mixed paste: the non-image is chipped, the image flows through untouched. expect(paste.defaultPrevented).toBe(false); const chips = container.querySelector( '[data-testid="task-chat-composer-attachments"]', )!; expect(chips.textContent).toContain("notes.txt"); expect(chips.textContent).not.toContain("shot.png"); expect(onAttachImage).toHaveBeenCalledTimes(1); expect(onAttachImage).toHaveBeenCalledWith(text); }); it("inserts an @-mention from the autocomplete menu and posts it", async () => { const onAdd = vi.fn().mockResolvedValue(undefined); render( , ); typeText("@Cl"); await placeCaretAtEnd(); const option = autocompleteOption("Clippy"); flushSync(() => { option.dispatchEvent( new MouseEvent("mousedown", { bubbles: true, cancelable: true }), ); }); await flushAsync(); const expected = `[@Clippy](${buildAgentMentionHref("a1", null)}) `; expect(editable().textContent).toBe(expected); pressKey("Enter", { metaKey: true }); await flushAsync(); expect(onAdd).toHaveBeenCalledWith(expected.trim(), undefined, undefined); }); it("inserts a /-command from the autocomplete menu", async () => { const onAdd = vi.fn().mockResolvedValue(undefined); render(); typeText("/dep"); await placeCaretAtEnd(); const option = autocompleteOption("/deploy"); flushSync(() => { option.dispatchEvent( new MouseEvent("mousedown", { bubbles: true, cancelable: true }), ); }); await flushAsync(); expect(editable().textContent).toBe(`[/deploy](${SLASH_HREF}) `); }); describe("/goal action commands", () => { const capability = { availability: "available" as const, verified: true, actions: ["set", "pause", "resume", "clear"] as Array< "set" | "pause" | "resume" | "clear" >, autonomousUpdates: true, persistentAcrossResume: true, maxObjectiveChars: 4_000, tokenBudgetControl: true, usageReporting: true, }; it("matches only an exact first /goal token", () => { expect(parseRunnerGoalCommand(" /goal Ship the feature ")).toEqual({ matched: true, command: { action: "create", objective: "Ship the feature" }, }); expect( parseRunnerGoalCommand( "[/goal\u00a0]()", ), ).toEqual({ matched: true, command: { action: "create", objective: "Ship the feature across turns.", }, }); expect( parseRunnerGoalCommand( "[/go]()", ), ).toEqual({ matched: true, command: { action: "create", objective: "Return one concise confirmation, then complete.", }, }); expect( parseRunnerGoalCommand("[/](/goal%20Confirm%20the%20goal%20state.)"), ).toEqual({ matched: true, command: { action: "create", objective: "Confirm the goal state.", }, }); expect(parseRunnerGoalCommand("[/goal](/goal%20pause)")).toEqual({ matched: true, command: { action: "pause" }, }); expect(parseRunnerGoalCommand("/goal pause extra")).toEqual({ matched: true, error: "/goal pause does not accept extra arguments.", }); expect(parseRunnerGoalCommand("[goal](/goal Ship it)")).toEqual({ matched: false, }); expect(parseRunnerGoalCommand("\\/goal ordinary text")).toEqual({ matched: false, }); expect(parseRunnerGoalCommand("/goalkeeper ordinary text")).toEqual({ matched: false, }); }); it("dispatches a goal action without posting a comment", async () => { const onAdd = vi.fn().mockResolvedValue(undefined); const onRunnerGoalCommand = vi.fn().mockResolvedValue(undefined); render( , ); typeText("/goal Ship the feature"); pressKey("Enter", { metaKey: true }); await flushAsync(); expect(onRunnerGoalCommand).toHaveBeenCalledWith({ action: "create", objective: "Ship the feature", }); expect(onAdd).not.toHaveBeenCalled(); expect(editable().textContent).toBe(""); }); it("commits a pending agent reassignment before starting the goal", async () => { const order: string[] = []; const onAdd = vi.fn().mockResolvedValue(undefined); const onPendingAssigneeChange = vi.fn(); const onRunnerGoalReassign = vi.fn().mockImplementation(async () => { order.push("reassign"); }); const onRunnerGoalCommand = vi.fn().mockImplementation(async () => { order.push("goal"); }); render( , ); const trigger = container.querySelector( '[data-testid="task-chat-composer-assignee"]', )!; flushSync(() => trigger.click()); await flushAsync(); const option = [ ...document.body.querySelectorAll("button"), ].find((button) => button.textContent?.trim() === "Clippy"); expect(option).toBeDefined(); flushSync(() => option!.click()); await flushAsync(); typeText("/goal Ship the feature"); pressKey("Enter", { metaKey: true }); await flushAsync(); expect(onPendingAssigneeChange).toHaveBeenCalledWith("agent:a1"); expect(onRunnerGoalReassign).toHaveBeenCalledWith({ assigneeAgentId: "a1", assigneeUserId: null, }); expect(onRunnerGoalCommand).toHaveBeenCalledWith({ action: "create", objective: "Ship the feature", }); expect(order).toEqual(["reassign", "goal"]); expect(onAdd).not.toHaveBeenCalled(); }); it("preserves unsupported goal text and shows the provider reason", async () => { const onAdd = vi.fn().mockResolvedValue(undefined); render( , ); typeText("/goal Ship the feature"); pressKey("Enter", { metaKey: true }); await flushAsync(); expect(onAdd).not.toHaveBeenCalled(); expect(editable().textContent).toBe("/goal Ship the feature"); expect(container.querySelector('[role="alert"]')?.textContent).toContain( "Unsupported by OpenCode", ); }); }); it("shows the assignee combobox only when reassign is enabled, with the current label", () => { render(); expect( container.querySelector('[data-testid="task-chat-composer-assignee"]'), ).toBeNull(); render( , ); const trigger = container.querySelector( '[data-testid="task-chat-composer-assignee"]', ); expect(trigger?.textContent).toContain("Sam"); }); it("shows configured agent icons in the assignee trigger and dropdown options", async () => { render( , ); const trigger = container.querySelector( '[data-testid="task-chat-composer-assignee"]', )!; expect( trigger.querySelector('[data-assignee-trigger-icon="rocket"]'), ).not.toBeNull(); flushSync(() => { trigger.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushAsync(); expect( document.querySelector('[data-assignee-option-icon="agent:a1"]'), ).not.toBeNull(); expect( document.querySelector('[data-assignee-option-icon="agent:a2"]'), ).not.toBeNull(); expect( document .querySelector('[data-assignee-identity="agent:a2"]') ?.getAttribute("data-assignee-option-icon"), ).toBe("agent:a2"); }); it("shows human avatars in assignee options and after selection", async () => { render( , ); const trigger = container.querySelector( '[data-testid="task-chat-composer-assignee"]', )!; flushSync(() => { trigger.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushAsync(); const userOptionAvatar = document.querySelector( '[data-assignee-option-avatar="user:u1"]', ); expect(userOptionAvatar).not.toBeNull(); const userOption = userOptionAvatar?.closest("button"); flushSync(() => { userOption?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushAsync(); expect(trigger.textContent).toContain("Sam Rivera"); expect( trigger.querySelector('[data-assignee-trigger-avatar="u1"]'), ).not.toBeNull(); }); describe("draft persistence", () => { const draftKey = "task-chat-draft:issue-1"; it("restores a saved draft on mount", () => { localStorage.setItem(draftKey, "unsent draft"); render( , ); expect(editable().textContent).toBe("unsent draft"); expect(sendButton().disabled).toBe(false); }); it("preserves a restored draft through the StrictMode effect probe", () => { localStorage.setItem(draftKey, "still here"); render( , ); expect(editable().textContent).toBe("still here"); expect(localStorage.getItem(draftKey)).toBe("still here"); }); it("saves after the debounce and flushes a pending value on unmount", () => { vi.useFakeTimers(); try { render( , ); typeText("work in progress"); expect(localStorage.getItem(draftKey)).toBeNull(); vi.advanceTimersByTime(DRAFT_DEBOUNCE_MS); expect(localStorage.getItem(draftKey)).toBe("work in progress"); typeText("save before leaving"); flushSync(() => root?.unmount()); root = null; expect(localStorage.getItem(draftKey)).toBe("save before leaving"); } finally { vi.useRealTimers(); } }); it("flushes a pending value before page unload", () => { vi.useFakeTimers(); try { render( , ); typeText("save before reload"); window.dispatchEvent(new Event("beforeunload")); expect(localStorage.getItem(draftKey)).toBe("save before reload"); } finally { vi.useRealTimers(); } }); it("clears the visible composer but retains an uncertain reload draft while sending", async () => { localStorage.setItem(draftKey, "queued message"); const onAdd = vi.fn().mockReturnValue(new Promise(() => {})); render( , ); pressKey("Enter", { metaKey: true }); await flushAsync(); expect(onAdd).toHaveBeenCalledWith( "queued message", undefined, undefined, ); expect(editable().textContent).toBe(""); expect(localStorage.getItem(draftKey)).toBe("queued message"); expect(localStorage.getItem(`${draftKey}:submission:v1`)).toContain( '"reviewed":false', ); }); it("keeps text entered while an earlier send is pending", async () => { let resolveSend!: () => void; const onAdd = vi.fn().mockReturnValue( new Promise((resolve) => { resolveSend = resolve; }), ); render( , ); typeText("first message"); pressKey("Enter", { metaKey: true }); await flushAsync(); typeText("next message"); resolveSend(); await flushAsync(); await flushAsync(); expect(onAdd).toHaveBeenCalledWith("first message", undefined, undefined); expect(editable().textContent).toBe("next message"); expect(localStorage.getItem(draftKey)).toBe("next message"); }); it("keeps an attachment added while an earlier send is pending", async () => { let resolveSend!: () => void; const onAdd = vi.fn().mockReturnValue( new Promise((resolve) => { resolveSend = resolve; }), ); const onAttachImage = vi.fn().mockResolvedValue({ contentPath: "/attachments/next.txt", originalFilename: "next.txt", }); render( , ); typeText("first message"); pressKey("Enter", { metaKey: true }); await flushAsync(); pasteFiles([new File(["next"], "next.txt", { type: "text/plain" })]); await flushAsync(); resolveSend(); await flushAsync(); await flushAsync(); expect(onAdd).toHaveBeenCalledWith("first message", undefined, undefined); expect( container.querySelector( '[data-testid="task-chat-composer-attachments"]', )?.textContent, ).toContain("next.txt"); }); it("restores a failed send before text entered while it was pending", async () => { vi.useFakeTimers(); try { let rejectSend!: (error: Error) => void; const onAdd = vi.fn().mockReturnValue( new Promise((_resolve, reject) => { rejectSend = reject; }), ); render( , ); typeText("do not lose this"); vi.advanceTimersByTime(DRAFT_DEBOUNCE_MS); vi.useRealTimers(); pressKey("Enter", { metaKey: true }); await flushAsync(); expect(editable().textContent).toBe(""); expect(localStorage.getItem(draftKey)).toBe("do not lose this"); typeText("next draft"); rejectSend(new Error("network down")); await flushAsync(); await flushAsync(); expect(editable().textContent).toBe("do not lose this\n\nnext draft"); expect(localStorage.getItem(draftKey)).toBe( "do not lose this\n\nnext draft", ); } finally { vi.useRealTimers(); } }); }); it("gives separate identical chat submissions separate receipt identities", async () => { const onAdd = vi.fn().mockResolvedValue(undefined); render(); typeText("Same message"); await act(async () => sendButton().click()); typeText("Same message"); await act(async () => sendButton().click()); expect(onAdd.mock.calls).toHaveLength(2); expect(onAdd.mock.calls[0][4]).toEqual(expect.any(String)); expect(onAdd.mock.calls[1][4]).not.toBe(onAdd.mock.calls[0][4]); }); describe("paused task takeover", () => { it("allows only standalone /new to resume a paused conversation through the normal composer", async () => { const onAdd = vi.fn().mockResolvedValue(undefined); render(); typeText("Keep working"); expect(sendButton().disabled).toBe(true); await act(async () => sendButton().click()); expect(onAdd).not.toHaveBeenCalled(); typeText("/new"); expect(sendButton().disabled).toBe(false); await act(async () => sendButton().click()); expect(onAdd).toHaveBeenCalledWith("/new", undefined, undefined, undefined, expect.any(String)); }); it("preserves a typed draft and blocks sending until resume completes", async () => { const onAdd = vi.fn(); const onResume = vi.fn(); const props = { onAdd, workMode: "standard" as const, draftKey: "paused-draft" }; act(() => root!.render()); typeText("Please check mobile too."); act(() => root!.render()); expect(container.querySelector('[contenteditable="true"]')).toBeNull(); expect(container.querySelector('button[aria-label="Send"]')).toBeNull(); expect(container.textContent).toContain("Your draft is saved."); act(() => container.querySelector("button")!.click()); expect(onResume).toHaveBeenCalledOnce(); expect(onAdd).not.toHaveBeenCalled(); act(() => root!.render()); expect(container.querySelector("button")!.disabled).toBe(true); act(() => root!.render()); expect(editable().textContent).toBe("Please check mobile too."); await act(async () => sendButton().click()); expect(onAdd).toHaveBeenCalledWith("Please check mobile too.", undefined, undefined); }); it("takes precedence over pending questions and queued-message edits", () => { const onSkip = vi.fn(); act(() => root!.render(Answer question, onDismiss: vi.fn(), onSkip }} />)); expect(container.textContent).toContain("Subtree is paused."); expect(container.textContent).not.toContain("Answer question"); expect(container.textContent).not.toContain("Skip"); expect(container.querySelector('[contenteditable="true"]')).toBeNull(); expect(container.querySelector("button")!.disabled).toBe(true); }); }); describe("queued message editing", () => { const draftKey = "task-chat-draft:queued-edit"; function Harness({ onSave, stale = false, }: { onSave: (commentId: string, body: string) => Promise; stale?: boolean; }) { const [queuedEdit, setQueuedEdit] = useState<{ commentId: string; body: string; stale?: boolean; } | null>({ commentId: "queued-1", body: "Complete queued markdown", stale, }); return ( setQueuedEdit(null)} /> ); } it("restores the existing composer draft after cancelling an edit", async () => { localStorage.setItem(draftKey, "Unsent normal draft"); render(); await flushAsync(); await vi.waitFor(() => { expect(editable().textContent).toBe("Complete queued markdown"); }); expect(localStorage.getItem(draftKey)).toBe("Unsent normal draft"); const cancel = Array.from(container.querySelectorAll("button")).find( (button) => button.textContent?.trim() === "Cancel", ); flushSync(() => cancel?.click()); await flushAsync(); expect(editable().textContent).toBe("Unsent normal draft"); expect(localStorage.getItem(draftKey)).toBe("Unsent normal draft"); }); it("saves through the queue callback without posting a new comment", async () => { localStorage.setItem(draftKey, "Normal draft stays here"); const onSave = vi.fn().mockResolvedValue(undefined); render(); await flushAsync(); typeText("Edited queued markdown"); pressKey("Enter", { metaKey: true }); await flushAsync(); await flushAsync(); expect(onSave).toHaveBeenCalledWith("queued-1", "Edited queued markdown"); expect(editable().textContent).toBe("Normal draft stays here"); }); it("retains edited text when the queue target rejects the save", async () => { const onSave = vi .fn() .mockRejectedValue(new Error("queued_comment_stale_target")); render(); await flushAsync(); typeText("Keep this replacement text"); pressKey("Enter", { metaKey: true }); await flushAsync(); await vi.waitFor(() => { expect(editable().textContent).toBe("Keep this replacement text"); expect(container.textContent).toContain("Editing queued message"); }); }); it("offers a stale edit as a new queued message and preserves its Markdown source", async () => { const onSave = vi.fn().mockResolvedValue(undefined); render(); await flushAsync(); typeText(" replacement with trailing Markdown "); expect(container.textContent).toContain("Queued message changed"); expect(sendButton().getAttribute("aria-label")).toBe( "Queue as new message", ); pressKey("Enter", { metaKey: true }); await flushAsync(); await flushAsync(); expect(onSave).toHaveBeenCalledWith( "queued-1", " replacement with trailing Markdown ", ); }); }); describe("composer takeovers", () => { it("replaces the editor with one action surface and exposes Skip", async () => { const onSkip = vi.fn().mockResolvedValue(undefined); render( Which environment should receive this?

, onDismiss: vi.fn(), onSkip, onShowNext: vi.fn(), }} />, ); expect( container.querySelector('[data-testid="task-chat-composer-takeover"]') ?.textContent, ).toContain("Which environment should receive this?"); expect(container.querySelector('[data-testid="mdx-editor"]')).toBeNull(); expect(container.textContent).not.toContain("Input needed"); expect(container.textContent).not.toContain("Write instead"); const skip = Array.from( container.querySelectorAll("button"), ).find((button) => button.textContent?.trim() === "Skip"); flushSync(() => skip?.click()); await flushAsync(); expect(onSkip).toHaveBeenCalledTimes(1); }); it("restores the exact ordinary draft after Skip", async () => { const draftKey = "task-composer-takeover-draft"; localStorage.setItem(draftKey, "Preserved draft with **markdown**"); function Harness() { const [open, setOpen] = useState(true); return ( Do you accept this plan?

, onDismiss: () => setOpen(false), onSkip: () => setOpen(false), } : null } /> ); } render(); const skip = Array.from( container.querySelectorAll("button"), ).find((button) => button.textContent?.trim() === "Skip"); flushSync(() => skip?.click()); await flushAsync(); expect(editable().textContent).toBe("Preserved draft with **markdown**"); expect(container.textContent).not.toContain("Write instead"); }); it("keeps title, pending count, pagination, and dismiss on one header row", () => { render( ), onDismiss: vi.fn(), onSkip: vi.fn(), onShowNext: vi.fn(), }} />, ); const header = container.querySelector( '[data-testid="task-chat-composer-takeover-header"]', ); expect(header?.textContent).toContain("Release decisions"); expect(header?.textContent).toContain("2 pending"); expect(header?.textContent).toContain("1 of 2"); expect( header?.querySelector('button[aria-label="Dismiss Release decisions"]'), ).not.toBeNull(); expect( header?.querySelector('[aria-label="Question pagination"]'), ).not.toBeNull(); expect( container.querySelector( '[data-testid="task-chat-composer-takeover-body"] [aria-label="Question pagination"]', ), ).toBeNull(); }); it("waits for the submit button on a single-select question", async () => { const onSubmit = vi.fn(); render( ), onDismiss: vi.fn(), onSkip: vi.fn(), }} />, ); const buttons = () => Array.from(container.querySelectorAll("button")); const interview = buttons().find((button) => button.textContent?.includes("Interview me"), ); expect(interview).not.toBeUndefined(); // Picking the option only selects it: nothing is sent yet. flushSync(() => interview?.click()); await flushAsync(); expect(interview?.getAttribute("data-selected")).toBe("true"); expect(onSubmit).not.toHaveBeenCalled(); // A required question cannot be skipped. expect( buttons().find((button) => button.textContent?.trim() === "Skip"), ).toBeUndefined(); // The submit button carries the card's label and does the sending. const submit = buttons().find( (button) => button.textContent?.trim() === "Continue", ); expect(submit).not.toBeUndefined(); expect(submit?.disabled).toBe(false); flushSync(() => submit?.click()); await flushAsync(); expect(onSubmit).toHaveBeenCalledTimes(1); expect(onSubmit.mock.calls[0]?.[0]).toMatchObject({ answers: { "first-task-opening": { selectedOptionIds: ["interview"] } }, }); }); it("advances single selections, preserves answers when going back, and skips optional answers", async () => { const onSubmit = vi.fn(); render( ), onDismiss: vi.fn(), onSkip: vi.fn(), }} />, ); const buttons = () => Array.from(container.querySelectorAll("button")); const byLabel = (label: string) => buttons().find((button) => button.textContent?.trim() === label); // Page 1: required, so no Skip; Next waits for an answer. expect(byLabel("Skip")).toBeUndefined(); expect(byLabel("Next")?.disabled).toBe(true); flushSync(() => byLabel("Staging")?.click()); await flushAsync(); expect(onSubmit).not.toHaveBeenCalled(); expect(container.textContent).toContain("When?"); expect(document.activeElement?.textContent).toBe("When?"); // Page 2: pick advances. Go back to confirm it is saved, then skip. flushSync(() => byLabel("Today")?.click()); await flushAsync(); expect(container.textContent).toContain("Who?"); flushSync(() => container.querySelector('button[aria-label="Previous question"]')?.click()); await flushAsync(); expect(byLabel("Today")?.getAttribute("aria-checked")).toBe("true"); flushSync(() => byLabel("Skip")?.click()); await flushAsync(); expect(container.textContent).toContain("Who?"); expect(onSubmit).not.toHaveBeenCalled(); // Last page: primary reads Submit answers and sends everything. expect(byLabel("Next")).toBeUndefined(); flushSync(() => byLabel("Me")?.click()); await flushAsync(); flushSync(() => byLabel("Submit answers")?.click()); await flushAsync(); expect(onSubmit).toHaveBeenCalledTimes(1); const response = onSubmit.mock.calls[0]?.[0]; expect(response.answers.env).toEqual({ selectedOptionIds: ["staging"] }); expect(response.answers.when).toBeUndefined(); expect(response.answers.who).toEqual({ selectedOptionIds: ["me"] }); }); it.each(["click", "keyboard"])("advances a single choice by %s, while Other and multi-select stay put", async (input) => { const onSubmit = vi.fn(); render(); const byLabel = (label: string) => Array.from(container.querySelectorAll("button")) .find((button) => button.textContent?.trim() === label)!; // Restoring a selection does not advance. Other needs text first. expect(container.textContent).toContain("1 of 3"); flushSync(() => byLabel("Other").click()); await flushAsync(); expect(container.textContent).toContain("1 of 3"); expect(container.querySelector('[data-testid="question-other-answer-composer"]')).not.toBeNull(); expect(byLabel("Next").disabled).toBe(true); flushSync(() => { if (input === "click") byLabel("SQLite").click(); else byLabel("SQLite").dispatchEvent(new KeyboardEvent("keydown", { key: "1", bubbles: true })); }); await flushAsync(); expect(container.textContent).toContain("2 of 3"); expect(document.activeElement?.textContent).toBe("Features?"); flushSync(() => document.activeElement?.dispatchEvent(new KeyboardEvent("keydown", { key: "1", repeat: true, bubbles: true }))); expect(byLabel("Sign in").getAttribute("aria-checked")).toBe("false"); flushSync(() => byLabel("Sign in").click()); flushSync(() => byLabel("Search").click()); expect(container.textContent).toContain("2 of 3"); expect(byLabel("Sign in").getAttribute("aria-checked")).toBe("true"); expect(byLabel("Search").getAttribute("aria-checked")).toBe("true"); expect(onSubmit).not.toHaveBeenCalled(); flushSync(() => byLabel("Next").click()); await flushAsync(); expect(container.textContent).toContain("3 of 3"); flushSync(() => byLabel("Submit answers").click()); await flushAsync(); expect(onSubmit).toHaveBeenCalledExactlyOnceWith({ schema: "paperclip.question_response.v1", answers: { storage: { selectedOptionIds: ["sqlite"] }, features: { selectedOptionIds: ["auth", "search"] } }, }); }); describe("single-choice confirmation animation", () => { const questionSet = { schema: "paperclip.question_set.v1" as const, questions: ["First", "Second", "Third"].map((prompt) => ({ id: prompt, prompt, required: true, answerMode: "single_select" as const, options: [{ id: "yes", label: "Yes" }], customAnswer: { enabled: true as const }, })), }; const form = (disabled = false) => ( ); const click = (selector: string) => act(() => { flushSync(() => container.querySelector(selector)!.click()); }); beforeEach(() => { vi.useFakeTimers(); document.documentElement.style.setProperty("--motion-question-confirm", "160ms"); }); afterEach(() => { render(
); vi.useRealTimers(); document.documentElement.style.removeProperty("--motion-question-confirm"); }); it("shows the selected radio before advancing exactly one page", () => { render(form()); click('[role="radio"]'); expect(container.querySelector('[role="radio"]')?.getAttribute("aria-checked")).toBe("true"); expect(container.querySelector(".tc-question-choice-confirm")).not.toBeNull(); expect(container.textContent).toContain("1 of 3"); act(() => vi.advanceTimersByTime(159)); expect(container.textContent).toContain("1 of 3"); act(() => vi.advanceTimersByTime(1)); expect(container.textContent).toContain("2 of 3"); expect(document.activeElement?.textContent).toBe("Second"); act(() => vi.advanceTimersByTime(160)); expect(container.textContent).toContain("2 of 3"); }); it.each(["navigation", "custom answer", "disabled", "unmount"])("cancels the pending advance on %s", (reason) => { render(form()); click('[role="radio"]'); if (reason === "navigation") { click('[aria-label="Next question"]'); click('[aria-label="Next question"]'); } else if (reason === "custom answer") { click('#animated-First-custom'); } else if (reason === "disabled") { render(form(true)); render(form(false)); } else { render(
Closed
); } act(() => vi.advanceTimersByTime(160)); expect(container.textContent).toContain( reason === "navigation" ? "3 of 3" : reason === "unmount" ? "Closed" : "1 of 3", ); }); it("advances immediately when the motion token is zero", () => { document.documentElement.style.setProperty("--motion-question-confirm", "0ms"); render(form()); click('[role="radio"]'); expect(container.textContent).toContain("2 of 3"); expect(container.querySelector(".tc-question-choice-confirm")).toBeNull(); }); }); it("Skip on the last question submits the other answers", async () => { const onSubmit = vi.fn(); render( ), onDismiss: vi.fn(), onSkip: vi.fn(), }} />, ); const byLabel = (label: string) => Array.from( container.querySelectorAll("button"), ).find((button) => button.textContent?.trim() === label); flushSync(() => byLabel("Staging")?.click()); await flushAsync(); expect(container.textContent).toContain("Anything else?"); flushSync(() => byLabel("Skip")?.click()); await flushAsync(); expect(onSubmit).toHaveBeenCalledTimes(1); expect(onSubmit.mock.calls[0]?.[0]).toMatchObject({ answers: { env: { selectedOptionIds: ["staging"] } }, }); }); it("Skip on the last question returns to a required question the arrows walked past", async () => { const onSubmit = vi.fn(); render( ), onDismiss: vi.fn(), onSkip: vi.fn(), }} />, ); const byLabel = (label: string) => Array.from( container.querySelectorAll("button"), ).find((button) => button.textContent?.trim() === label); // The pagination arrow browses past the unanswered required question. const arrow = container.querySelector( 'button[aria-label="Next question"]', ); flushSync(() => arrow?.click()); await flushAsync(); expect(container.textContent).toContain("Anything else?"); // Skip here would send; instead the form goes back and says why. flushSync(() => byLabel("Skip")?.click()); await flushAsync(); expect(onSubmit).not.toHaveBeenCalled(); expect(container.textContent).toContain("Where?"); expect(container.textContent).toContain( "Question 1 needs an answer before you can send.", ); }); it("Cancel closes the takeover and leaves the request pending", async () => { const onSubmit = vi.fn(); const onDismiss = vi.fn(); const onSkip = vi.fn(); render( ), onDismiss, onSkip, }} />, ); const cancel = Array.from( container.querySelectorAll("button"), ).find((button) => button.textContent?.trim() === "Cancel"); expect(cancel).not.toBeUndefined(); flushSync(() => cancel?.click()); await flushAsync(); expect(onDismiss).toHaveBeenCalledTimes(1); expect(onSkip).not.toHaveBeenCalled(); expect(onSubmit).not.toHaveBeenCalled(); }); it("places Skip beside Submit answers for structured questions", () => { render( ), onDismiss: vi.fn(), onSkip: vi.fn(), }} />, ); const buttons = Array.from( container.querySelectorAll("button"), ); const skip = buttons.find( (button) => button.textContent?.trim() === "Skip", ); const submit = buttons.find( (button) => button.textContent?.trim() === "Submit answers", ); expect(skip).not.toBeUndefined(); expect(submit).not.toBeUndefined(); expect(skip?.parentElement).toBe(submit?.parentElement); expect( buttons.filter((button) => button.textContent?.trim() === "Skip"), ).toHaveLength(1); const takeoverBody = container.querySelector( '[data-testid="task-chat-composer-takeover-body"]', ); expect(takeoverBody?.className).not.toContain("max-h-"); expect(takeoverBody?.className).not.toContain("overflow-y-auto"); expect(takeoverBody?.className).not.toContain("pr-8"); const staging = buttons.find((button) => button.textContent?.includes("Staging"), ); const production = buttons.find( (button) => button.textContent?.trim() === "Production", ); expect(staging?.getAttribute("data-recommended")).toBe("true"); expect(staging?.className.split(" ")).toContain("bg-muted/50"); expect( production?.className .split(" ") .some( (token) => token.startsWith("border") || token.startsWith("bg-"), ), ).toBe(false); flushSync(() => staging?.click()); expect(staging?.getAttribute("data-selected")).toBe("true"); expect(staging?.className.split(" ")).toContain("bg-foreground/5"); }); it("hides Skip when the takeover already provides a request-changes path", () => { render( Do you accept this plan?

, onDismiss: vi.fn(), onSkip: vi.fn(), hideSkip: true, }} />, ); expect(container.textContent).not.toContain("Skip"); expect( container.querySelector( 'button[aria-label="Dismiss Review the proposed plan"]', ), ).not.toBeNull(); expect( container.querySelector( '[data-testid="task-chat-composer-takeover"] .border-t', ), ).toBeNull(); }); it("dismisses every takeover without invoking Skip", async () => { const onDismiss = vi.fn(); const onSkip = vi.fn(); render( Which release should ship?

, onDismiss, onSkip, }} />, ); flushSync(() => container .querySelector( 'button[aria-label="Dismiss Release questions"]', ) ?.click(), ); await flushAsync(); expect(onDismiss).toHaveBeenCalledTimes(1); expect(onSkip).not.toHaveBeenCalled(); }); }); }); describe("composer Stop", () => { function stopButton() { return container.querySelector('[data-testid="task-chat-composer-stop"]'); } it("switches Stop to Send with text, whitespace back to Stop, without interrupting on keyboard submit", async () => { const onStop = vi.fn(async () => {}); const onAdd = vi.fn(async () => {}); render(); expect(stopButton()?.title).toBe("Stop and pause subtree"); pressKey("Enter", { metaKey: true }); expect(onStop).not.toHaveBeenCalled(); expect(onAdd).not.toHaveBeenCalled(); typeText("Check mobile too."); expect(stopButton()).toBeNull(); expect(sendButton().disabled).toBe(false); flushSync(() => sendButton().click()); await flushAsync(); expect(onAdd).toHaveBeenCalledWith("Check mobile too.", undefined, undefined); expect(onStop).not.toHaveBeenCalled(); typeText(" \n "); expect(stopButton()?.disabled).toBe(false); }); it("blocks duplicate stops and preserves text typed while stopping", async () => { let resolve!: () => void; const onStop = vi.fn(() => new Promise((done) => { resolve = done; })); render(); const stop = stopButton()!; flushSync(() => { stop.click(); stop.click(); }); expect(onStop).toHaveBeenCalledTimes(1); expect(stopButton()?.disabled).toBe(true); expect(stopButton()?.getAttribute("aria-label")).toBe("Stopping…"); typeText("Keep this draft."); expect(sendButton().disabled).toBe(false); resolve(); await flushAsync(); expect(editable().textContent).toBe("Keep this draft."); }); it("reports failure without discarding the draft and permits retry", async () => { const onStop = vi.fn().mockRejectedValueOnce(new Error("Unable to stop. Try again.")).mockResolvedValue(undefined); render(); flushSync(() => stopButton()!.click()); await flushAsync(); expect(container.querySelector('[role="alert"]')?.textContent).toBe("Unable to stop. Try again."); flushSync(() => stopButton()!.click()); await flushAsync(); expect(onStop).toHaveBeenCalledTimes(2); expect(container.querySelector('[role="alert"]')).toBeNull(); }); it("retains disabled Send when idle or when stop permission is absent", () => { render(); expect(stopButton()).toBeNull(); expect(sendButton().disabled).toBe(true); render(); expect(stopButton()?.disabled).toBe(true); }); it("never turns a queued edit's save action into Stop", () => { render(); expect(stopButton()).toBeNull(); expect(sendButton().getAttribute("aria-label")).toBe("Save queued message"); }); it.each([false, true])("keeps attachments in send mode after upload (failed=%s)", async (failed) => { let resolve!: (value: never) => void; let reject!: (error: Error) => void; const onAttachImage = vi.fn(() => new Promise((done, fail) => { resolve = done; reject = fail; })); render(); pasteFiles([new File(["notes"], "notes.txt", { type: "text/plain" })]); await flushAsync(); expect(stopButton()).toBeNull(); expect(sendButton().disabled).toBe(true); if (failed) reject(new Error("Upload failed")); else resolve({ id: "attachment", contentPath: "/notes.txt", originalFilename: "notes.txt" } as never); await flushAsync(); expect(stopButton()).toBeNull(); expect(sendButton().disabled).toBe(failed); }); });