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:
2026-10-09 15:51:19 +07:00
parent ce773a3f5a
commit fa156d6a59
114 changed files with 5831 additions and 885 deletions
@@ -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", () => {