/** * @file PaneChat.test.tsx * @description The Chat view's controls: the `+` menu (Upload, Skills, Plugins * → a plugin's skills, each inserted as `/`), 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ĩ */ 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; }) => (
), })); 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>(); 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 = {}) { return render( ); } 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( ); 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) => ( ); 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( ); 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 = () => ( ); 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( ); 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( ); 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) => { 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); } }); });