fa156d6a59
Chat UI: - Each Claude/Codex session keeps its own chat/terminal view; Settings only sets how new sessions open. - Composer + menu: upload, project files and folders (#54, also dragged from the Files/Changes tree), context files, recent uploads, skills, plugins. - Activity side panel (progress, outputs, files read, uploads, memory, skills, connectors) with a file preview; edited-file counts per turn. - Permission-mode chip (Shift+Tab steps, read back from the pane: server/lib/pane-mode.js, GET|POST /api/run/:id/permission-mode). - Stop (Esc) while the main agent works; the status is checked per agent (with session_id /api/agents ignores its status filter) and re-read every 5 s while Stop shows. - Dropped images shown in the conversation (GET /api/run/drop/:name, the requester's own drop dir), full-size viewer. - A plain-text paste over 5,000 characters is attached as pasted-text.txt, so no prompt reaches tmux/argv limits (#68). Changes panel: every /api/lanes/:id/git* route takes ?source=<lane_sources id> (sourceFolder; ENOSOURCE otherwise) and a folder picker switches the whole panel to a workspace's extra folder (#70). Server mode S3 (one Linux user per account) — experimental, verified with fake sudo plus a P6 managed-settings spike on a test account: - member tmux listing runs in the background (cached, 15 s => unknown); - transcript snapshot copy is async, chunked, temp file + rename; - Express Layer patch only on Express 4; - tmux capturePane/sendKey go to the member's own server. Docs: the multi-user design moved in as docs/plans/server-mode/README.md (the AgentHub-Server repo is gone); S3-PLAN P6 result; LANES/API/openapi.
430 lines
16 KiB
TypeScript
430 lines
16 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, 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 { 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<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" }) },
|
|
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("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 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("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");
|
|
});
|
|
});
|