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