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
@@ -51,10 +51,12 @@ let mockCost: { total_cost: number; breakdown: unknown[] } = { total_cost: 0, br
// The header's Resume reads lanes and runs; this file is about the agent tree.
vi.mock("../../components/lanes/SessionResumeButton", () => ({
SessionResumeButton: () => null,
owningLane: () => null,
}));
vi.mock("../../lib/api", () => ({
api: {
lanes: { list: vi.fn(() => Promise.resolve({ lanes: [] })) },
sessions: {
get: vi.fn(() =>
Promise.resolve({
@@ -104,6 +106,7 @@ vi.mock("../../lib/api", () => ({
vi.mock("../../lib/eventBus", () => ({
eventBus: {
subscribe: vi.fn(() => () => {}),
onConnection: vi.fn(() => () => {}),
},
}));
@@ -65,6 +65,18 @@ vi.mock("../../lib/api", async (importOriginal) => {
env_owned: { redact_secrets: false, ignore_cwd: false },
})),
},
backup: {
get: vi.fn(async () => ({
dir: "",
hour: 2,
keep: 7,
running: false,
last: null,
files: [],
dir_error: null,
env_owned: { dir: false, hour: false, keep: false },
})),
},
},
},
};
@@ -3,7 +3,8 @@
* @description The Settings page shell: a path picks its section, `/settings`
* and the legacy `?tool=` / `#ai-tools` links redirect to the path form, an
* unknown section falls back to Appearance, a disabled tool has no page while an
* installed, enabled catalog tool gets one, the mobile
* installed, enabled catalog tool gets one — as a tab on AI tools, not a nav
* item —, the mobile
* select navigates, and the search filters the nav, opens the first match on
* Enter, clears on Escape and is focused by Ctrl+F (a second press selects its
* text), its result list deep-links a row's sub-tab, an anchor in the URL is
@@ -159,13 +160,27 @@ describe("SettingsHub", () => {
view.tools[1] = tool("codex", { launcher: "codex", enabled: false });
renderAt("/settings/agents/gemini");
expect(await screen.findByTestId("tool-gemini")).toBeInTheDocument();
const tabs = within(screen.getByRole("tablist", { name: "AI tool settings" }))
.getAllByRole("tab")
.map((a) => a.textContent);
expect(tabs).toEqual(["All tools", "Claude Code", "Gemini"]);
});
it("shows tool pages as tabs on AI tools, not as nav items", async () => {
renderAt("/settings/agents/codex");
expect(await screen.findByTestId("codex-config")).toBeInTheDocument();
const links = within(nav())
.getAllByRole("link")
.map((a) => a.textContent);
expect(links).toContain("Claude Code");
expect(links).not.toContain("Codex");
expect(links).not.toContain("Qwen");
expect(links).not.toContain("Aider");
expect(within(nav()).getByRole("link", { name: "AI tools" })).toHaveAttribute(
"aria-current",
"page"
);
expect(screen.getByRole("tab", { name: "Codex" })).toHaveAttribute("aria-selected", "true");
fireEvent.click(screen.getByRole("tab", { name: "All tools" }));
expect(screen.getByTestId("where").textContent).toBe("/settings/agents");
expect(screen.getByTestId("ai-tools")).toBeInTheDocument();
});
it("navigates from the mobile select", async () => {
+4 -4
View File
@@ -66,7 +66,7 @@ function renderPage() {
beforeEach(() => {
for (const fn of Object.values(users)) fn.mockReset();
users.list.mockResolvedValue({
users: [row(1, "boss", "admin"), row(2, "mia", "member")],
users: [row(1, "boss", "admin"), row(2, "mia", "developer")],
});
users.audit.mockResolvedValue({ entries: [] });
});
@@ -76,7 +76,7 @@ describe("Users page", () => {
authState = { mode: "local" };
renderPage();
expect(await screen.findByTestId("home")).toBeTruthy();
authState = { mode: "server", user: { id: 2, username: "mia", role: "member" } };
authState = { mode: "server", user: { id: 2, username: "mia", role: "developer" } };
renderPage();
expect((await screen.findAllByTestId("home")).length).toBeGreaterThan(0);
expect(users.list).not.toHaveBeenCalled();
@@ -94,7 +94,7 @@ describe("Users page", () => {
it("adds a user only with the admin's own password", async () => {
authState = { mode: "server", user: { id: 1, username: "boss", role: "admin" } };
users.create.mockResolvedValue({
user: { id: 9, username: "neo", role: "member", disabled: false },
user: { id: 9, username: "neo", role: "developer", disabled: false },
});
const ue = userEvent.setup();
renderPage();
@@ -111,7 +111,7 @@ describe("Users page", () => {
await ue.click(confirm);
await waitFor(() =>
expect(users.create).toHaveBeenCalledWith(
{ username: "neo", password: "neo-pass-123", role: "member" },
{ username: "neo", password: "neo-pass-123", role: "developer" },
"boss-pass"
)
);
@@ -732,7 +732,7 @@ describe("split panes", () => {
expect(shownPanes()).toEqual(["pane-0", "pane-1", "pane-2", "pane-3"]);
await ctrlClick(screen.getByTestId("console-tab-4"));
expect(shownPanes()).toEqual(["pane-4"]);
expect(screen.getByTestId("console-tab-0-1-2-3")).toBeInTheDocument();
expect(screen.getByTestId("console-tab-0-3-1-2")).toBeInTheDocument();
expect(screen.getByTestId("console-tab-4")).toHaveAttribute("aria-selected", "true");
});
@@ -7189,6 +7189,35 @@ exports[`screen snapshots > Settings — about 1`] = `
</button>
</div>
</div>
<div
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-3 items-center"
>
<div
class="min-w-0"
>
<p
class="text-sm text-fg-secondary"
>
Uninstall AgentHub
</p>
<p
class="mt-0.5 text-xs text-fg-muted"
>
Remove AgentHub from this computer: the dashboard, its database and settings, its hooks in Claude Code, Codex and Antigravity, and the plugin. Your conversations, workspace folders and repositories are kept.
</p>
</div>
<div
class="shrink-0"
>
<button
class="btn-ghost text-xs text-status-danger disabled:opacity-40"
data-testid="uninstall-agenthub"
type="button"
>
Uninstall
</button>
</div>
</div>
</div>
</section>
<section
@@ -7220,7 +7249,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<span
class="text-status-warning/90"
>
UI build v2.5.12 (rebuild client to match)
UI build v2.5.13 (rebuild client to match)
</span>
</p>
</div>
@@ -9066,6 +9095,205 @@ exit 0
</div>
</section>
</div>
<section
class="card p-5 scroll-mt-24"
id="backup"
>
<h3
class="text-sm font-semibold text-fg-primary"
>
<span
class="flex items-center gap-2"
>
<svg
class="lucide lucide-database-backup w-4 h-4 text-fg-muted"
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"
>
<ellipse
cx="12"
cy="5"
rx="9"
ry="3"
/>
<path
d="M3 12a9 3 0 0 0 5 2.69"
/>
<path
d="M21 9.3V5"
/>
<path
d="M3 5v14a9 3 0 0 0 6.47 2.88"
/>
<path
d="M12 12v4h4"
/>
<path
d="M13 20a5 5 0 0 0 9-3 4.5 4.5 0 0 0-4.5-4.5c-1.33 0-2.54.54-3.41 1.41L12 16"
/>
</svg>
Daily backup
</span>
</h3>
<p
class="mt-0.5 text-xs text-fg-muted"
>
A full copy of the database once a day, into a folder you choose — best on another disk, so a failed drive does not take the backups with it.
</p>
<div
class="mt-2"
>
<div
class="py-3 space-y-3"
>
<label
class="block text-xs text-fg-muted"
>
Backup folder
<input
class="input mt-1 w-full font-mono text-xs"
placeholder="/mnt/hdd/agenthub-backups"
value=""
/>
</label>
<p
class="text-xs text-fg-muted"
>
An absolute path to an existing folder the server can write to. Leave it empty to turn backups off. AgentHub only ever deletes its own agenthub-YYYYMMDD-HHMMSS.db files here, oldest first beyond the number kept.
</p>
<div
class="flex flex-wrap items-end gap-3"
>
<div
class="w-28"
>
<div
class="relative"
>
<button
aria-expanded="false"
aria-haspopup="listbox"
aria-label="Time (server)"
class="w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent focus-visible:ring-2 focus-visible:ring-accent/40 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
title="Time (server)"
type="button"
>
<span
class="flex min-w-0 items-center gap-2"
>
<span
class="truncate"
>
02:00
</span>
</span>
<svg
aria-hidden="true"
class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0"
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="m6 9 6 6 6-6"
/>
</svg>
</button>
</div>
</div>
<label
class="text-xs text-fg-muted"
>
Backups kept
<input
class="input mt-1 block w-24 text-sm"
max="365"
min="1"
step="1"
type="number"
value="7"
/>
</label>
</div>
<div
class="flex flex-wrap items-center gap-2"
>
<button
class="btn-primary px-4 py-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
disabled=""
type="button"
>
Save
</button>
<button
class="btn-ghost text-sm disabled:opacity-50 disabled:cursor-not-allowed"
disabled=""
type="button"
>
<svg
class="lucide lucide-database-backup w-4 h-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"
>
<ellipse
cx="12"
cy="5"
rx="9"
ry="3"
/>
<path
d="M3 12a9 3 0 0 0 5 2.69"
/>
<path
d="M21 9.3V5"
/>
<path
d="M3 5v14a9 3 0 0 0 6.47 2.88"
/>
<path
d="M12 12v4h4"
/>
<path
d="M13 20a5 5 0 0 0 9-3 4.5 4.5 0 0 0-4.5-4.5c-1.33 0-2.54.54-3.41 1.41L12 16"
/>
</svg>
Back up now
</button>
</div>
<p
class="text-xs text-fg-secondary"
>
Backups are off.
</p>
<p
class="text-xs text-fg-muted"
>
To restore: agenthub stop, delete dashboard.db-wal and dashboard.db-shm, copy a backup over dashboard.db (its path is on the System page), then agenthub start.
</p>
</div>
</div>
</section>
<section
class="card p-5 space-y-4 scroll-mt-24"
id="cleanup"
@@ -350,6 +350,18 @@ vi.mock("../../lib/api", async (importOriginal) => {
}),
save: r({ ok: true }),
},
backup: {
get: r({
dir: "",
hour: 2,
keep: 7,
running: false,
last: null,
files: [],
dir_error: null,
env_owned: { dir: false, hour: false, keep: false },
}),
},
exportData: () => "/api/settings/export",
cleanup: r({
ok: true,