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
@@ -1,7 +1,8 @@
/**
* @file Workspace.changesPanel.test.tsx
* @description The Changes panel is ONE control: it steps 30/70 → 50/50 →
* full → closed, and the rail reopens it at 30/70. The width survives a reload.
* @description The Changes panel has two controls: Full screen (Restore at
* full screen, back to the last dragged width) and Collapse; the rail reopens
* it at the last dragged width, else 25/75. The width survives a reload.
* All of it lives in localStorage and reaches no server — the same rule as the
* lane strip's order.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -82,7 +83,22 @@ vi.mock("../../lib/api", async (importOriginal) => {
}),
pipelines: r({ pipelines: [] }),
git: r({ available: false }),
// Workspace 1 has one extra folder, its own repo (`?source=`).
sources: vi.fn((id: number) =>
Promise.resolve({
sources:
id === 1
? [
{ id: 7, kind: "folder", path: "/x/shop", name: "shop", missing: false },
{ id: 8, kind: "file", path: "/d/notes.md", name: "notes.md", missing: false },
]
: [],
})
),
gitTree: r({ entries: [] }),
// A file opened in the panel on a chat's request.
changes: r({ available: false }),
gitFile: r({ available: false }),
runtime: r({ up: false, ports: {}, lastError: null }),
features: { list: r({ features: [] }), show: r({ feature: null }) },
proof: { list: r({ features: [] }), imageUrl: () => "" },
@@ -104,6 +120,8 @@ vi.mock("../../lib/eventBus", () => ({
}));
import { Workspace } from "../Workspace";
import { openInChanges } from "../../lib/openInChanges";
import { api } from "../../lib/api";
class ObserverStub {
observe() {}
@@ -150,43 +168,115 @@ describe("Workspace — the Changes panel's width", () => {
}
}
it("steps 30/70 → 50/50 → full → closed on one control, with no 70/30 step", async () => {
it("offers Full screen and Collapse, then Restore and Collapse at full screen", async () => {
await openPanel();
const card = () => screen.getByTestId("lane-changes-card");
expect(card().className).toContain("w-[30%]");
fireEvent.click(screen.getByTestId("lane-changes-resize"));
expect(card().className).toContain("w-1/2");
const size = () => screen.getByTestId("lane-changes-resize");
expect(card().className).toContain("w-[25%]");
expect(size()).toHaveAttribute("aria-label", "Full screen");
expect(screen.getByTestId("lane-changes-collapse")).toBeInTheDocument();
// Full width HIDES the console beside it — hidden, still mounted.
fireEvent.click(screen.getByTestId("lane-changes-resize"));
fireEvent.click(size());
expect(card().className).toContain("w-full");
expect(screen.getByTestId("lane-detail").className).toContain("hidden");
expect(size()).toHaveAttribute("aria-label", "Restore size");
expect(screen.getByTestId("lane-changes-collapse")).toBeInTheDocument();
// Past the widest step the panel closes — and reopens at 30/70, not back
// at full width, which is why closing resets the width.
fireEvent.click(screen.getByTestId("lane-changes-resize"));
// Restore brings the console back beside it.
fireEvent.click(size());
expect(card().className).toContain("w-[25%]");
expect(screen.getByTestId("lane-detail").className).not.toContain("hidden");
// Collapsing from full screen reopens at 25/75, not back at full width.
fireEvent.click(size());
fireEvent.click(screen.getByTestId("lane-changes-collapse"));
await settle();
expect(screen.queryByTestId("lane-changes-card")).toBeNull();
fireEvent.click(screen.getByTestId("lane-changes-expand"));
await settle();
expect(card().className).toContain("w-[30%]");
expect(card().className).toContain("w-[25%]");
});
it("remembers the width across a reload, and never asks the server", async () => {
await openPanel();
fireEvent.click(screen.getByTestId("lane-changes-resize"));
expect(window.localStorage.getItem("agenthub.workspace.changesSize")).toBe("half");
expect(window.localStorage.getItem("agenthub.workspace.changesSize")).toBe("full");
cleanup();
await openPanel();
expect(screen.getByTestId("lane-changes-card").className).toContain("w-1/2");
expect(screen.getByTestId("lane-changes-card").className).toContain("w-full");
});
it("keeps the rail as the only way back in", async () => {
it("restores, and reopens, at the width last dragged to, not the preset", async () => {
window.localStorage.setItem("agenthub.workspace.changesSize", "full");
window.localStorage.setItem("agenthub.workspace.changesDragged", "0.4");
await openPanel();
expect(screen.queryByTestId("lane-changes-collapse")).toBeNull();
fireEvent.click(screen.getByTestId("lane-changes-resize"));
expect(screen.getByTestId("lane-changes-card").style.width).toBe("40%");
fireEvent.click(screen.getByTestId("lane-changes-resize"));
fireEvent.click(screen.getByTestId("lane-changes-collapse"));
await settle();
fireEvent.click(screen.getByTestId("lane-changes-expand"));
await settle();
expect(screen.getByTestId("lane-changes-card").style.width).toBe("40%");
});
it("opens on a chat's request for a file of the workspace it shows, not another's", async () => {
window.localStorage.setItem("agenthub.workspace.changesOpen", "0");
render(
<MemoryRouter initialEntries={["/workspace?lane=1"]}>
<Workspace />
</MemoryRouter>
);
await settle();
expect(screen.queryByTestId("lane-changes-card")).toBeNull();
act(() => {
openInChanges(2, "/workspace/beta", "/workspace/beta/a.ts");
});
await settle();
expect(screen.queryByTestId("lane-changes-card")).toBeNull();
act(() => {
openInChanges(1, "/workspace/alpha", "/workspace/alpha/src/a.ts");
});
await settle();
expect(screen.getByTestId("lane-changes-card")).toBeInTheDocument();
expect(api.lanes.gitFile).toHaveBeenCalledWith(1, "src/a.ts", "work");
});
it("shows an extra folder's repo when picked, and only a workspace's own folders", async () => {
await openPanel();
const pick = screen.getByTestId("panel-git-folder") as HTMLSelectElement;
// The workspace folder, then its folders — never a context file.
expect([...pick.options].map((o) => o.text)).toEqual(["alpha", "shop"]);
vi.mocked(api.lanes.git).mockClear();
fireEvent.change(pick, { target: { value: "7" } });
await settle();
expect(api.lanes.git).toHaveBeenCalledWith({ lane: 1, source: 7 });
// A chat's file is the workspace folder's: the panel goes back there.
act(() => {
openInChanges(1, "/workspace/alpha", "/workspace/alpha/src/b.ts");
});
await settle();
expect(pick.value).toBe("");
expect(api.lanes.gitFile).toHaveBeenCalledWith(1, "src/b.ts", "work");
});
it("has no folder picker for a workspace with no extra folders", async () => {
render(
<MemoryRouter initialEntries={["/workspace?lane=2"]}>
<Workspace />
</MemoryRouter>
);
await settle();
expect(screen.queryByTestId("panel-git-folder")).toBeNull();
});
it("collapses from its narrow width too, back to the rail", async () => {
await openPanel();
fireEvent.click(screen.getByTestId("lane-changes-collapse"));
await settle();
expect(screen.queryByTestId("lane-changes-card")).toBeNull();
expect(screen.getByTestId("lane-changes-expand")).toBeInTheDocument();
});
});