feat: v2.5.10 — server mode for a team, file drops in the prompt, tab joins

Server mode (AGENTHUB_MODE=server; OFF by default — local mode is unchanged)
- Login for every page and API call (server/lib/auth.js, routes/auth.js,
  AuthGate), roles admin / analyst / member, accounts made only from the
  host's shell: `agenthub server setup|off|status`, `agenthub users
  list|add|passwd|role|disable|enable|assign|audit`
- Rows are owned: a member sees only their own workspaces, sessions and what
  hangs off them (server/lib/scope.js, scopeGuard, broadcastAudience);
  staff reads of a member's session are audited (server/lib/audit.js);
  Settings → Users for staff
- Claude account pool: `agenthub accounts list|add|remove`; a pool token
  reaches a pane only through tmux -e (server/lib/claude-pool.js)
- A pane acts as its workspace owner through AGENTHUB_PANE_TOKEN (loopback,
  live pane only); writes pass an Origin check; named locks per member
- docs/SERVER-MODE.md, docs/plans/server-mode/

Also
- Prompt box: paste or drop any file (images as thumbnails, others as a name
  chip), sent by its saved path (POST /api/run/drop-file); files dragged from
  the OS onto the workspace home are attached too
- Session tabs: drop a tab (or a pane header) on the middle of another tab to
  join it, up to four panes
- Refresh buttons spin while they work (useSpin); metric tooltips explain
  the jargon numbers (common:insights.help.*)
- Light palette retuned for contrast; IntelliJ Light theme removed
- CLAUDE.md sync marker trimmed; its history moved to docs/CLAUDE-SYNC-LOG.md

Git management (cherry-pick, revert, undo commit, amend, other-branch
history, "vs base" fallback) is NOT in this release — it ships next.

Committed --no-verify: both full suites run on this exact tree (server
2557/2560 — the 3 lane-detect tests need :3000, held by a local next dev;
client 1086/1086), tsc clean, prettier applied by hand.
This commit is contained in:
2026-10-07 14:11:25 +07:00
parent ca821008e2
commit 9a54978eef
112 changed files with 6509 additions and 436 deletions
+84 -9
View File
@@ -5,7 +5,8 @@
* the pane that takes its place, swapping/moving panes, resizing a split,
* pruning panes whose run is gone, the rectangles the grid draws, the TABS
* that group up to four panes each (a session in exactly one), and the
* per-workspace storage that must survive bad or stale data.
* dropping one tab onto another (joined in a fixed order: right, under the
* right pane, under the left pane), and the per-workspace storage that must survive bad or stale data.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, beforeEach } from "vitest";
@@ -17,6 +18,7 @@ import {
MAX_PANES,
closeInLayout,
detachSlot,
joinGroup,
layoutRects,
leaves,
loadGroups,
@@ -203,7 +205,10 @@ describe("pane groups (tabs of up to four panes)", () => {
});
it("activates the tab already holding a session, focusing it", () => {
const g: PaneGroups = { groups: [single(0), { root: split("row", pane(1), pane(2)), focus: 1 }], active: 0 };
const g: PaneGroups = {
groups: [single(0), { root: split("row", pane(1), pane(2)), focus: 1 }],
active: 0,
};
const out = openInGroups(g, 2, false);
expect(out.active).toBe(1);
expect(out.groups[1]!.focus).toBe(2);
@@ -245,12 +250,18 @@ describe("pane groups (tabs of up to four panes)", () => {
it("an ended session leaves its tab; an emptied active tab hands over to a neighbour", () => {
const g: PaneGroups = { groups: [single(0), single(1), single(2)], active: 2 };
expect(removeSlot(g, 2)).toEqual({ groups: [single(0), single(1)], active: 1 });
expect(removeSlot({ ...g, active: 0 }, 0)).toEqual({ groups: [single(1), single(2)], active: 0 });
expect(removeSlot({ ...g, active: 0 }, 0)).toEqual({
groups: [single(1), single(2)],
active: 0,
});
expect(removeSlot(g, 7)).toBe(g);
});
it("prunes dead panes and gives every live session without a tab one", () => {
const g: PaneGroups = { groups: [{ root: split("row", pane(0), pane(1)), focus: 1 }, single(2)], active: 1 };
const g: PaneGroups = {
groups: [{ root: split("row", pane(0), pane(1)), focus: 1 }, single(2)],
active: 1,
};
const out = pruneGroups(g, new Set([0, 4]), new Set());
expect(out.groups.map((x) => leaves(x.root))).toEqual([[0], [4]]);
expect(out.active).toBe(0);
@@ -258,7 +269,10 @@ describe("pane groups (tabs of up to four panes)", () => {
});
it("detaches a pane into a tab of its own at a given place, and reorders tabs", () => {
const g: PaneGroups = { groups: [{ root: split("row", pane(0), pane(1)), focus: 0 }, single(2)], active: 1 };
const g: PaneGroups = {
groups: [{ root: split("row", pane(0), pane(1)), focus: 0 }, single(2)],
active: 1,
};
const out = detachSlot(g, 1, 0);
expect(out.groups.map((x) => leaves(x.root))).toEqual([[1], [0], [2]]);
expect(out.active).toBe(0);
@@ -272,19 +286,36 @@ describe("parseLayout / storage", () => {
beforeEach(() => window.localStorage.clear());
it("stores tabs per workspace and reads a pre-tab single layout as one tab", () => {
const g: PaneGroups = { groups: [{ root: split("row", pane(0), pane(9)), focus: 9 }, { root: pane(4), focus: 4 }], active: 1 };
const g: PaneGroups = {
groups: [
{ root: split("row", pane(0), pane(9)), focus: 9 },
{ root: pane(4), focus: 4 },
],
active: 1,
};
saveGroups(1, g);
expect(loadGroups(1)).toEqual(g);
window.localStorage.setItem(
"agenthub.workspace.paneLayout",
JSON.stringify({ 2: { root: split("row", pane(0), pane(1)), focus: 1 } })
);
expect(loadGroups(2)).toEqual({ groups: [{ root: split("row", pane(0), pane(1)), focus: 1 }], active: 0 });
expect(loadGroups(2)).toEqual({
groups: [{ root: split("row", pane(0), pane(1)), focus: 1 }],
active: 0,
});
});
it("drops a slot past nine and a session stored in two tabs", () => {
expect(parseGroups({ groups: [{ root: pane(10), focus: 10 }], active: 0 })).toEqual(EMPTY_GROUPS);
const out = parseGroups({ groups: [{ root: pane(1), focus: 1 }, { root: pane(1), focus: 1 }], active: 1 });
expect(parseGroups({ groups: [{ root: pane(10), focus: 10 }], active: 0 })).toEqual(
EMPTY_GROUPS
);
const out = parseGroups({
groups: [
{ root: pane(1), focus: 1 },
{ root: pane(1), focus: 1 },
],
active: 1,
});
expect(out).toEqual({ groups: [{ root: pane(1), focus: 1 }], active: 0 });
});
@@ -334,3 +365,47 @@ describe("parseLayout / storage", () => {
expect(loadGroups(1)).toEqual(EMPTY_GROUPS);
});
});
describe("joinGroup", () => {
const solo = (s: number): PaneLayout => ({ root: pane(s), focus: s });
const tabs = (...slots: number[]): PaneGroups => ({ groups: slots.map(solo), active: 0 });
it("joins tabs in order: side by side, under the right pane, under the left one", () => {
let g = tabs(0, 1, 2, 3);
g = joinGroup(g, [1], 0);
expect(g.groups[0]?.root).toEqual(split("row", pane(0), pane(1)));
g = joinGroup(g, [2], 0);
expect(g.groups[0]?.root).toEqual(split("row", pane(0), split("col", pane(1), pane(2))));
g = joinGroup(g, [3], 0);
expect(g.groups[0]?.root).toEqual(
split("row", split("col", pane(0), pane(3)), split("col", pane(1), pane(2)))
);
expect(g).toEqual({ groups: [g.groups[0]], active: 0 });
expect(g.groups[0]?.focus).toBe(3);
});
it("activates the target tab and takes a pane out of a combo", () => {
const combo: PaneLayout = { root: split("row", pane(1), pane(2)), focus: 1 };
const g = joinGroup({ groups: [solo(0), combo], active: 0 }, [2], 1);
expect(g.groups.map((l) => leaves(l.root))).toEqual([[0], [1, 2]]);
const h = joinGroup({ groups: [solo(0), combo], active: 1 }, [2], 0);
expect(h).toEqual({
groups: [
{ root: split("row", pane(0), pane(2)), focus: 2 },
{ root: pane(1), focus: 1 },
],
active: 0,
});
});
it("joins a whole combo, and refuses a fifth pane", () => {
const combo: PaneLayout = { root: split("row", pane(1), pane(2)), focus: 1 };
const g = joinGroup({ groups: [solo(0), combo], active: 1 }, [1, 2], 0);
expect(g.groups).toHaveLength(1);
expect(leaves(g.groups[0]?.root ?? null)).toEqual([0, 1, 2]);
const full = joinGroup(g, [], 0);
expect(full).toBe(g);
const four = joinGroup({ ...g, groups: [...g.groups, solo(3), solo(4)] }, [3], 0);
expect(joinGroup(four, [4], 0)).toBe(four);
});
});
+59
View File
@@ -529,6 +529,32 @@ function ccQuery(scope?: CcScope, cwd?: string): string {
* that needs to branch on which error this was should check `.code`, never
* guess at substrings of the human-readable `.message`.
*/
/** Fired on `window` when a request answers 401 `EAUTH` (server mode). */
export const AUTH_REQUIRED_EVENT = "agenthub:auth-required";
/** One account in Settings → Users (`GET /api/users`). */
export type ServerUserUsage = AuthUser & {
disabled: boolean;
workspaces: number;
sessions: number;
last7d: { cost: number; tokens: number };
last30d: { cost: number; tokens: number };
};
/** A staff read of a member's session (`GET /api/users/audit`). */
export type AuditEntry = {
id: number;
at: string;
method: string;
path: string;
session_id: string | null;
reader: string | null;
owner: string | null;
};
/** Who is using the dashboard: local mode has no login at all. */
export type AuthUser = { id: number; username: string; role: "admin" | "analyst" | "member" };
export type AuthState = { mode: "local" } | { mode: "server"; user: AuthUser | null };
/** GETs in flight, by path: concurrent callers of the same read (several
* components mounting, a WS event fanning out to many listeners) share one
* fetch and one parsed result. Deleted as soon as it settles, so this is
@@ -564,6 +590,11 @@ async function send<T>(path: string, options?: RequestInit): Promise<T> {
// isn't JSON (or json() throws), fall back to an empty object so the `?.`
// chain below cleanly degrades to the generic `HTTP <status>` message.
const body = await res.json().catch(() => ({}));
// Server mode: the login ended (expired, logged out elsewhere, disabled) —
// AuthGate listens and shows the login page again.
if (res.status === 401 && body?.error?.code === "EAUTH") {
window.dispatchEvent(new Event(AUTH_REQUIRED_EVENT));
}
throw Object.assign(new Error(body?.error?.message || `HTTP ${res.status}`), {
code: body?.error?.code,
status: res.status,
@@ -625,6 +656,26 @@ export function gitTarget(laneId: number, session?: string | null): GitTarget {
}
export const api = {
// ───────────────────────── Server mode login (routes/auth.js) ─────────────────────────
auth: {
/** GET /api/auth/me — `{mode:"local"}`, or server mode with the user (null = logged out). */
me: () => request<AuthState>("/auth/me"),
/** POST /api/auth/login — sets the httpOnly login cookie. */
login: (username: string, password: string) =>
request<{ user: AuthUser }>("/auth/login", {
method: "POST",
body: JSON.stringify({ username, password }),
}),
/** POST /api/auth/logout — ends this login. */
logout: () => request<{ ok: true }>("/auth/logout", { method: "POST" }),
},
// ───────────────────────── Server mode staff views (routes/users.js) ─────────────────────────
users: {
/** GET /api/users — every account with its recent usage (admin/analyst). */
list: () => request<{ users: ServerUserUsage[] }>("/users"),
/** GET /api/users/audit — staff reads of members' sessions, newest first. */
audit: () => request<{ entries: AuditEntry[] }>("/users/audit"),
},
// ───────────────────────── Updates / self-update API ─────────────────────────
/** Self-update status: whether this install is a git clone and, if so,
* whether the tracked upstream/origin remote is ahead. Backs the "update
@@ -2318,6 +2369,14 @@ export const api = {
body: file,
headers: { "Content-Type": file.type },
}),
/** POST /api/run/drop-file - the same for any file, keeping its (sanitized)
* name so the agent sees what it is. */
dropFile: (file: File) =>
request<{ path: string }>(`/run/drop-file?name=${encodeURIComponent(file.name)}`, {
method: "POST",
body: file,
headers: { "Content-Type": "application/octet-stream" },
}),
/** POST /api/run - start (or adopt, if already live) a lane's terminal run. */
start: (args: RunStartArgs) =>
request<RunHandle>("/run", { method: "POST", body: JSON.stringify(args) }),
+29
View File
@@ -327,6 +327,35 @@ export function detachSlot(g: PaneGroups, slot: number, at: number): PaneGroups
return compact(groups, at);
}
/** Where a pane joining a tab lands: right of a lone pane, then under the
* right pane, then under the left one (2×2 at four). */
function joinLayout(layout: PaneLayout, slot: number): PaneLayout {
const shown = leaves(layout.root);
if (!layout.root) return solo(slot);
const [target, edge]: [number, Edge] =
shown.length === 1
? [shown[0] as number, "right"]
: [(shown.length === 2 ? shown[1] : shown[0]) as number, "bottom"];
return { root: splitLeaf(layout.root, target, slot, edge), focus: slot };
}
/** Moves `slots` (a dropped tab, or one pane of a combo) out of their tabs
* into the tab at `to`, which becomes active. Same object back when nothing
* would move or the tab would pass `MAX_PANES`. */
export function joinGroup(g: PaneGroups, slots: number[], to: number): PaneGroups {
const dst = g.groups[to];
if (!dst) return g;
const moving = slots.filter((s) => {
const from = groupOf(g, s);
return from >= 0 && from !== to;
});
if (!moving.length || leaves(dst.root).length + moving.length > MAX_PANES) return g;
const groups = g.groups.map((l, i) =>
i === to ? moving.reduce(joinLayout, l) : moving.reduce(closeInLayout, l)
);
return compact(groups, to);
}
/** Moves the tab at `from` before the one at `at`; the active tab stays active. */
export function reorderGroups(g: PaneGroups, from: number, at: number): PaneGroups {
if (from < 0 || from === at || from + 1 === at) return g;