0547488f13
- 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
756 lines
28 KiB
TypeScript
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"
|
|
);
|
|
});
|
|
});
|