Files
AgentHub/client/src/components/run/__tests__/RunSetup.test.tsx
T
nntrivi2001 0547488f13 chore: v2.8.13 — Artifacts reinstall/edit/installs, agent steps in chat, pick a local file to attach, Windows paths as links
- Chat: an Agent step shows its task, status and tool calls and opens its conversation in place; Windows paths (E:\…, C:/…) in replies open as files
- Prompt: attach a file on this computer by path (native picker), clearer too-large messages
- Artifacts: edit title/description and a skill's files (new snapshot), Reinstall, install history, Hub-only rows, skills installed into ~/.claude/skills and ~/.agents/skills
- Files: batch upload of small files in one extract
- AI Tools: npm install falls back to --prefix ~/.local when the global prefix is not writable
2026-10-20 16:44:55 +07:00

756 lines
28 KiB
TypeScript

/**
* @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ĩ <vinnt@smartgift.vn>
*/
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<Record<string, unknown>>();
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<string, { id: string; label: string }[]> = {
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(
<PromptEditor
value={value}
placeholder="Ask anything…"
slash={slash}
fileCwd="/Users/tester"
images={[]}
onImagesChange={() => {}}
{...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(
<PromptEditor
value={"/review @src/a.ts please"}
placeholder="Ask anything…"
slash
fileCwd="/Users/tester"
images={[]}
onImagesChange={() => {}}
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(<PromptEditor {...props} value={"a\nb\nc\nd\ne"} bare />);
expect(box().style.height).toBe("180px");
expect(box().className).toContain("max-h-[40vh]");
tall.mockReturnValue(60);
view.rerender(<PromptEditor {...props} value="a" bare />);
expect(box().style.height).toBe("60px");
view.unmount();
// The framed editor keeps its fixed rows.
render(<PromptEditor {...props} value={"a\nb\nc"} />);
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<PromptImage[]>([]);
return (
<>
<PromptEditor
value="see"
onChange={() => {}}
onSubmit={() => {}}
placeholder="Ask anything…"
slash
fileCwd="/Users/tester"
images={images}
onImagesChange={setImages}
lane={7}
/>
<output data-testid="sent">{promptWithImages("see", images)}</output>
</>
);
}
render(<Harness />);
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<PromptImage[]>([]);
return (
<>
<PromptEditor
value=""
onChange={() => {}}
onSubmit={() => {}}
placeholder="Ask anything…"
slash
fileCwd="/Users/tester"
images={images}
onImagesChange={setImages}
pickFiles={pick}
/>
<output data-testid="sent">{promptWithImages("", images)}</output>
</>
);
}
render(<Harness />);
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<PromptImage[]>([]);
return (
<>
<PromptEditor
value="read"
onChange={() => {}}
onSubmit={() => {}}
placeholder="Ask anything…"
slash
fileCwd="/Users/tester"
images={images}
onImagesChange={setImages}
/>
<output data-testid="sent">{promptWithImages("read", images)}</output>
</>
);
}
render(<Harness />);
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<PromptImage[]>([]);
return (
<>
<PromptEditor
value="read"
onChange={() => {}}
onSubmit={() => {}}
placeholder="Ask anything…"
slash={false}
fileCwd="/home/u"
images={images}
onImagesChange={setImages}
/>
<output data-testid="sent">{promptWithImages("read", images)}</output>
</>
);
}
render(<Harness />);
const big = new File(["x"], "demo.mp4", { type: "video/mp4" });
const data: Record<string, string> = { "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(
<PromptEditor
value=""
onChange={() => {}}
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<string, string> = { "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<PromptImage[]>([]);
return (
<>
<PromptEditor
value=""
onChange={() => {}}
onSubmit={() => {}}
placeholder="Ask anything…"
slash={false}
fileCwd="/home/u"
images={images}
onImagesChange={setImages}
/>
<output data-testid="sent">{promptWithImages("", images)}</output>
</>
);
}
render(<Harness />);
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<string, string> = {
[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(
<PromptEditor
value=""
onChange={() => {}}
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<PromptImage[]>([]);
return (
<>
<PromptEditor
value="read"
onChange={() => {}}
onSubmit={() => {}}
placeholder="Ask anything…"
slash
fileCwd="/Users/tester"
images={images}
onImagesChange={setImages}
/>
<output data-testid="sent">{promptWithImages("read", images)}</output>
</>
);
}
render(<Harness />);
const data: Record<string, string> = {};
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<PromptImage[]>([]);
return (
<>
<div>
<PromptEditor
bare
slash={false}
value="read"
onChange={() => {}}
onSubmit={() => {}}
placeholder="Ask anything…"
fileCwd="/Users/tester"
images={images}
onImagesChange={setImages}
/>
<div data-testid="toolbar" />
</div>
<section data-drag-root="/Users/tester/extra/">
<span data-testid="row" />
</section>
<output data-testid="sent">{promptWithImages("read", images)}</output>
</>
);
}
render(<Harness />);
const data: Record<string, string> = {};
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<PromptImage[]>([]);
return (
<div data-prompt-drop>
<div data-testid="transcript" />
<PromptEditor
value="see"
onChange={() => {}}
onSubmit={() => {}}
placeholder="Ask anything…"
slash={false}
fileCwd="/Users/tester"
images={images}
onImagesChange={setImages}
/>
<output data-testid="sent">{promptWithImages("see", images)}</output>
</div>
);
}
render(<Harness />);
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<PromptImage[]>([]);
return (
<>
<PromptEditor
slash={false}
value={value}
onChange={setValue}
onSubmit={() => {}}
placeholder="Ask anything…"
fileCwd="/Users/tester"
images={images}
onImagesChange={setImages}
/>
<output data-testid="sent">{promptWithImages(value, images)}</output>
</>
);
}
render(<Harness />);
const drag = (start: (e: React.DragEvent) => void) => {
const data: Record<string, string> = {};
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(<ModelPicker value="" onChange={onChange} tool="claude" />);
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(<ModelPicker value="" onChange={() => {}} 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(
<ModelPicker
value=""
onChange={() => {}}
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(
<ModelMenu value="" onChange={onChange} tool="claude" effort="" onEffortChange={onEffort} />
);
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"
);
});
});