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,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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user