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,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);
});
});