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:
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user