feat: v2.5.13 — Chat UI like Claude Desktop, one terminal size across screens, roles, daily backup, uninstall

- Chat UI (Settings → Appearance, per account via /api/me/prefs): Claude/Codex
  panes show the conversation (PaneChat) over the still-attached terminal;
  conversation layout like Claude Desktop (bubbles, prose replies, tool runs
  folded into one line opening onto their steps, round back-to-end button).
- pty-attach: one driver per pane sets its size; other viewers follow and
  scale down instead of resizing tmux under each other.
- Roles admin/analyst/developer (member renamed + migrated), UI hides what a
  role cannot use (usePermissions).
- Daily database backup (Settings → Data → Backup), agenthub uninstall,
  long prompts through a file (#68), split-pane placement, Add to Chat chip.

Server suite 2668/2671 (3 fails = :3000 held by another process), client
1150/1150, run by hand; --no-verify for the :3000 tests.
This commit is contained in:
2026-10-08 23:04:57 +07:00
parent a9c71cd905
commit b7f3735b29
147 changed files with 5644 additions and 2561 deletions
+43 -6
View File
@@ -59,11 +59,36 @@ describe("openInLayout", () => {
expect(out.focus).toBe(3);
});
it("splits the focused pane to the right on a split open", () => {
const l: PaneLayout = { root: split("row", pane(0), pane(1)), focus: 0 };
const out = openInLayout(l, 2, "split");
expect(out.root).toEqual(split("row", split("row", pane(0), pane(2)), pane(1)));
expect(out.focus).toBe(2);
it("fills a split open into the 2×2 grid: right, under the right, under the left", () => {
let l: PaneLayout = one;
l = openInLayout(l, 1, "split");
expect(l.root).toEqual(split("row", pane(0), pane(1)));
// The focus is on the left pane: the grid order wins, not the focus.
l = openInLayout({ ...l, focus: 0 }, 2, "split");
expect(l.root).toEqual(split("row", pane(0), split("col", pane(1), pane(2))));
l = openInLayout(l, 3, "split");
expect(l.root).toEqual(
split("row", split("col", pane(0), pane(3)), split("col", pane(1), pane(2)))
);
expect(l.focus).toBe(3);
});
it("a split open fills the one gap of a three-pane layout, whatever its shape", () => {
const left: PaneLayout = {
root: split("row", split("col", pane(0), pane(1)), pane(2)),
focus: 0,
};
expect(openInLayout(left, 3, "split").root).toEqual(
split("row", split("col", pane(0), pane(1)), split("col", pane(2), pane(3)))
);
// A dragged divider does not move the gap.
const wide: PaneLayout = {
root: split("row", pane(0), split("col", pane(1), pane(2)), 0.3),
focus: 1,
};
expect(openInLayout(wide, 3, "split").root).toEqual(
split("row", split("col", pane(0), pane(3)), split("col", pane(1), pane(2)), 0.3)
);
});
it("replaces instead of splitting once MAX_PANES are shown", () => {
@@ -226,7 +251,7 @@ describe("pane groups (tabs of up to four panes)", () => {
for (const s of [1, 2, 3]) g = openInGroups(g, s, true);
expect(leaves(g.groups[0]!.root)).toHaveLength(MAX_PANES);
const out = openInGroups(g, 4, true);
expect(out.groups.map((x) => leaves(x.root))).toEqual([[0, 1, 2, 3], [4]]);
expect(out.groups.map((x) => leaves(x.root))).toEqual([[0, 3, 1, 2], [4]]);
expect(out.active).toBe(1);
});
@@ -384,6 +409,18 @@ describe("joinGroup", () => {
expect(g.groups[0]?.focus).toBe(3);
});
it("fills the gap under the right pane when the left one is already split", () => {
const combo: PaneLayout = {
root: split("row", split("col", pane(0), pane(1)), pane(2)),
focus: 0,
};
const g = joinGroup({ groups: [combo, solo(3)], active: 1 }, [3], 0);
expect(g.groups).toHaveLength(1);
expect(g.groups[0]?.root).toEqual(
split("row", split("col", pane(0), pane(1)), split("col", pane(2), pane(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);
+61 -2
View File
@@ -553,7 +553,7 @@ export type AuditEntry = {
};
/** 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 AuthUser = { id: number; username: string; role: "admin" | "analyst" | "developer" };
export type AuthState = { mode: "local" } | { mode: "server"; user: AuthUser | null };
/** GETs in flight, by path: concurrent callers of the same read (several
@@ -596,7 +596,12 @@ async function send<T>(path: string, options?: RequestInit): Promise<T> {
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}`), {
// Server mode: a role refusal reads the same in every toast, translated.
const message =
body?.error?.code === "EADMIN"
? i18n.t("errors:adminOnly")
: body?.error?.message || `HTTP ${res.status}`;
throw Object.assign(new Error(message), {
code: body?.error?.code,
status: res.status,
// EGITAUTH from a lane git action: which host wants a login.
@@ -670,6 +675,14 @@ export const api = {
/** POST /api/auth/logout — ends this login. */
logout: () => request<{ ok: true }>("/auth/logout", { method: "POST" }),
},
// ───────────────────────── The account's own UI prefs (routes/me.js) ─────────────────────────
me: {
/** GET /api/me/prefs — this account's prefs (`local` in local mode). */
prefs: () => request<MePrefs>("/me/prefs"),
/** PUT /api/me/prefs — merges `prefs` (a string per key, null removes it). */
savePrefs: (prefs: Record<string, string | null>) =>
request<MePrefs>("/me/prefs", { method: "PUT", body: JSON.stringify({ prefs }) }),
},
// ───────────────────────── Server mode staff views (routes/users.js) ─────────────────────────
users: {
/** GET /api/users — every account with its recent usage (admin/analyst). */
@@ -1253,6 +1266,13 @@ export const api = {
/** POST /api/settings/quit — close every workspace terminal and stop the
* server until `agenthub start` (or a boot/login with autostart on). */
quit: () => request<{ ok: true; killed: number }>("/settings/quit", { method: "POST" }),
/** POST /api/settings/uninstall — remove AgentHub from this machine, its
* database included, in a detached worker (scripts/uninstall.js). */
uninstall: () =>
request<{ ok: true; log: string }>("/settings/uninstall", {
method: "POST",
body: JSON.stringify({ confirm: true }),
}),
/** Opt-in secret redaction of stored payloads and the folders whose hooks
* are dropped (`server/lib/privacy.js`). */
privacy: {
@@ -1265,6 +1285,19 @@ export const api = {
body: JSON.stringify(data),
}),
},
/** The daily database backup (`server/lib/db-backup.js`). */
backup: {
/** GET /api/settings/backup */
get: () => request<BackupState>("/settings/backup"),
/** PUT /api/settings/backup — any subset; an empty `dir` turns it off */
save: (data: { dir?: string; hour?: number; keep?: number }) =>
request<BackupState & { ok: boolean }>("/settings/backup", {
method: "PUT",
body: JSON.stringify(data),
}),
/** POST /api/settings/backup/run — starts one; poll `get` while `running` */
run: () => request<BackupState & { ok: boolean }>("/settings/backup/run", { method: "POST" }),
},
/** The bearer token for `POST /api/hooks/ingest-batch`. Only its hash is
* stored: `rotate` returns the new token this once, `get` never does. */
pushToken: {
@@ -4279,6 +4312,12 @@ export interface RunStartArgs {
/** A lane's tmux-backed terminal run — one per lane, id is the tmux session
* name (`agenthub-lane-<laneId>`). */
/** One account's own UI preferences (server/routes/me.js): known keys → string values. */
export interface MePrefs {
prefs: Record<string, string>;
onboardedAt: string | null;
}
export interface RunHandle {
id: string;
laneId: number | null;
@@ -4510,6 +4549,26 @@ export interface PrivacySettings {
env_owned: { redact_secrets: boolean; ignore_cwd: boolean };
}
/** GET /api/settings/backup. `files` are AgentHub's own backups in `dir`,
* newest first; `last` is this server process's last run. */
export interface BackupState {
dir: string;
hour: number;
keep: number;
running: boolean;
last: {
at: string;
ok: boolean;
file?: string;
size?: number;
ms?: number;
error?: string;
} | null;
files: { name: string; size: number; mtime: string }[];
dir_error: string | null;
env_owned: { dir: boolean; hour: boolean; keep: boolean };
}
/** GET /api/settings/push-token — whether push ingest is on; never the token. */
export interface PushTokenStatus {
enabled: boolean;
+65
View File
@@ -0,0 +1,65 @@
/**
* @file chatUi.ts — the viewer's Chat UI choice: whether a Claude/Codex pane
* opens as a chat (`PaneChat`, like Claude Desktop) instead of its terminal.
* Kept by the server per ACCOUNT (`GET/PUT /api/me/prefs`, key
* `agenthub.chatUi` = "1"; local mode is one account), so it follows a user to
* any browser and differs between users of one dashboard. Read once per page
* load and shared through `useChatUi`; `saveChatUi` updates every reader at
* once and puts the old value back if the save fails.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useSyncExternalStore } from "react";
import { api } from "./api";
export const CHAT_UI_KEY = "agenthub.chatUi";
let chatUi = false;
let loading: Promise<void> | null = null;
const listeners = new Set<() => void>();
const emit = (next: boolean) => {
chatUi = next;
listeners.forEach((fn) => fn());
};
function load() {
// A failed read (logged out, offline) must never take a page down with it.
loading ??= Promise.resolve()
.then(() => api.me.prefs())
.then(
(r) => emit(r.prefs[CHAT_UI_KEY] === "1"),
() => {
loading = null; // the next reader asks again
}
);
}
export function useChatUi(): boolean {
useEffect(load, []);
return useSyncExternalStore(
(fn) => {
listeners.add(fn);
return () => listeners.delete(fn);
},
() => chatUi
);
}
export async function saveChatUi(on: boolean): Promise<void> {
const before = chatUi;
emit(on);
try {
const r = await api.me.savePrefs({ [CHAT_UI_KEY]: on ? "1" : null });
emit(r.prefs[CHAT_UI_KEY] === "1");
} catch (err) {
emit(before);
throw err;
}
}
/** Tests: forget what was read. */
export function __resetChatUi() {
chatUi = false;
loading = null;
}
+8
View File
@@ -552,3 +552,11 @@ export function pathBasename(p: string | null | undefined): string | null {
const idx = trimmed.lastIndexOf("/");
return idx === -1 ? trimmed : trimmed.slice(idx + 1) || trimmed;
}
/** 1536 → "1.5 KB"; binary units, one decimal from KB up. */
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 ** 3) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / 1024 ** 3).toFixed(1)} GB`;
}
+24 -20
View File
@@ -101,9 +101,28 @@ function focusOf(layout: PaneLayout): number | null {
return layout.focus !== null && shown.includes(layout.focus) ? layout.focus : (shown[0] ?? null);
}
/** The pane a new one splits, and on which side, filling a 2×2 grid in
* order: right of a lone pane, under the right one, under the left one. The
* biggest pane by the tree's SHAPE (dragged ratios ignored; a tie goes to the
* later pane) is the gap — so a three-pane tab of any shape fills its one
* missing quarter. A tall pane splits down, any other to the right. */
function nextSpot(root: PaneNode): [number, Edge] {
const even = (n: PaneNode): PaneNode =>
n.type === "pane" ? n : { ...n, ratio: 0.5, a: even(n.a), b: even(n.b) };
const best = layoutRects(even(root)).panes.reduce((m, r) => (r.w * r.h >= m.w * m.h ? r : m));
return [best.slot, best.h > best.w ? "bottom" : "right"];
}
/** Adds `slot` to the layout at the grid's next place (`nextSpot`), focused. */
function placeInLayout(layout: PaneLayout, slot: number): PaneLayout {
if (!layout.root) return { root: pane(slot), focus: slot };
const [target, edge] = nextSpot(layout.root);
return { root: splitLeaf(layout.root, target, slot, edge), focus: slot };
}
/**
* Shows `slot`: focuses it when it is already on screen; otherwise `split`
* opens it to the right of the focused pane (while there is room) and
* adds it at the grid's next place (`nextSpot`, while there is room) and
* `replace` puts it in the focused pane.
*/
export function openInLayout(
@@ -114,12 +133,9 @@ export function openInLayout(
if (!layout.root) return { root: pane(slot), focus: slot };
const shown = leaves(layout.root);
if (shown.includes(slot)) return { ...layout, focus: slot };
if (mode === "split" && shown.length < MAX_PANES) return placeInLayout(layout, slot);
const target = focusOf(layout) as number;
const root =
mode === "split" && shown.length < MAX_PANES
? splitLeaf(layout.root, target, slot, "right")
: mapLeaves(layout.root, (s) => pane(s === target ? slot : s));
return { root, focus: slot };
return { root: mapLeaves(layout.root, (s) => pane(s === target ? slot : s)), focus: slot };
}
/** Closes `slot`'s pane (its session keeps running as a tab). */
@@ -289,7 +305,7 @@ export function updateActive(g: PaneGroups, fn: (l: PaneLayout) => PaneLayout):
/**
* Shows `slot`. Its own tab is activated (and the slot focused there); a
* session in no tab opens a new one after the active tab. `split` (Ctrl/Cmd+
* click) brings it into the active tab beside the focused pane instead —
* click) brings it into the active tab at the grid's next place instead —
* unless that tab is already full, when it falls back to the plain open.
*/
export function openInGroups(g: PaneGroups, slot: number, split: boolean): PaneGroups {
@@ -327,18 +343,6 @@ 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`. */
@@ -351,7 +355,7 @@ export function joinGroup(g: PaneGroups, slots: number[], to: number): PaneGroup
});
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)
i === to ? moving.reduce(placeInLayout, l) : moving.reduce(closeInLayout, l)
);
return compact(groups, to);
}
+14 -3
View File
@@ -7,12 +7,14 @@
* bracketed, so `claude` takes it as one block in its composer — and never an
* Enter: the user reads it and sends it. Only a pane on SCREEN takes it (one
* hidden behind a full-width Changes panel or a replay would swallow it
* unseen); the caller says where it went, or that it went nowhere.
* unseen); the caller says where it went, or that it went nowhere. The one
* exception is `submitToRun`: a pane's own Chat view (`PaneChat.tsx`) sends
* what the user typed there, so it pastes AND presses Enter.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
interface Pane {
paste: (text: string) => void;
paste: (text: string, submit?: boolean) => void;
visible: () => boolean;
}
const panes = new Map<string, Pane>();
@@ -22,7 +24,7 @@ let lastFocused: string | null = null;
* unregister. */
export function registerPane(
runId: string,
paste: (text: string) => void,
paste: (text: string, submit?: boolean) => void,
visible: () => boolean = () => true
): () => void {
const entry = { paste, visible };
@@ -52,6 +54,15 @@ export function pasteToRun(runId: string, text: string): boolean {
return true;
}
/** Sends `text` as a message to the pane of `runId`: a paste, then Enter.
* False when the pane is not mounted or not attached. */
export function submitToRun(runId: string, text: string): boolean {
const p = panes.get(runId);
if (!p?.visible()) return false;
p.paste(text, true);
return true;
}
/** Pastes `text` into one of the workspace's VISIBLE panes — the one focused
* last when it is — and returns its run id; null when none is on screen. */
export function pasteToLane(laneId: number, text: string): string | null {
+8 -1
View File
@@ -32,6 +32,8 @@ export const SETTINGS_INDEX: {
descKey?: string;
keywords?: string[];
to?: string;
/** Server mode: a control only an admin sees, kept out of anyone else's search. */
adminOnly?: boolean;
}[] = [
{
section: "appearance",
@@ -114,18 +116,21 @@ export const SETTINGS_INDEX: {
{
section: "system",
titleKey: "autostart.label",
adminOnly: true,
keywords: ["autostart", "boot", "login", "systemd", "launchd", "service"],
to: "#autostart",
},
{
section: "system",
titleKey: "quit.label",
adminOnly: true,
keywords: ["quit", "stop", "kill", "shutdown", "exit", "background"],
to: "#autostart",
},
{
section: "system",
titleKey: "tray.label",
adminOnly: true,
keywords: ["tray", "icon", "taskbar", "menu bar", "status area", "notification area"],
to: "#autostart",
},
@@ -154,6 +159,7 @@ export const SETTINGS_INDEX: {
{
section: "reports",
titleKey: "reports.title",
adminOnly: true,
descKey: "reports.description",
keywords: ["schedule", "report", "email", "summary", "html", "json", "export"],
},
@@ -189,6 +195,7 @@ export const SETTINGS_INDEX: {
{
section: "notifications",
titleKey: "webhooks.title",
adminOnly: true,
keywords: ["slack", "discord", "telegram", "channels"],
to: "#alerts",
},
@@ -279,7 +286,7 @@ export const SETTINGS_INDEX: {
{ section: "agents/*", titleKey: "toolTab.defaultModel", keywords: ["model", "config"] },
{ section: "ai-proxy", titleKey: "aiProxy.title", keywords: ["router", "endpoint", "key"] },
{ section: "pricing", titleKey: "pricing.title", keywords: ["cost", "price", "tokens"] },
{ section: "git", titleKey: "gitCreds.title", keywords: ["login", "token"] },
{ section: "git", titleKey: "gitCreds.title", adminOnly: true, keywords: ["login", "token"] },
{
section: "git",
titleKey: "worktrees.showUntracked",
+3
View File
@@ -1683,6 +1683,9 @@ export interface AlertEvent {
/** Agent the alert pertains to; null when not agent-specific. FK into
* `Agent.id` when set. */
agent_id: string | null;
/** Server mode, an analyst's feed only: whether this alert is one of their
* own sessions' and so theirs to acknowledge. Absent = acknowledgeable. */
can_ack?: boolean;
/** Human-readable description of what triggered the alert. Example:
* `"Session idle for 32 minutes"`. */
message: string;