/** * @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, 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 { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import i18n from "i18next"; import { ModelMenu, ModelPicker, PASTE_AS_FILE_CHARS, PromptEditor, promptWithImages, type PromptImage, } from "../RunSetup"; import { api } from "../../../lib/api"; import { dragProjectPath, 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" }) }, 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("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 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("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"); }); });