/** * @file RunSetup.test.tsx * @description The run-form pieces that outlived the pane's run form: * `PromptEditor`'s `/` and `@` autocomplete (Enter picks while the list is * open, `/` only where slash commands apply), Shift/Alt+Enter newlines, a * pasted image shown as a thumbnail and sent as its saved path (and * remembered as a recent upload), a Files panel row dropped as an `@path` * chip, `/command` and `@path` coloured by a mirror over the text, a composer * (`bare`) box growing with its text up to a cap, and `ModelPicker` — a tool's own * list from the server, a fixed list (an AI Proxy's models) when given one, * and Custom…, which is a sentinel, never a model id — and `ModelMenu`, the * same list as one chip: aliases up front, pinned versions under More models, * plus the effort slider. * * @author Nguyễn Ngọc Trí Vĩ */ import { useState } from "react"; import { createEvent, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import i18n from "i18next"; import { ModelMenu, ModelPicker, PASTE_AS_FILE_CHARS, PromptEditor, promptForPane, promptWithImages, type PromptImage, } from "../RunSetup"; import { api } from "../../../lib/api"; import { ATTACH_MIME, dragAttachments, dragProjectPath, dragSkill, recentUploads, } from "../../../lib/promptAttach"; vi.mock("../../../lib/api", async (importOriginal) => { const actual = await importOriginal>(); const r = (value: unknown) => vi.fn().mockResolvedValue(value); return { ...actual, api: { aiTools: { // Each tool's own model list, as GET /api/ai-tools/:id/models answers it. models: vi.fn(async (id: string) => { const lists: Record = { claude: [ { id: "sonnet", label: "Sonnet (latest · 5.5)" }, { id: "claude-sonnet-5-5", label: "Claude Sonnet 5.5" }, ], codex: [{ id: "gpt-6-sol", label: "GPT-6-Sol" }], }; return { flag: id in lists, models: lists[id] ?? [], defaultModel: null }; }), }, run: { files: r({ items: [] }), dropFile: r({ path: "/tmp/agenthub-drops-1/a.png" }), localPaths: r({ sizes: [1] }), }, ccConfig: { commands: r({ items: [ { scope: "project", name: "ship-it", frontmatter: { description: "Ship the change" }, preview: "", }, ], }), skills: r({ items: [{ scope: "user", name: "review-pr", frontmatter: {}, preview: "Review a PR" }], }), plugins: r({ plugins: [ { name: "vercel", enabled: true, contributes: { items: { skills: [{ name: "deploy", description: "Deploy to Vercel" }], commands: [], }, }, }, { name: "off", enabled: false, contributes: { items: { skills: [{ name: "deployer" }], commands: [] } }, }, ], }), }, }, }; }); beforeEach(() => { i18n.changeLanguage("en"); vi.clearAllMocks(); }); function renderEditor(value: string, slash = true) { const spies = { onChange: vi.fn(), onSubmit: vi.fn() }; render( {}} {...spies} /> ); return spies; } const box = () => screen.getByPlaceholderText("Ask anything…"); describe("PromptEditor — / and @ suggestions", () => { it("colours /command and @path tokens through a mirror, only when there are any", () => { const { unmount } = render( {}} onChange={() => {}} onSubmit={() => {}} /> ); const mirror = screen.getByTestId("prompt-mirror"); expect(mirror).toHaveAttribute("aria-hidden"); expect(within(mirror).getByText("/review").className).toContain("text-accent"); expect(within(mirror).getByText("@src/a.ts").className).toContain("text-violet"); expect(box().className).toContain("text-transparent"); unmount(); renderEditor("plain words"); expect(screen.queryByTestId("prompt-mirror")).toBeNull(); expect(box().className).toContain("text-fg-primary"); }); it("lists the folder's commands, skills and the CLI built-ins on /, and Enter picks one", async () => { const spies = renderEditor(""); fireEvent.change(box(), { target: { value: "/rev" } }); // Both prefix matches; the shorter name ranks first. const options = await screen.findAllByRole("option"); expect(options.map((o) => o.textContent)).toEqual([ expect.stringContaining("/review"), expect.stringContaining("/review-pr"), // Claude Code's bundled skills, matched on a word of their name. expect.stringContaining("/code-review"), expect.stringContaining("/security-review"), ]); fireEvent.keyDown(box(), { key: "ArrowDown" }); // Read for the directory the session will start in, not the server's own. expect(api.ccConfig.commands).toHaveBeenCalledWith(undefined, "/Users/tester"); expect(api.ccConfig.skills).toHaveBeenCalledWith(undefined, "/Users/tester"); fireEvent.keyDown(box(), { key: "Enter" }); expect(spies.onChange).toHaveBeenLastCalledWith("/review-pr "); // Enter picked the suggestion; it did not send the prompt. expect(spies.onSubmit).not.toHaveBeenCalled(); }); it("offers enabled plugins' skills under their plugin's name", async () => { renderEditor(""); fireEvent.change(box(), { target: { value: "/deplo" } }); const options = await screen.findAllByRole("option"); expect(options.map((o) => o.textContent)).toEqual([expect.stringContaining("/vercel:deploy")]); }); it("suggests files under the folder on @", async () => { vi.mocked(api.run.files).mockResolvedValue({ items: ["src/main.ts"] }); // The prompt is controlled by the caller: put the cursor at the end of it. const spies = renderEditor("look at @ma"); (box() as HTMLTextAreaElement).setSelectionRange(11, 11); fireEvent.select(box()); fireEvent.click(await screen.findByText("src/main.ts")); expect(api.run.files).toHaveBeenCalledWith("/Users/tester", "ma"); expect(spies.onChange).toHaveBeenLastCalledWith("look at @src/main.ts "); }); it("offers no slash commands where they do not apply", async () => { renderEditor("", false); fireEvent.change(box(), { target: { value: "/rev" } }); await new Promise((r) => setTimeout(r, 20)); expect(screen.queryByTestId("prompt-autocomplete")).toBeNull(); expect(api.ccConfig.commands).not.toHaveBeenCalled(); }); }); describe("PromptEditor — a composer grows with its text", () => { it("takes the height its text needs, capped by max-h, and only when bare", () => { const tall = vi .spyOn(HTMLTextAreaElement.prototype, "scrollHeight", "get") .mockReturnValue(180); const props = { placeholder: "Ask anything…", slash: false, fileCwd: "/Users/tester", images: [], onImagesChange: () => {}, onChange: () => {}, onSubmit: () => {}, }; const view = render(); expect(box().style.height).toBe("180px"); expect(box().className).toContain("max-h-[40vh]"); tall.mockReturnValue(60); view.rerender(); expect(box().style.height).toBe("60px"); view.unmount(); // The framed editor keeps its fixed rows. render(); expect(box().style.height).toBe(""); tall.mockRestore(); }); }); describe("PromptEditor — newlines and images", () => { it("Alt+Enter inserts a newline and does not send", () => { const spies = renderEditor("ab"); (box() as HTMLTextAreaElement).setSelectionRange(1, 1); fireEvent.keyDown(box(), { key: "Enter", altKey: true }); expect(spies.onChange).toHaveBeenLastCalledWith("a\nb"); fireEvent.keyDown(box(), { key: "Enter", shiftKey: true }); expect(spies.onSubmit).not.toHaveBeenCalled(); fireEvent.keyDown(box(), { key: "Enter" }); expect(spies.onSubmit).toHaveBeenCalledTimes(1); }); it("shows a pasted image as a thumbnail that opens full size and can be removed", async () => { URL.createObjectURL = vi.fn(() => "blob:shot"); URL.revokeObjectURL = vi.fn(); function Harness() { const [images, setImages] = useState([]); return ( <> {}} onSubmit={() => {}} placeholder="Ask anything…" slash fileCwd="/Users/tester" images={images} onImagesChange={setImages} lane={7} /> {promptWithImages("see", images)} ); } render(); const img = new File(["x"], "shot.png", { type: "image/png" }); fireEvent.paste(box(), { clipboardData: { files: [img] } }); const thumb = await screen.findByRole("button", { name: "shot.png" }); // Saved where workspace 7's panes can read it. expect(api.run.dropFile).toHaveBeenCalledWith(img, 7); // The path is not typed into the prompt; it goes with it on send. expect(screen.getByTestId("sent").textContent).toBe("see /tmp/agenthub-drops-1/a.png"); fireEvent.click(thumb); // On the page, not in the composer's layer: nothing stacked over a pane covers it. expect(screen.getByRole("dialog", { name: "shot.png" }).parentElement).toBe(document.body); fireEvent.keyDown(document, { key: "Escape" }); expect(screen.queryByRole("dialog")).toBeNull(); fireEvent.click(screen.getByRole("button", { name: "Remove attachment" })); expect(screen.queryByRole("button", { name: "shot.png" })).toBeNull(); expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:shot"); expect(screen.getByTestId("sent").textContent).toBe("see"); }); it("a composer's Upload opens a picker whose files attach like a paste", async () => { const pick: { current: (() => void) | null } = { current: null }; function Harness() { const [images, setImages] = useState([]); return ( <> {}} onSubmit={() => {}} placeholder="Ask anything…" slash fileCwd="/Users/tester" images={images} onImagesChange={setImages} pickFiles={pick} /> {promptWithImages("", images)} ); } render(); const input = screen.getByTestId("prompt-file-input") as HTMLInputElement; const clicked = vi.spyOn(input, "click"); pick.current?.(); expect(clicked).toHaveBeenCalled(); const doc = new File(["x"], "spec.pdf", { type: "application/pdf" }); fireEvent.change(input, { target: { files: [doc] } }); expect(api.run.dropFile).toHaveBeenCalledWith(doc, undefined); // no workspace named expect(await screen.findByText("spec.pdf")).toBeTruthy(); }); it("attaches a dropped non-image file as a name chip sent by its path", async () => { vi.mocked(api.run.dropFile).mockResolvedValueOnce({ path: "/tmp/agenthub-drops-1/b-spec.pdf" }); function Harness() { const [images, setImages] = useState([]); return ( <> {}} onSubmit={() => {}} placeholder="Ask anything…" slash fileCwd="/Users/tester" images={images} onImagesChange={setImages} /> {promptWithImages("read", images)} ); } render(); const pdf = new File(["%PDF"], "spec.pdf", { type: "application/pdf" }); fireEvent.drop(box(), { dataTransfer: { files: [pdf] } }); expect(await screen.findByText("spec.pdf")).toBeTruthy(); expect(screen.getByTestId("sent").textContent).toBe("read /tmp/agenthub-drops-1/b-spec.pdf"); // Remembered for the + menu's Recent uploads. expect(recentUploads()).toContainEqual({ path: "/tmp/agenthub-drops-1/b-spec.pdf", name: "spec.pdf", }); }); it("attaches a local file manager's files by their own paths, uploading nothing", async () => { function Harness() { const [images, setImages] = useState([]); return ( <> {}} onSubmit={() => {}} placeholder="Ask anything…" slash={false} fileCwd="/home/u" images={images} onImagesChange={setImages} /> {promptWithImages("read", images)} ); } render(); const big = new File(["x"], "demo.mp4", { type: "video/mp4" }); const data: Record = { "text/uri-list": "file:///home/u/demo.mp4" }; fireEvent.drop(box(), { dataTransfer: { files: [big], types: ["Files"], getData: (k: string) => data[k] ?? "" }, }); expect(await screen.findByText("demo.mp4")).toBeTruthy(); expect(api.run.localPaths).toHaveBeenCalledWith(["/home/u/demo.mp4"]); expect(api.run.dropFile).not.toHaveBeenCalled(); expect(screen.getByTestId("sent").textContent).toBe("read @/home/u/demo.mp4"); }); it("uploads a dropped file whose path the server does not have (an SSH tunnel's laptop)", async () => { vi.mocked(api.run.localPaths).mockResolvedValueOnce({ sizes: [null] }); const onImagesChange = vi.fn(); render( {}} onSubmit={() => {}} placeholder="Ask anything…" slash={false} fileCwd="/home/u" images={[]} onImagesChange={onImagesChange} /> ); const doc = new File(["x"], "spec.pdf", { type: "application/pdf" }); const data: Record = { "text/uri-list": "file:///Users/me/spec.pdf" }; fireEvent.drop(box(), { dataTransfer: { files: [doc], types: ["Files"], getData: (k: string) => data[k] ?? "" }, }); await vi.waitFor(() => expect(api.run.dropFile).toHaveBeenCalledWith(doc, undefined)); }); it("keeps the files that uploaded when another one is refused", async () => { vi.mocked(api.run.dropFile) .mockResolvedValueOnce({ path: "/tmp/agenthub-drops-1/a-ok.txt" }) .mockRejectedValueOnce(Object.assign(new Error("too big"), { code: "ETOOBIG" })); function Harness() { const [images, setImages] = useState([]); return ( <> {}} onSubmit={() => {}} placeholder="Ask anything…" slash={false} fileCwd="/home/u" images={images} onImagesChange={setImages} /> {promptWithImages("", images)} ); } render(); fireEvent.drop(box(), { dataTransfer: { files: [new File(["x"], "ok.txt"), new File(["y"], "huge.iso")] }, }); expect(await screen.findByText("ok.txt")).toBeTruthy(); expect(screen.getByTestId("sent").textContent).toBe("/tmp/agenthub-drops-1/a-ok.txt"); expect(screen.getByRole("alert").textContent).toMatch(/too large/); // Anything attached afterwards — here a Files panel row — ends the error. const data: Record = { [ATTACH_MIME]: JSON.stringify({ path: "@src/a.ts", name: "a.ts" }), }; fireEvent.drop(box(), { dataTransfer: { getData: (k: string) => data[k] ?? "" } }); expect(await screen.findByText("a.ts")).toBeTruthy(); expect(screen.queryByRole("alert")).toBeNull(); }); it("says a file over the server's limit is too big, and how to attach it", async () => { vi.mocked(api.run.dropFile).mockRejectedValueOnce( Object.assign(new Error("too big"), { code: "ETOOBIG", status: 413, maxBytes: 20971520 }) ); render( {}} onSubmit={() => {}} placeholder="Ask anything…" slash={false} fileCwd="/home/u" images={[]} onImagesChange={() => {}} /> ); fireEvent.drop(box(), { dataTransfer: { files: [new File(["x"], "huge.iso")] } }); expect((await screen.findByRole("alert")).textContent).toMatch( /too large to upload \(max 20 MB\).*Choose a file on this computer/ ); }); it("attaches a Files panel row dropped on it as an @path chip, once", () => { function Harness() { const [images, setImages] = useState([]); return ( <> {}} onSubmit={() => {}} placeholder="Ask anything…" slash fileCwd="/Users/tester" images={images} onImagesChange={setImages} /> {promptWithImages("read", images)} ); } render(); const data: Record = {}; const dataTransfer = { setData: (k: string, v: string) => (data[k] = v), getData: (k: string) => data[k] ?? "", get types() { return Object.keys(data); }, files: [], }; // What a Files panel row's onDragStart puts on the drag. dragProjectPath({ dataTransfer } as unknown as React.DragEvent, "src/lib/"); fireEvent.drop(box(), { dataTransfer }); fireEvent.drop(box(), { dataTransfer }); expect(screen.getByText("lib/")).toBeTruthy(); expect(api.run.dropFile).not.toHaveBeenCalled(); expect(screen.getByTestId("sent").textContent).toBe("read @src/lib/"); }); it("takes a drop anywhere on a bare editor's box; an extra folder's row comes absolute", () => { function Harness() { const [images, setImages] = useState([]); return ( <>
{}} onSubmit={() => {}} placeholder="Ask anything…" fileCwd="/Users/tester" images={images} onImagesChange={setImages} />
{promptWithImages("read", images)} ); } render(); const data: Record = {}; const dataTransfer = { setData: (k: string, v: string) => (data[k] = v), getData: (k: string) => data[k] ?? "", get types() { return Object.keys(data); }, files: [], }; dragProjectPath( { dataTransfer, currentTarget: screen.getByTestId("row") } as unknown as React.DragEvent, "notes.md" ); expect(data["text/plain"]).toBe("/Users/tester/extra/notes.md"); const over = createEvent.dragOver(screen.getByTestId("toolbar"), { dataTransfer }); fireEvent(screen.getByTestId("toolbar"), over); expect(over.defaultPrevented).toBe(true); fireEvent.drop(screen.getByTestId("toolbar"), { dataTransfer }); expect(screen.getByTestId("sent").textContent).toBe("read @/Users/tester/extra/notes.md"); }); it("takes a drop anywhere inside its data-prompt-drop screen, not only on the box", () => { function Harness() { const [images, setImages] = useState([]); return (
{}} onSubmit={() => {}} placeholder="Ask anything…" slash={false} fileCwd="/Users/tester" images={images} onImagesChange={setImages} /> {promptWithImages("see", images)}
); } render(); const dataTransfer = { getData: (k: string) => k === ATTACH_MIME ? JSON.stringify([{ path: "@/w/a.ts", name: "a.ts" }]) : "", types: [ATTACH_MIME], files: [], }; const over = createEvent.dragOver(screen.getByTestId("transcript"), { dataTransfer }); fireEvent(screen.getByTestId("transcript"), over); expect(over.defaultPrevented).toBe(true); fireEvent.drop(screen.getByTestId("transcript"), { dataTransfer }); expect(screen.getByTestId("sent").textContent).toBe("see @/w/a.ts"); }); it("takes an activity panel's drag: a group of files as chips, a skill as the /command", () => { function Harness() { const [value, setValue] = useState("check it"); const [images, setImages] = useState([]); return ( <> {}} placeholder="Ask anything…" fileCwd="/Users/tester" images={images} onImagesChange={setImages} /> {promptWithImages(value, images)} ); } render(); const drag = (start: (e: React.DragEvent) => void) => { const data: Record = {}; const dataTransfer = { setData: (k: string, v: string) => (data[k] = v), getData: (k: string) => data[k] ?? "", get types() { return Object.keys(data); }, files: [], }; start({ dataTransfer } as unknown as React.DragEvent); fireEvent.drop(box(), { dataTransfer }); }; drag((e) => dragAttachments(e, [ { path: "@/a/x.md", name: "x.md" }, { path: "/d/drops/0123456789abcdef.png", name: "y.png" }, ]) ); drag((e) => dragSkill(e, "ponytail:ponytail-help")); expect(screen.getByTestId("sent").textContent).toBe( "/ponytail:ponytail-help check it @/a/x.md /d/drops/0123456789abcdef.png" ); }); it("leaves a text paste to the textarea", () => { renderEditor(""); fireEvent.paste(box(), { clipboardData: { files: [], getData: () => "a short note" }, }); expect(api.run.dropFile).not.toHaveBeenCalled(); }); it("attaches a very long text paste as a text file, like a pasted file (#68)", async () => { renderEditor(""); const long = "x".repeat(PASTE_AS_FILE_CHARS + 1); fireEvent.paste(box(), { clipboardData: { files: [], getData: () => long } }); await waitFor(() => expect(api.run.dropFile).toHaveBeenCalledTimes(1)); const file = vi.mocked(api.run.dropFile).mock.calls[0]![0] as File; expect(file.name).toBe("pasted-text.txt"); expect(file.type).toBe("text/plain"); expect(file.size).toBe(long.length); // the whole paste, nothing cut expect(box()).toHaveValue(""); // not typed into the box }); }); describe("ModelPicker", () => { it("lists the tool's own models, and Custom… reports only the typed id", async () => { const onChange = vi.fn(); render(); fireEvent.click(screen.getByText(i18n.t("run:fields.modelInheritLabel"))); fireEvent.click(await screen.findByText("Sonnet (latest · 5.5)")); expect(onChange).toHaveBeenCalledWith("sonnet"); fireEvent.click(screen.getByText(i18n.t("run:fields.modelInheritLabel"))); fireEvent.click(screen.getByText(i18n.t("run:fields.modelCustom"))); expect(onChange).toHaveBeenCalledTimes(1); fireEvent.change(screen.getByPlaceholderText(i18n.t("run:fields.modelCustomPlaceholder")), { target: { value: "claude-opus-5" }, }); expect(onChange).toHaveBeenLastCalledWith("claude-opus-5"); }); it("offers the models Codex itself lists", async () => { render( {}} tool="codex" />); fireEvent.click(screen.getByText(i18n.t("run:fields.modelInheritLabel"))); expect(await screen.findByText("GPT-6-Sol")).toBeTruthy(); }); it("offers only a fixed list when given one (an AI Proxy's models)", async () => { render( {}} tool="claude" choices={[{ id: "glm-4.6", label: "glm-4.6" }]} /> ); fireEvent.click(screen.getByText(i18n.t("run:fields.modelInheritLabel"))); expect(await screen.findByText("glm-4.6")).toBeTruthy(); expect(screen.queryByText("Sonnet (latest · 5.5)")).toBeNull(); }); }); describe("ModelMenu", () => { it("puts the aliases up front, pinned versions under More models, and sets the effort", async () => { const onChange = vi.fn(); const onEffort = vi.fn(); render( ); fireEvent.click(screen.getByTitle(i18n.t("run:fields.model"))); await screen.findByText("Sonnet (latest · 5.5)"); expect(screen.queryByText("Claude Sonnet 5.5")).toBeNull(); // Low, Medium, High, … — index 2 is High. fireEvent.change(screen.getByRole("slider", { name: i18n.t("run:fields.effort") }), { target: { value: "2" }, }); expect(onEffort).toHaveBeenCalledWith("high"); fireEvent.click(screen.getByText(i18n.t("run:fields.moreModels"))); fireEvent.click(screen.getByText("Claude Sonnet 5.5")); expect(onChange).toHaveBeenCalledWith("claude-sonnet-5-5"); }); }); describe("promptForPane — images apart, every other file in the text", () => { it("keeps @-mentions and other files in the text, and takes images out", () => { // A Windows path inside a paste of text stayed a plain path in claude; a // paste that is only the path becomes [Image #n]. const images: PromptImage[] = [ { path: "C:\\Users\\u\\.claude\\agenthub\\drops\\f326fc666d3bdd92-image.png", name: "image.png", }, { path: "/data/drops/0123456789abcdef-log.txt", name: "log.txt" }, { path: '@"/home/u/My Docs/a.PNG"', name: "a.PNG" }, { path: "/data/drops/0123456789abcdef.JPG", name: "b.jpg" }, ]; expect(promptForPane(" look ", images)).toEqual({ text: 'look /data/drops/0123456789abcdef-log.txt @"/home/u/My Docs/a.PNG"', files: [ "C:\\Users\\u\\.claude\\agenthub\\drops\\f326fc666d3bdd92-image.png", "/data/drops/0123456789abcdef.JPG", ], }); }); it("hands claude every file as an @ mention in one paste, with a trailing space", () => { // A bare pasted path stayed plain text on Windows, and a path in the // command-line prompt is never an image; claude reads an @ mention at submit. const images: PromptImage[] = [ { path: "C:\\Users\\u\\.claude\\agenthub\\drops\\f326fc666d3bdd92-image.png", name: "image.png", }, { path: "/data/drops/0123456789abcdef-log.txt", name: "log.txt" }, { path: '@"/home/u/My Docs/a.PNG"', name: "a.PNG" }, { path: "C:\\Users\\Jo Do\\drops\\0123456789abcdef.png", name: "b.png" }, ]; expect(promptForPane(" look ", images, true)).toEqual({ text: "look @C:/Users/u/.claude/agenthub/drops/f326fc666d3bdd92-image.png " + '@/data/drops/0123456789abcdef-log.txt @"/home/u/My Docs/a.PNG" ' + '@"C:/Users/Jo Do/drops/0123456789abcdef.png" ', files: [], }); expect(promptForPane("just text", [], true)).toEqual({ text: "just text", files: [] }); expect(promptWithImages("see", [images[1]!], true)).toBe( "see @/data/drops/0123456789abcdef-log.txt" ); }); });