ecbbb4dd8f
- Chat view: an attached image's path is pasted on its own after the text (promptForPane). claude turns a paste that is only an image path into [Image #n], but left a Windows path inside a paste of text as a plain path. Other files and @-mentions stay in the text as before. - Plugin bootstrap: depsHash covers only what npm installs (no release version), so a version bump no longer reinstalls runtime deps; the client's installed tree is kept while its manifests are unchanged (no npm ci per update); the user-side build runs vite only (tsc -b cost ~25 s / ~900 MB); clientBuild is recorded only after a build that did not fail, so a failed one is retried; npm output goes to runtime/npm-install.log. - Server: a port held by another program ends with a clear message and exit 1 instead of an uncaught 'error' stack trace. - Client: the onboarding wizard is lazy and rendered only before onboarding; usePrefersReducedMotion moved to hooks/ so PetSettings no longer pulls the Sagi chat/pane stack into the entry chunk (884 KB -> 431 KB, gzip 268 -> 133 KB).
555 lines
21 KiB
TypeScript
555 lines
21 KiB
TypeScript
/**
|
|
* @file PaneChat.test.tsx
|
|
* @description The Chat view's controls: the `+` menu (Upload, Skills, Plugins
|
|
* → a plugin's skills, each inserted as `/<name>`), the Model/Effort chip typed
|
|
* into the pane as `/model` / `/effort`, and the permission mode read off the
|
|
* pane and stepped by the server — none of them for a Codex pane; and the
|
|
* activity panel, collapsed into a file button with the output count, whose
|
|
* files open a preview in its place; a file in a turn's "Edited N files" card
|
|
* opens in the Workspace's Changes panel when it is in the pane's folder; and
|
|
* Stop in Send's place while the main agent works, pressing Esc in the pane;
|
|
* and the menu the CLI asks with, shown as buttons that press its keys, a
|
|
* message sent meanwhile typed as its "Type something" answer.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
|
import { MemoryRouter } from "react-router-dom";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { PaneChat, contextWindow } from "../PaneChat";
|
|
import { api } from "../../../lib/api";
|
|
import {
|
|
answerRunMenu,
|
|
chooseRunOption,
|
|
interruptRun,
|
|
readRunMenu,
|
|
submitToRun,
|
|
type PaneMenu,
|
|
} from "../../../lib/panePaste";
|
|
import { OPEN_IN_CHANGES_EVENT } from "../../../lib/openInChanges";
|
|
|
|
vi.mock("../../conversation/ConversationView", () => ({
|
|
// Stands in for a turn's "Edited N files" card: a click names a file.
|
|
ConversationView: ({
|
|
onOpenFile,
|
|
onNotStarted,
|
|
}: {
|
|
onOpenFile?: (path: string) => void;
|
|
onNotStarted?: (notStarted: boolean) => void;
|
|
}) => (
|
|
<div data-testid="conversation">
|
|
<button data-testid="edited-in" onClick={() => onOpenFile?.("/w/src/a.ts")} />
|
|
<button data-testid="edited-out" onClick={() => onOpenFile?.("/elsewhere/b.ts")} />
|
|
{/* The session has no row yet: the CLI has not started it. */}
|
|
<button data-testid="not-started" onClick={() => onNotStarted?.(true)} />
|
|
</div>
|
|
),
|
|
}));
|
|
const confirmDialog = vi.hoisted(() => vi.fn(async () => false));
|
|
vi.mock("../../../lib/confirm", () => ({ confirmDialog }));
|
|
vi.mock("../../../lib/panePaste", () => ({
|
|
submitToRun: vi.fn(() => true),
|
|
interruptRun: vi.fn(() => true),
|
|
readRunMenu: vi.fn(() => null),
|
|
chooseRunOption: vi.fn(() => true),
|
|
answerRunMenu: vi.fn(() => true),
|
|
}));
|
|
vi.mock("../../../lib/api", async (importOriginal) => {
|
|
const actual = await importOriginal<Record<string, unknown>>();
|
|
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
|
|
return {
|
|
...actual,
|
|
api: {
|
|
agents: { list: r({ agents: [] }) },
|
|
aiTools: {
|
|
models: r({
|
|
flag: true,
|
|
models: [
|
|
{ id: "opus", label: "Opus" },
|
|
{ id: "sonnet", label: "Sonnet" },
|
|
],
|
|
defaultModel: null,
|
|
}),
|
|
},
|
|
run: {
|
|
files: r({ items: [] }),
|
|
dropFile: r({ path: "/tmp/agenthub-drops-1/a.png" }),
|
|
permissionMode: r({ mode: "acceptEdits" }),
|
|
setPermissionMode: r({ mode: "plan" }),
|
|
},
|
|
sessions: {
|
|
outline: r({
|
|
progress: [],
|
|
outputs: ["/w/notes.md", "/w/report.xlsx"],
|
|
files: [],
|
|
uploads: [],
|
|
memory: [],
|
|
skills: [],
|
|
mcp: [],
|
|
}),
|
|
readFile: r({ text: "", truncated: false }),
|
|
},
|
|
lanes: {
|
|
gitFile: r({
|
|
available: true,
|
|
path: "notes.md",
|
|
rev: "work",
|
|
binary: false,
|
|
truncated: false,
|
|
lines: ["# Notes", "", "hello"],
|
|
}),
|
|
gitRawUrl: () => "",
|
|
},
|
|
ccConfig: {
|
|
commands: r({ items: [] }),
|
|
skills: r({
|
|
items: [{ scope: "user", name: "morning", frontmatter: { description: "Plan the day" } }],
|
|
}),
|
|
plugins: r({
|
|
plugins: [
|
|
{
|
|
name: "doox",
|
|
enabled: true,
|
|
contributes: {
|
|
items: {
|
|
skills: [{ name: "plan-consolidation", description: "Merge plans" }],
|
|
commands: [],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "off",
|
|
enabled: false,
|
|
contributes: { items: { skills: [{ name: "x" }], commands: [] } },
|
|
},
|
|
],
|
|
}),
|
|
},
|
|
},
|
|
};
|
|
});
|
|
|
|
const RUN = {
|
|
id: "agenthub-lane-1",
|
|
tool: "claude" as "claude" | "codex",
|
|
model: "opus",
|
|
effort: "high" as const,
|
|
permissionMode: "auto" as const,
|
|
};
|
|
|
|
function renderChat(run: Partial<typeof RUN> = {}) {
|
|
return render(
|
|
<MemoryRouter>
|
|
<PaneChat run={{ ...RUN, ...run }} sessionId="s1" cwd="/w" />
|
|
</MemoryRouter>
|
|
);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.mocked(submitToRun).mockClear();
|
|
// A prompt typed in one test is a saved draft in the next (`lib/chatDraft`).
|
|
localStorage.removeItem("agenthub.chat.draft.s1");
|
|
sessionStorage.clear();
|
|
});
|
|
|
|
describe("PaneChat — the + menu", () => {
|
|
it("puts a skill, or a plugin's skill, at the front of the message", async () => {
|
|
renderChat();
|
|
const box = screen.getByPlaceholderText("Write a message…") as HTMLTextAreaElement;
|
|
fireEvent.change(box, { target: { value: "today" } });
|
|
|
|
fireEvent.click(screen.getByTestId("chat-add"));
|
|
fireEvent.click(screen.getByTestId("chat-add-skills"));
|
|
fireEvent.click(await screen.findByTestId("chat-add-skill-morning"));
|
|
expect(box.value).toBe("/morning today");
|
|
|
|
fireEvent.click(screen.getByTestId("chat-add"));
|
|
fireEvent.click(screen.getByTestId("chat-add-plugins"));
|
|
expect(screen.queryByTestId("chat-add-plugin-off")).toBeNull(); // disabled plugin
|
|
fireEvent.click(await screen.findByTestId("chat-add-plugin-doox"));
|
|
fireEvent.click(screen.getByTestId("chat-add-skill-doox:plan-consolidation"));
|
|
expect(box.value).toBe("/doox:plan-consolidation /morning today");
|
|
});
|
|
|
|
it("Upload opens the prompt box's file picker", () => {
|
|
renderChat();
|
|
const input = screen.getByTestId("prompt-file-input") as HTMLInputElement;
|
|
const clicked = vi.spyOn(input, "click");
|
|
fireEvent.click(screen.getByTestId("chat-add"));
|
|
fireEvent.click(screen.getByTestId("chat-add-upload"));
|
|
expect(clicked).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("PaneChat — Stop", () => {
|
|
it("replaces Send while the main agent works and the box is empty, and presses Esc", async () => {
|
|
vi.mocked(api.agents.list).mockResolvedValueOnce({
|
|
agents: [{ type: "main", status: "working" }],
|
|
} as never);
|
|
renderChat();
|
|
fireEvent.click(await screen.findByTestId("pane-chat-stop"));
|
|
expect(interruptRun).toHaveBeenCalledWith("agenthub-lane-1");
|
|
expect(screen.queryByTestId("pane-chat-stop")).toBeNull();
|
|
expect(screen.getByTestId("pane-chat-send")).toBeInTheDocument();
|
|
});
|
|
|
|
it("drops Stop once the turn ended even when no live update says so", async () => {
|
|
// A missed agent_updated (another dashboard on the same database got the
|
|
// hook, or the socket dropped): the poll while working catches it.
|
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
|
try {
|
|
vi.mocked(api.agents.list).mockResolvedValueOnce({
|
|
agents: [{ type: "main", status: "working" }],
|
|
} as never);
|
|
renderChat();
|
|
expect(await screen.findByTestId("pane-chat-stop")).toBeInTheDocument();
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(5000);
|
|
});
|
|
await waitFor(() => expect(screen.queryByTestId("pane-chat-stop")).toBeNull());
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("keeps Send for a session at rest", async () => {
|
|
// With session_id the server answers EVERY agent of the session (its
|
|
// status filter does not apply): the main agent is there, waiting.
|
|
vi.mocked(api.agents.list).mockResolvedValueOnce({
|
|
agents: [{ type: "main", status: "waiting" }],
|
|
} as never);
|
|
renderChat();
|
|
await waitFor(() => expect(api.agents.list).toHaveBeenCalled());
|
|
expect(screen.queryByTestId("pane-chat-stop")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("PaneChat — waiting on the user", () => {
|
|
it("marks a hidden tab while a permission prompt waits", async () => {
|
|
const vis = vi.spyOn(document, "visibilityState", "get").mockReturnValue("hidden");
|
|
document.title = "Workspace · AgentHub";
|
|
try {
|
|
vi.mocked(api.agents.list).mockResolvedValueOnce({
|
|
agents: [
|
|
{
|
|
type: "main",
|
|
status: "waiting",
|
|
awaiting_input_since: "2026-10-12T08:00:00.000Z",
|
|
awaiting_reason: "notification",
|
|
},
|
|
],
|
|
} as never);
|
|
const { unmount } = renderChat();
|
|
await waitFor(() => expect(document.title).toBe("● Waiting · Workspace · AgentHub"));
|
|
unmount();
|
|
expect(document.title).toBe("Workspace · AgentHub");
|
|
} finally {
|
|
vis.mockRestore();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("contextWindow", () => {
|
|
it("knows Claude's 200K line from the 1M one", () => {
|
|
expect(contextWindow("claude-opus-4-5-20251101")).toBe(200_000);
|
|
expect(contextWindow("claude-sonnet-4-20250514")).toBe(200_000);
|
|
expect(contextWindow("claude-3-7-sonnet-20250219")).toBe(200_000);
|
|
expect(contextWindow("claude-opus-4-6")).toBe(1_000_000);
|
|
expect(contextWindow("claude-opus-5-5")).toBe(1_000_000);
|
|
expect(contextWindow("claude-haiku-5-5")).toBe(1_000_000);
|
|
expect(contextWindow("gpt-5-codex")).toBeNull();
|
|
expect(contextWindow(null)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("PaneChat — model, effort and permission mode", () => {
|
|
it("types /model into the pane", async () => {
|
|
renderChat();
|
|
fireEvent.click(screen.getByRole("button", { name: /Opus/ }));
|
|
fireEvent.click(await screen.findByRole("menuitemradio", { name: /Sonnet/ }));
|
|
expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "/model sonnet", []);
|
|
});
|
|
|
|
it("shows the mode the pane shows, and asks the server to step it", async () => {
|
|
renderChat();
|
|
const chip = byId("pane-chat-mode");
|
|
await waitFor(() => expect(chip()).toHaveTextContent("Accept edits"));
|
|
fireEvent.click(chip().querySelector("button")!);
|
|
fireEvent.click(await screen.findByRole("option", { name: /Plan/ }));
|
|
await waitFor(() =>
|
|
expect(api.run.setPermissionMode).toHaveBeenCalledWith("agenthub-lane-1", "plan")
|
|
);
|
|
await waitFor(() => expect(chip()).toHaveTextContent("Plan"));
|
|
});
|
|
|
|
it("a Codex pane gets the + menu's Upload only — no skills, model or mode", () => {
|
|
renderChat({ tool: "codex" });
|
|
expect(screen.queryByTestId("pane-chat-mode")).toBeNull();
|
|
expect(screen.queryByRole("button", { name: /Opus/ })).toBeNull();
|
|
fireEvent.click(screen.getByTestId("chat-add"));
|
|
expect(screen.getByTestId("chat-add-upload")).toBeTruthy();
|
|
expect(screen.queryByTestId("chat-add-skills")).toBeNull();
|
|
});
|
|
});
|
|
|
|
const byId = (id: string) => () => screen.getByTestId(id);
|
|
|
|
describe("PaneChat — the activity panel", () => {
|
|
it("opens from the file button, previews a file, and closes back to the panel", async () => {
|
|
render(
|
|
<MemoryRouter>
|
|
<PaneChat run={RUN} sessionId="s1" cwd="/w" laneId={7} laneCwd="/w" />
|
|
</MemoryRouter>
|
|
);
|
|
expect(screen.queryByTestId("chat-outline")).toBeNull();
|
|
const show = screen.getByTestId("chat-outline-show");
|
|
await waitFor(() => expect(show.textContent).toBe("2"));
|
|
|
|
fireEvent.click(show);
|
|
fireEvent.click(screen.getByRole("button", { name: "MD: notes" }));
|
|
expect(screen.queryByTestId("chat-outline")).toBeNull();
|
|
await screen.findByTestId("markdown-preview");
|
|
expect(vi.mocked(api.lanes.gitFile)).toHaveBeenCalledWith(7, "notes.md");
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
|
expect(screen.getByTestId("chat-outline")).toBeTruthy();
|
|
});
|
|
|
|
it("starts open in a pane spanning the grid, and follows the pane when it is split", async () => {
|
|
const pane = (panelOpen: boolean) => (
|
|
<MemoryRouter>
|
|
<PaneChat run={RUN} sessionId="s1" cwd="/w" panelOpen={panelOpen} />
|
|
</MemoryRouter>
|
|
);
|
|
const { rerender } = render(pane(true));
|
|
await screen.findByTestId("chat-outline");
|
|
rerender(pane(false));
|
|
expect(screen.queryByTestId("chat-outline")).toBeNull();
|
|
expect(screen.getByTestId("chat-outline-show")).toBeTruthy();
|
|
});
|
|
|
|
it("starts closed while the session has no output, even in a spanning pane", async () => {
|
|
vi.mocked(api.sessions.outline).mockResolvedValueOnce({
|
|
progress: [],
|
|
outputs: [],
|
|
files: [],
|
|
uploads: [],
|
|
memory: [],
|
|
skills: [],
|
|
mcp: [],
|
|
} as never);
|
|
render(
|
|
<MemoryRouter>
|
|
<PaneChat run={RUN} sessionId="s1" cwd="/w" panelOpen />
|
|
</MemoryRouter>
|
|
);
|
|
await waitFor(() => expect(api.sessions.outline).toHaveBeenCalled());
|
|
expect(screen.queryByTestId("chat-outline")).toBeNull();
|
|
expect(screen.getByTestId("chat-outline-show").textContent).toBe("0");
|
|
});
|
|
|
|
it("keeps the user's open or close across the chat leaving for the terminal", async () => {
|
|
const pane = () => (
|
|
<MemoryRouter>
|
|
<PaneChat run={RUN} sessionId="s1" cwd="/w" panelOpen />
|
|
</MemoryRouter>
|
|
);
|
|
const first = render(pane());
|
|
fireEvent.click(
|
|
within(await screen.findByTestId("chat-outline")).getByRole("button", {
|
|
name: "Hide side panel",
|
|
})
|
|
);
|
|
first.unmount();
|
|
render(pane());
|
|
await waitFor(() => expect(screen.getByTestId("chat-outline-show").textContent).toBe("2"));
|
|
expect(screen.queryByTestId("chat-outline")).toBeNull();
|
|
});
|
|
|
|
it("resizes from its splitter and remembers the width", async () => {
|
|
localStorage.removeItem("agenthub.chat.panelWidth");
|
|
render(
|
|
<MemoryRouter>
|
|
<PaneChat run={RUN} sessionId="s1" cwd="/w" panelOpen />
|
|
</MemoryRouter>
|
|
);
|
|
const splitter = await screen.findByRole("separator", { name: "Resize side panel" });
|
|
expect(splitter.getAttribute("aria-valuenow")).toBe("70");
|
|
fireEvent.keyDown(splitter, { key: "ArrowLeft" });
|
|
expect(localStorage.getItem("agenthub.chat.panelWidth")).toBe("0.35");
|
|
expect(splitter.getAttribute("aria-valuenow")).toBe("65");
|
|
});
|
|
});
|
|
|
|
describe("PaneChat — an edited file", () => {
|
|
it("opens in the Changes panel when it is in the pane's folder, else in its own preview", async () => {
|
|
const seen: unknown[] = [];
|
|
const listen = (e: Event) => seen.push((e as CustomEvent).detail);
|
|
window.addEventListener(OPEN_IN_CHANGES_EVENT, listen);
|
|
render(
|
|
<MemoryRouter>
|
|
<PaneChat run={RUN} sessionId="s1" cwd="/w" laneId={7} laneCwd="/w" />
|
|
</MemoryRouter>
|
|
);
|
|
fireEvent.click(screen.getByTestId("edited-in"));
|
|
expect(seen).toEqual([{ laneId: 7, path: "src/a.ts" }]);
|
|
expect(screen.queryByTestId("chat-file-preview")).toBeNull();
|
|
// Outside the folder the Changes panel cannot show it: the chat's preview does.
|
|
fireEvent.click(screen.getByTestId("edited-out"));
|
|
expect(seen).toHaveLength(1);
|
|
expect(await screen.findByTestId("chat-file-preview")).toBeInTheDocument();
|
|
window.removeEventListener(OPEN_IN_CHANGES_EVENT, listen);
|
|
});
|
|
});
|
|
|
|
describe("PaneChat — the unsent prompt", () => {
|
|
it("keeps the half-typed prompt and its attachments when the chat unmounts (terminal view, reload)", async () => {
|
|
const first = renderChat();
|
|
fireEvent.change(screen.getByPlaceholderText("Write a message…"), {
|
|
target: { value: "half typed" },
|
|
});
|
|
// A dropped attachment, as a Files row or an "Edited N files" row brings it.
|
|
const drop = new Event("drop", { bubbles: true, cancelable: true });
|
|
Object.defineProperty(drop, "dataTransfer", {
|
|
value: {
|
|
types: ["application/x-agenthub-attach"],
|
|
getData: (t: string) =>
|
|
t === "application/x-agenthub-attach"
|
|
? JSON.stringify({ path: "@src/a.ts", name: "a.ts" })
|
|
: "",
|
|
files: [],
|
|
},
|
|
});
|
|
act(() => {
|
|
screen.getByPlaceholderText("Write a message…").dispatchEvent(drop);
|
|
});
|
|
expect(await screen.findByText("a.ts")).toBeInTheDocument();
|
|
first.unmount();
|
|
|
|
renderChat();
|
|
expect(screen.getByPlaceholderText("Write a message…")).toHaveValue("half typed");
|
|
expect(screen.getByText("a.ts")).toBeInTheDocument();
|
|
fireEvent.click(screen.getByTestId("pane-chat-send"));
|
|
expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "half typed @src/a.ts", []);
|
|
await waitFor(() => expect(localStorage.getItem("agenthub.chat.draft.s1")).toBeNull());
|
|
});
|
|
|
|
// A drag from outside the dashboard (a file manager, VS Code, a selection)
|
|
// carries only text: the terminal beneath pastes it, so the chat takes it too.
|
|
const textDrop = (data: Record<string, string>) => {
|
|
const drop = new Event("drop", { bubbles: true, cancelable: true });
|
|
Object.defineProperty(drop, "dataTransfer", {
|
|
value: { types: Object.keys(data), getData: (t: string) => data[t] ?? "", files: [] },
|
|
});
|
|
act(() => {
|
|
screen.getByTestId("pane-chat").dispatchEvent(drop);
|
|
});
|
|
return drop;
|
|
};
|
|
|
|
it("attaches the local files of a dropped file:// list", async () => {
|
|
renderChat();
|
|
const drop = textDrop({
|
|
"text/uri-list": "file:///home/u/My%20Docs/spec.md\r\nfile:///home/u/b.ts",
|
|
"text/plain": "/home/u/My Docs/spec.md",
|
|
});
|
|
expect(drop.defaultPrevented).toBe(true);
|
|
expect(await screen.findByText("spec.md")).toBeInTheDocument();
|
|
expect(screen.getByText("b.ts")).toBeInTheDocument();
|
|
fireEvent.change(screen.getByPlaceholderText("Write a message…"), {
|
|
target: { value: "read" },
|
|
});
|
|
fireEvent.click(screen.getByTestId("pane-chat-send"));
|
|
expect(submitToRun).toHaveBeenCalledWith(
|
|
"agenthub-lane-1",
|
|
'read @"/home/u/My Docs/spec.md" @/home/u/b.ts',
|
|
[]
|
|
);
|
|
});
|
|
|
|
it("inserts dropped plain text into the prompt", () => {
|
|
renderChat();
|
|
const drop = textDrop({ "text/plain": "src/lib/x.ts" });
|
|
expect(drop.defaultPrevented).toBe(true);
|
|
expect(screen.getByPlaceholderText("Write a message…")).toHaveValue("src/lib/x.ts");
|
|
});
|
|
|
|
// Issue #73: before the session starts the CLI may be asking in the terminal
|
|
// (trust this folder? — "No, exit" preselected); an Enter sent from here
|
|
// would answer it, so a send asks first.
|
|
it("asks before sending while the session has not started", async () => {
|
|
vi.mocked(submitToRun).mockClear();
|
|
confirmDialog.mockClear();
|
|
renderChat();
|
|
fireEvent.click(screen.getByTestId("not-started"));
|
|
const box = screen.getByPlaceholderText("Write a message…");
|
|
fireEvent.change(box, { target: { value: "hello" } });
|
|
confirmDialog.mockResolvedValueOnce(false);
|
|
fireEvent.click(screen.getByTestId("pane-chat-send"));
|
|
await waitFor(() => expect(confirmDialog).toHaveBeenCalledTimes(1));
|
|
expect(submitToRun).not.toHaveBeenCalled();
|
|
expect(box).toHaveValue("hello");
|
|
confirmDialog.mockResolvedValueOnce(true);
|
|
fireEvent.click(screen.getByTestId("pane-chat-send"));
|
|
await waitFor(() => expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "hello", []));
|
|
});
|
|
|
|
it("sends without asking once the session has started", async () => {
|
|
vi.mocked(submitToRun).mockClear();
|
|
confirmDialog.mockClear();
|
|
renderChat();
|
|
fireEvent.change(screen.getByPlaceholderText("Write a message…"), { target: { value: "hi" } });
|
|
fireEvent.click(screen.getByTestId("pane-chat-send"));
|
|
await waitFor(() => expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "hi", []));
|
|
expect(confirmDialog).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("PaneChat — a menu the CLI asks with", () => {
|
|
const MENU: PaneMenu = {
|
|
question: "Which color?",
|
|
options: [
|
|
{ n: 1, label: "Red", hint: "warm", checked: null },
|
|
{ n: 2, label: "Type something.", hint: "", checked: null },
|
|
],
|
|
cursor: 0,
|
|
multi: false,
|
|
typeAt: 1,
|
|
};
|
|
|
|
it("shows the question as buttons in the box's place, and a pick presses its key", async () => {
|
|
vi.mocked(readRunMenu).mockReturnValue(MENU);
|
|
try {
|
|
renderChat();
|
|
const card = await screen.findByTestId("pane-chat-menu");
|
|
expect(card).toHaveTextContent("Which color?");
|
|
expect(card).toHaveTextContent("warm");
|
|
// The prompt box is hidden (kept mounted) while the CLI asks.
|
|
expect(screen.getByPlaceholderText("Write a message…").closest(".hidden")).not.toBeNull();
|
|
fireEvent.click(screen.getByText("Red"));
|
|
expect(chooseRunOption).toHaveBeenCalledWith("agenthub-lane-1", MENU, 0);
|
|
} finally {
|
|
vi.mocked(readRunMenu).mockReturnValue(null);
|
|
}
|
|
});
|
|
|
|
it("types the answer in the card's own field, never pressing that row's key", async () => {
|
|
vi.mocked(readRunMenu).mockReturnValue(MENU);
|
|
vi.mocked(chooseRunOption).mockClear();
|
|
vi.mocked(submitToRun).mockClear();
|
|
try {
|
|
renderChat();
|
|
fireEvent.click(await screen.findByText("Type something."));
|
|
expect(chooseRunOption).not.toHaveBeenCalled();
|
|
const field = screen.getByTestId("pane-chat-menu-input");
|
|
expect(field).toHaveFocus();
|
|
fireEvent.change(field, { target: { value: "purple" } });
|
|
fireEvent.keyDown(field, { key: "Enter" });
|
|
expect(answerRunMenu).toHaveBeenCalledWith("agenthub-lane-1", MENU, "purple");
|
|
expect(submitToRun).not.toHaveBeenCalled();
|
|
} finally {
|
|
vi.mocked(readRunMenu).mockReturnValue(null);
|
|
}
|
|
});
|
|
});
|