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();
});
});
@@ -103,6 +103,8 @@ vi.mock("../../lib/api", async (importOriginal) => {
api: {
aiTools: { iconUrl: (id: string) => `/api/ai-tools/${id}/icon` },
lanes: {
// The Changes panel's folder picker reads the workspace's extra folders.
sources: vi.fn().mockResolvedValue({ sources: [] }),
list: vi.fn().mockImplementation(async () => {
recordCall("GET", "/api/lanes");
return { lanes: lanesToReturn, counts: countsToReturn };
@@ -434,15 +436,10 @@ describe("Workspace page — lane integration", () => {
});
describe("Workspace layout", () => {
it("opens the add-workspace modal from the empty banner when there is none", async () => {
const user = userEvent.setup();
it("shows no empty banner when there is no workspace (the sidebar's + adds one)", async () => {
lanesToReturn = [];
await renderWorkspace();
await user.click(screen.getByTestId("lane-empty"));
expect(
await screen.findByText("Create a workspace from a working directory")
).toBeInTheDocument();
expect(screen.queryByTestId("lane-empty")).toBeNull();
});
it("shows the full card only for the selected lane, in the detail panel", async () => {
@@ -5563,82 +5563,6 @@ exports[`screen snapshots > Run 1`] = `
<div
class="flex min-h-0 flex-1 flex-col gap-4"
>
<button
class="card flex w-full shrink-0 flex-wrap items-center gap-4 border-dashed px-5 py-4 text-left transition-colors hover:border-accent/50 hover:bg-surface-2"
data-testid="lane-empty"
type="button"
>
<span
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-surface-4 text-fg-muted"
>
<svg
class="lucide lucide-layers h-5 w-5"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z"
/>
<path
d="M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12"
/>
<path
d="M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17"
/>
</svg>
</span>
<div
class="min-w-0 flex-1"
>
<span
class="block text-sm font-medium text-fg-primary"
>
No workspaces yet
</span>
<p
class="mt-0.5 text-xs text-fg-muted"
>
A workspace is one working directory the dashboard follows — its pipeline, sessions and terminal. Click anywhere on this card to add one, or from a terminal:
<code
class="rounded bg-surface-2 px-1.5 py-0.5 text-[11px] text-fg-secondary"
>
agenthub workspace add --cwd $(pwd)
</code>
</p>
</div>
<span
class="flex shrink-0 items-center gap-1.5 text-xs font-medium text-accent"
>
<svg
class="lucide lucide-plus h-4 w-4"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5 12h14"
/>
<path
d="M12 5v14"
/>
</svg>
Add workspace
</span>
</button>
<div
class="relative flex min-h-0 flex-1"
>
@@ -5662,6 +5586,7 @@ exports[`screen snapshots > Run 1`] = `
<div
class="absolute flex flex-col"
data-focused="true"
data-menu-bounds="true"
data-testid="pane-0"
style="left: 0%; top: 0%; width: 100%; height: 100%;"
>
@@ -7249,7 +7174,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<span
class="text-status-warning/90"
>
UI build v2.5.13 (rebuild client to match)
UI build v2.6.0 (rebuild client to match)
</span>
</p>
</div>