feat: v2.6.0 — Chat UI composer (+ menu, project files, activity panel, permission mode, Stop), git on a workspace's extra folders (#70), long paste as a file (#68), S3 server mode (experimental)
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.
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* @file ChatAddMenu.test.tsx
|
||||
* @description The `+` menu's existing-resource pickers: Project files (a
|
||||
* search with folders, attached as `@path`), the workspace's Context files
|
||||
* (files only, none missing) and this browser's Recent uploads.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "i18next";
|
||||
import { ChatAddMenu } from "../ChatAddMenu";
|
||||
import { api } from "../../../lib/api";
|
||||
import { rememberUploads } from "../../../lib/promptAttach";
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
run: { files: vi.fn().mockResolvedValue({ items: ["src/", "src/a.ts"] }) },
|
||||
lanes: {
|
||||
sources: vi.fn().mockResolvedValue({
|
||||
sources: [
|
||||
{ id: 1, kind: "file", path: "/data/ctx/1/brief.md", name: "brief.md", missing: false },
|
||||
{ id: 2, kind: "file", path: "/data/ctx/1/gone.md", name: "gone.md", missing: true },
|
||||
{ id: 3, kind: "folder", path: "/srv/docs", name: "docs", missing: false },
|
||||
],
|
||||
}),
|
||||
},
|
||||
ccConfig: {
|
||||
skills: vi.fn().mockResolvedValue({ items: [] }),
|
||||
plugins: vi.fn().mockResolvedValue({ plugins: [] }),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
function renderMenu(laneId: number | null = 7) {
|
||||
const onAttach = vi.fn();
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ChatAddMenu
|
||||
cwd="/repo"
|
||||
laneId={laneId}
|
||||
skills={false}
|
||||
onUpload={() => {}}
|
||||
onInsert={() => {}}
|
||||
onAttach={onAttach}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
fireEvent.click(screen.getByTestId("chat-add"));
|
||||
return onAttach;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
i18n.changeLanguage("en");
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("ChatAddMenu — existing resources", () => {
|
||||
it("searches the project, folders included, and attaches a pick as @path", async () => {
|
||||
const onAttach = renderMenu();
|
||||
fireEvent.mouseEnter(screen.getByTestId("chat-add-project"));
|
||||
fireEvent.click(await screen.findByTestId("chat-add-project-src/"));
|
||||
expect(api.run.files).toHaveBeenCalledWith("/repo", "", true);
|
||||
expect(onAttach).toHaveBeenCalledWith([{ path: "@src/", name: "src/" }]);
|
||||
});
|
||||
|
||||
it("offers the workspace's context files that are still there, files only", async () => {
|
||||
const onAttach = renderMenu();
|
||||
fireEvent.mouseEnter(screen.getByTestId("chat-add-context"));
|
||||
fireEvent.click(await screen.findByTestId("chat-add-context-brief.md"));
|
||||
expect(api.lanes.sources).toHaveBeenCalledWith(7);
|
||||
expect(screen.queryByTestId("chat-add-context-gone.md")).toBeNull();
|
||||
expect(onAttach).toHaveBeenCalledWith([{ path: "@/data/ctx/1/brief.md", name: "brief.md" }]);
|
||||
});
|
||||
|
||||
it("has no Context files without a workspace", () => {
|
||||
renderMenu(null);
|
||||
expect(screen.queryByTestId("chat-add-context")).toBeNull();
|
||||
});
|
||||
|
||||
it("lists recent uploads newest first and attaches one as it was sent", () => {
|
||||
rememberUploads([{ path: "/tmp/d/old.pdf", name: "old.pdf", image: false }]);
|
||||
rememberUploads([{ path: "/tmp/d/0123456789abcdef.png", name: "shot.png", image: true }]);
|
||||
const onAttach = renderMenu();
|
||||
fireEvent.mouseEnter(screen.getByTestId("chat-add-recent"));
|
||||
const rows = screen.getAllByTestId(/^chat-add-recent-/);
|
||||
expect(rows.map((r) => r.textContent)).toEqual(["shot.png", "old.pdf"]);
|
||||
fireEvent.click(rows[0]!);
|
||||
expect(onAttach).toHaveBeenCalledWith([
|
||||
{
|
||||
path: "/tmp/d/0123456789abcdef.png",
|
||||
name: "shot.png",
|
||||
url: "/api/run/drop/0123456789abcdef.png",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* @file ChatFilePreview.test.tsx
|
||||
* @description Where a Chat pane's file preview reads from: a file under the
|
||||
* pane's folder through the lane's git file read, a memory file under
|
||||
* `~/.claude` through the config pages' read, anything else by path only; and
|
||||
* the list dialog previewing the file picked in it.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { FileListModal, FilePreview, displayName } from "../ChatFilePreview";
|
||||
import { api } from "../../../lib/api";
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
lanes: {
|
||||
gitFile: vi.fn().mockResolvedValue({
|
||||
available: true,
|
||||
path: "src/a.ts",
|
||||
rev: "work",
|
||||
binary: false,
|
||||
truncated: false,
|
||||
lines: ["export const a = 1;"],
|
||||
}),
|
||||
gitRawUrl: () => "",
|
||||
},
|
||||
ccConfig: {
|
||||
file: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: true, file: "", text: "Launch notes", size: 12, mtime: 0 }),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const scope = { laneId: 4, laneCwd: "/w", cwd: "/w", sessionId: "s1" };
|
||||
|
||||
describe("FilePreview", () => {
|
||||
it("reads a workspace file through the lane", async () => {
|
||||
render(<FilePreview path="/w/src/a.ts" scope={scope} onClose={() => {}} />);
|
||||
expect(await screen.findByText("export const a = 1;")).toBeTruthy();
|
||||
expect(vi.mocked(api.lanes.gitFile)).toHaveBeenCalledWith(4, "src/a.ts");
|
||||
});
|
||||
|
||||
it("reads a memory file through the config read", async () => {
|
||||
const mem = "/h/.claude/projects/-w/memory/launch.md";
|
||||
render(<FilePreview path={mem} scope={scope} onClose={() => {}} />);
|
||||
expect(await screen.findByText("Launch notes")).toBeTruthy();
|
||||
expect(vi.mocked(api.ccConfig.file)).toHaveBeenCalledWith(mem);
|
||||
});
|
||||
|
||||
it("shows only the path of a file it may not read", () => {
|
||||
render(<FilePreview path="/etc/hosts" scope={scope} onClose={() => {}} />);
|
||||
expect(screen.getByText("No preview for this file here")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FileListModal", () => {
|
||||
it("previews the file picked in the list", async () => {
|
||||
const drop = "/tmp/agenthub-drops-1000/0123456789abcdef-minutes.xlsx";
|
||||
expect(displayName(drop)).toBe("minutes.xlsx");
|
||||
render(
|
||||
<FileListModal
|
||||
title="Uploads"
|
||||
paths={[drop, "/w/src/a.ts"]}
|
||||
scope={scope}
|
||||
onClose={() => {}}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("No preview for this file here")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "a.ts" }));
|
||||
expect(await screen.findByText("export const a = 1;")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* @file ChatOutline.test.tsx
|
||||
* @description The Chat pane's activity panel renders a session's outline the
|
||||
* way Claude Desktop does: progress with done/current items, outputs by name
|
||||
* with an EXT tag (a click opens that file), and "Used in this session" rows —
|
||||
* uploads, files read and memory open their list, skills link to the config.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ChatOutline } from "../ChatOutline";
|
||||
|
||||
const outline = {
|
||||
progress: [
|
||||
{ id: "1", subject: "Parse plan", status: "completed" },
|
||||
{ id: "2", subject: "Build report", status: "in_progress" },
|
||||
],
|
||||
outputs: ["/w/out/Progress report.xlsx"],
|
||||
files: ["/w/src/a.ts", "/w/src/b.ts", "/w/src/c.ts"],
|
||||
uploads: ["/tmp/agenthub-drops-1000/0123456789abcdef-minutes.xlsx"],
|
||||
memory: ["/h/.claude/projects/-w/memory/release.md"],
|
||||
skills: ["xlsx"],
|
||||
mcp: ["vercel_vercel"],
|
||||
};
|
||||
|
||||
describe("ChatOutline", () => {
|
||||
it("shows progress, outputs and what the session used", () => {
|
||||
const onClose = vi.fn();
|
||||
const onOpenFile = vi.fn();
|
||||
const onOpenList = vi.fn();
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ChatOutline
|
||||
outline={outline}
|
||||
onClose={onClose}
|
||||
onOpenFile={onOpenFile}
|
||||
onOpenList={onOpenList}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
expect(screen.getByText("Parse plan").className).toContain("line-through");
|
||||
expect(screen.getByText("Build report").className).not.toContain("line-through");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "XLSX: Progress report" }));
|
||||
expect(onOpenFile).toHaveBeenCalledWith("/w/out/Progress report.xlsx");
|
||||
|
||||
// An upload is named without the drop dir's random prefix.
|
||||
fireEvent.click(screen.getByTitle("Uploads: minutes.xlsx"));
|
||||
expect(onOpenList).toHaveBeenCalledWith("uploads");
|
||||
expect(screen.getByText("+1")).toBeTruthy(); // three files read, two named
|
||||
fireEvent.click(screen.getByTitle("Memory: release"));
|
||||
expect(onOpenList).toHaveBeenCalledWith("memory");
|
||||
expect(screen.getByTitle("Skills: xlsx").getAttribute("href")).toBe(
|
||||
"/settings/agents/claude-code?tab=skills"
|
||||
);
|
||||
expect(screen.getByText("vercel_vercel")).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Hide side panel" }));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("hides progress and the used section when there is none", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ChatOutline
|
||||
outline={{
|
||||
...outline,
|
||||
progress: [],
|
||||
files: [],
|
||||
uploads: [],
|
||||
memory: [],
|
||||
skills: [],
|
||||
mcp: [],
|
||||
}}
|
||||
onClose={() => {}}
|
||||
onOpenFile={() => {}}
|
||||
onOpenList={() => {}}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
expect(screen.queryByText("Progress")).toBeNull();
|
||||
expect(screen.queryByText("Used in this session")).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "Hide side panel" })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -7,13 +7,13 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { act, render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { LaneConsolePane } from "../LaneConsolePane";
|
||||
import { api } from "../../../lib/api";
|
||||
import type { Lane } from "../../../lib/types";
|
||||
import { __resetAiTools } from "../../../hooks/useAiTools";
|
||||
import { __resetChatUi } from "../../../lib/chatUi";
|
||||
import { VIEWS_KEY, __resetChatUi, saveChatUi, setSessionChat } from "../../../lib/chatUi";
|
||||
|
||||
vi.mock("../TerminalView", () => ({
|
||||
// The exit button stands in for the server's `exit` frame (a double Ctrl+C
|
||||
@@ -33,7 +33,10 @@ vi.mock("../PaneChat", () => ({
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: {
|
||||
me: { prefs: vi.fn().mockResolvedValue({ prefs: {}, onboardedAt: null }) },
|
||||
me: {
|
||||
prefs: vi.fn().mockResolvedValue({ prefs: {}, onboardedAt: null }),
|
||||
savePrefs: vi.fn(),
|
||||
},
|
||||
lanes: {
|
||||
ensure: vi.fn(),
|
||||
action: vi.fn(),
|
||||
@@ -171,7 +174,41 @@ describe("LaneConsolePane", () => {
|
||||
render(<LaneConsolePane {...baseProps()} activeRuns={{ items: [live] }} />);
|
||||
expect(await screen.findByTestId("pane-chat")).toHaveAttribute("data-session-id", "s-1");
|
||||
expect(screen.getByTestId("terminal-view")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("pane-view-chat")).toBeNull(); // no per-pane switch
|
||||
__resetChatUi();
|
||||
});
|
||||
|
||||
it("keeps each session's own view: the Chat UI setting is only a new session's default", async () => {
|
||||
vi.mocked(api.me.prefs).mockResolvedValueOnce({
|
||||
prefs: { "agenthub.chatUi": "1" },
|
||||
onboardedAt: null,
|
||||
});
|
||||
__resetChatUi();
|
||||
const live = {
|
||||
id: "agenthub-lane-1",
|
||||
laneId: 1,
|
||||
status: "running" as const,
|
||||
cwd: "/workspace/a",
|
||||
model: null,
|
||||
permissionMode: null,
|
||||
effort: null,
|
||||
resumeSessionId: null,
|
||||
sessionId: "s-1",
|
||||
startedAt: null,
|
||||
promptPreview: null,
|
||||
tool: "claude" as const,
|
||||
};
|
||||
render(<LaneConsolePane {...baseProps()} activeRuns={{ items: [live] }} />);
|
||||
expect(await screen.findByTestId("pane-chat")).toBeInTheDocument();
|
||||
// Seen once, the session keeps chat: turning the default off leaves it alone.
|
||||
await waitFor(() =>
|
||||
expect(JSON.parse(localStorage.getItem(VIEWS_KEY) || "{}")).toEqual({ "s-1": true })
|
||||
);
|
||||
vi.mocked(api.me.savePrefs).mockResolvedValueOnce({ prefs: {}, onboardedAt: null });
|
||||
await act(() => saveChatUi(false));
|
||||
expect(screen.getByTestId("pane-chat")).toBeInTheDocument();
|
||||
// Its own toggle switches it.
|
||||
act(() => setSessionChat("s-1", false));
|
||||
expect(screen.queryByTestId("pane-chat")).toBeNull();
|
||||
__resetChatUi();
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* @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.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PaneChat } from "../PaneChat";
|
||||
import { api } from "../../../lib/api";
|
||||
import { interruptRun, submitToRun } 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 }: { onOpenFile?: (path: string) => 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")} />
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("../../../lib/panePaste", () => ({
|
||||
submitToRun: vi.fn(() => true),
|
||||
interruptRun: 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: [],
|
||||
}),
|
||||
},
|
||||
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());
|
||||
|
||||
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 — 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 () => {
|
||||
localStorage.removeItem("agenthub.chat.outlineOpen");
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<PaneChat run={RUN} sessionId="s1" cwd="/w" laneId={7} laneCwd="/w" outline />
|
||||
</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();
|
||||
expect(localStorage.getItem("agenthub.chat.outlineOpen")).toBe("1");
|
||||
});
|
||||
});
|
||||
|
||||
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" outline />
|
||||
</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);
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,10 @@
|
||||
* @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 `ModelPicker` — a tool's own
|
||||
* 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,
|
||||
@@ -13,17 +16,19 @@
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { fireEvent, render, screen } from "@testing-library/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>>();
|
||||
@@ -158,6 +163,34 @@ describe("PromptEditor — / and @ suggestions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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");
|
||||
@@ -202,7 +235,8 @@ describe("PromptEditor — newlines and images", () => {
|
||||
expect(screen.getByTestId("sent").textContent).toBe("see /tmp/agenthub-drops-1/a.png");
|
||||
|
||||
fireEvent.click(thumb);
|
||||
expect(screen.getByRole("dialog", { name: "shot.png" })).toBeTruthy();
|
||||
// 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();
|
||||
|
||||
@@ -212,6 +246,38 @@ describe("PromptEditor — newlines and images", () => {
|
||||
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() {
|
||||
@@ -237,13 +303,70 @@ describe("PromptEditor — newlines and images", () => {
|
||||
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: [] } });
|
||||
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", () => {
|
||||
|
||||
Reference in New Issue
Block a user