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
+27 -11
View File
@@ -94,8 +94,10 @@ import { SessionOverview } from "../components/SessionOverview";
import { ConversationView } from "../components/conversation/ConversationView";
import { SessionStatusBadge, REASON_ICONS } from "../components/StatusBadge";
import { SessionToolBadge } from "../components/SessionToolBadge";
import { SessionResumeButton } from "../components/lanes/SessionResumeButton";
import { SessionResumeButton, owningLane } from "../components/lanes/SessionResumeButton";
import { SnapshotButton } from "../components/SnapshotButton";
import { usePermissions } from "../components/AuthGate";
import { useLanes } from "../hooks/useLanes";
import { EventTimeline } from "../components/EventTimeline";
import { EventReplay, loadReplayEvents } from "../components/EventReplay";
import { CopyButton } from "../components/event-views/primitives";
@@ -151,6 +153,9 @@ export function SessionDetail() {
const { t } = useTranslation("sessions");
const { t: wfT } = useTranslation("workflows");
const [session, setSession] = useState<Session | null>(null);
// Server mode: an analyst reads every session but acts only on their own.
const { canEditLane } = usePermissions();
const { lanes } = useLanes();
const [agents, setAgents] = useState<Agent[]>([]);
const [workflows, setWorkflows] = useState<WorkflowRun[]>([]);
const [events, setEvents] = useState<DashboardEvent[]>([]);
@@ -666,16 +671,27 @@ export function SessionDetail() {
</div>
)}
</div>
{(() => {
const href = workspaceHref(session, runs);
return href ? (
<Link to={href} className="btn-ghost">
<ExternalLink className="w-4 h-4" /> {t("openInWorkspace")}
</Link>
) : null;
})()}
<SessionResumeButton session={session} />
<SnapshotButton sessionId={session.id} />
{/* The owning workspace: its stamp, else the one whose folder holds it
(a session a pane holds but no hook stamped yet). */}
{canEditLane(
lanes.find((l) => l.id === session.lane_id) ??
(session.lane_id == null && session.cwd
? owningLane(session.cwd, session.tool || "claude", lanes)
: null)
) && (
<>
{(() => {
const href = workspaceHref(session, runs);
return href ? (
<Link to={href} className="btn-ghost">
<ExternalLink className="w-4 h-4" /> {t("openInWorkspace")}
</Link>
) : null;
})()}
<SessionResumeButton session={session} />
<SnapshotButton sessionId={session.id} />
</>
)}
<button
onClick={refresh}
disabled={spinning}
+8 -10
View File
@@ -1,6 +1,6 @@
/**
* @file Settings.tsx
* @description The install-wide settings, as three pages of the Settings nav (`SettingsHub.tsx`): `section="data"` — total cost, database overview, workspace housekeeping (dry run first, confirm before a real run), import, remote sources, push ingest (`components/settings/PushIngestSettings.tsx`), privacy (`components/settings/PrivacySettings.tsx`), export, this browser's preferences backup, session cleanup (whole days ≥ 1, a dry-run count in its confirm) and the danger zone (typed confirmation) —, `section="about"` (the System page) — the start-at-login switch and Quit (`AutostartSettings`), then the server process (release, uptime, platform, CPU, memory, live connections), how AgentHub is installed (the update check's cached status) and the database file — and `section="notifications"` — browser notifications (per-browser, with a reset) and alerts (server-wide). Server info (`/api/settings/info`) is read only by Data and About, refreshed on activity at most every 10 s. Language, theme and the pet live on the Appearance page (`components/settings/AppearanceSettings.tsx`); per-tool settings live in that tool's page under `/settings/agents/<id>`.
* @description The install-wide settings, as three pages of the Settings nav (`SettingsHub.tsx`): `section="data"` — total cost, database overview, workspace housekeeping (dry run first, confirm before a real run), import, remote sources, push ingest (`components/settings/PushIngestSettings.tsx`), privacy (`components/settings/PrivacySettings.tsx`), the daily database backup (`components/settings/BackupSettings.tsx`), export, this browser's preferences backup, session cleanup (whole days ≥ 1, a dry-run count in its confirm) and the danger zone (typed confirmation) —, `section="about"` (the System page) — the start-at-login switch and Quit (`AutostartSettings`), then the server process (release, uptime, platform, CPU, memory, live connections), how AgentHub is installed (the update check's cached status) and the database file — and `section="notifications"` — browser notifications (per-browser, with a reset) and alerts (server-wide). Server info (`/api/settings/info`) is read only by Data and About, refreshed on activity at most every 10 s. Language, theme and the pet live on the Appearance page (`components/settings/AppearanceSettings.tsx`); per-tool settings live in that tool's page under `/settings/agents/<id>`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -85,7 +85,7 @@ import {
import { api } from "../lib/api";
import { eventBus } from "../lib/eventBus";
import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
import { fmt, fmtCost } from "../lib/format";
import { fmt, fmtCost, formatBytes } from "../lib/format";
import { subscribeToPush, unsubscribeFromPush } from "../lib/push";
import {
defaultNotifPrefs,
@@ -102,7 +102,9 @@ import { ImportHistory } from "../components/ImportHistory";
import { RemoteSources } from "../components/RemoteSources";
import { PushIngestSettings } from "../components/settings/PushIngestSettings";
import { PrivacySettings } from "../components/settings/PrivacySettings";
import { BackupSettings } from "../components/settings/BackupSettings";
import { AutostartSettings } from "../components/settings/AutostartSettings";
import { usePermissions } from "../components/AuthGate";
import { Skeleton } from "../components/Skeleton";
import { AlertsNotifications } from "../components/AlertsNotifications";
import {
@@ -142,13 +144,6 @@ const NOTIF_TYPES: [NotifType, string][] = [
type SystemInfo = Awaited<ReturnType<typeof api.settings.info>>;
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`;
}
function formatUptime(seconds: number): string {
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
@@ -201,6 +196,7 @@ export function Settings({
section?: "data" | "about" | "notifications";
} = {}) {
const { t } = useTranslation("settings");
const { isAdmin } = usePermissions();
// Only Data and About show server info; General and Notifications never read it.
const needsInfo = section === "data" || section === "about";
const [loading, setLoading] = useState(needsInfo);
@@ -966,6 +962,7 @@ export function Settings({
{/* ─── PUSH INGEST + PRIVACY (each its own anchored card) ─── */}
<PushIngestSettings />
<PrivacySettings />
<BackupSettings />
{/* ─── SESSION CLEANUP ─── */}
<section id="cleanup" className="card p-5 space-y-4 scroll-mt-24">
@@ -1120,7 +1117,8 @@ export function Settings({
{section === "about" && (
<div id="about" className="space-y-6 scroll-mt-24">
<AutostartSettings />
{/* Start at login, the icon, Quit and Uninstall act on the host: an admin's. */}
{isAdmin && <AutostartSettings />}
{sysInfo ? (
<SystemDetails info={sysInfo} />
) : (
+62 -26
View File
@@ -16,7 +16,9 @@
* (`snapshots`). A page's content is capped at a readable width. A tool's own settings live under `agents/<id>` — Claude Code and
* Codex have their own pages, every other installed, enabled catalog tool the
* generic `AiToolConfig` — and a tool's page is hidden while that tool is
* disabled. A workspace's own settings live on its home, not here.
* disabled. Those pages are not in the nav: they are tabs on AI tools (the nav
* marks AI tools current, the search shows their matches under it), and a
* row in the AI tools list opens its tool's tab. A workspace's own settings live on its home, not here.
* The old `?tool=<id>` query and `#ai-tools` hash redirect to the path form.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -65,7 +67,8 @@ import {
import { Skeleton } from "../components/Skeleton";
import { AiToolIcon } from "../components/AiToolIcon";
import { toast } from "../lib/toast";
import { useAuth } from "../components/AuthGate";
import { usePermissions } from "../components/AuthGate";
import { PageTabs } from "../components/PageTabs";
const GROUPS = ["general", "agents", "data"] as const;
type Group = (typeof GROUPS)[number];
@@ -77,8 +80,13 @@ interface Section {
description: string;
Icon: ComponentType<{ className?: string }>;
render: () => ReactNode;
/** A tool's own page: a tab on AI tools, not a nav item. */
tool?: string;
}
/** The nav item a section sits under: a tool's page is a tab on AI tools. */
const navId = (id: string) => (id.startsWith("agents/") ? "agents" : id);
/** The pre-1.9 `?tool=` ids, as section ids. Anything else was a catalog tool. */
const LEGACY_TOOL: Record<string, string> = {
dashboard: "appearance",
@@ -131,8 +139,8 @@ const MOVED: Record<string, string> = {
worktrees: "git#worktrees",
};
/** Sections whose API a server-mode member may not reach (STAFF_ONLY_PREFIXES in server/lib/auth.js). */
const STAFF_SECTIONS = new Set(["data", "agents/claude-code", "agents/codex"]);
/** Sections that only configure the host (ADMIN_WRITE_PREFIXES in server/lib/auth.js). */
const ADMIN_SECTIONS = new Set(["data", "ai-proxy", "pricing"]);
/** An unknown section: say so, then show Appearance. */
function UnknownSection({ name }: { name: string }) {
@@ -215,8 +223,7 @@ export function SettingsHub() {
};
}, [location.key, location.hash]);
const auth = useAuth();
const isMember = auth.state.mode === "server" && auth.state.user?.role === "member";
const { isAdmin } = usePermissions();
const enabled = (id: string) => aiTools.byId(id)?.enabled !== false;
const s = (key: string) => ({
title: t(`settingsNav.section.${key}.title`),
@@ -249,13 +256,14 @@ export function SettingsHub() {
group: "agents",
Icon: Bot,
...s("agents"),
render: () => <AiToolsSettings />,
render: () => <AiToolsSettings settingsHref={toolHref} />,
},
...(enabled("claude")
? [
{
id: "agents/claude-code",
group: "agents" as const,
tool: "claude",
Icon: toolNavIcon("claude", "Claude Code"),
...s("claudeCode"),
render: () => <CcConfig />,
@@ -267,6 +275,7 @@ export function SettingsHub() {
{
id: "agents/codex",
group: "agents" as const,
tool: "codex",
Icon: toolNavIcon("codex", "Codex"),
...s("codex"),
render: () => <CodexConfig />,
@@ -280,6 +289,7 @@ export function SettingsHub() {
.map((x) => ({
id: `agents/${x.id}`,
group: "agents" as const,
tool: x.id,
Icon: toolNavIcon(x.id, x.label),
title: x.label,
description: t("settingsNav.section.tool.description", { tool: x.label }),
@@ -335,8 +345,8 @@ export function SettingsHub() {
<div id="snapshots" className="scroll-mt-24">
<SnapshotSettings />
</div>
{/* Scheduled reports read every user's sessions: staff only. */}
{!isMember && <ReportSettings />}
{/* Scheduled reports read every user's sessions: an admin's. */}
{isAdmin && <ReportSettings />}
</div>
),
},
@@ -347,8 +357,8 @@ export function SettingsHub() {
...s("git"),
render: () => (
<div className="space-y-6">
{/* Git logins are the host's own: staff only. */}
{!isMember && (
{/* Git logins are the host's own: an admin's. */}
{isAdmin && (
<section id="git-credentials" className="scroll-mt-24">
<h2 className="mb-2 text-sm font-semibold text-fg-primary">{t("gitCreds.title")}</h2>
<GitCredentialsConfig />
@@ -361,18 +371,27 @@ export function SettingsHub() {
),
},
];
// Server mode: a member cannot open the host's own configuration or the
// all-users views (the server answers 403 EADMIN), so those pages are hidden.
const sections = isMember ? allSections.filter((x) => !STAFF_SECTIONS.has(x.id)) : allSections;
// Server mode: only an admin changes the host's own configuration (the
// server answers 403 EADMIN), so its pages — every tool's too — are hidden.
const sections = isAdmin
? allSections
: allSections.filter((x) => !x.tool && !ADMIN_SECTIONS.has(x.id));
const navSections = sections.filter((x) => !x.tool);
const toolTabs = sections.filter((x) => x.tool);
function toolHref(id: string) {
const page = toolTabs.find((x) => x.tool === id);
return page ? `/settings/${page.id}` : undefined;
}
const entries: SettingsEntry[] = sections.flatMap((sec) => [
{ section: sec.id, title: sec.title, description: sec.description, sectionTitle: true },
...SETTINGS_INDEX.filter(
(e) =>
e.section === sec.id ||
(e.section === "agents/*" &&
sec.id.startsWith("agents/") &&
!SETTINGS_INDEX.some((x) => x.section === sec.id))
(isAdmin || !e.adminOnly) &&
(e.section === sec.id ||
(e.section === "agents/*" &&
sec.id.startsWith("agents/") &&
!SETTINGS_INDEX.some((x) => x.section === sec.id)))
).map((e) => ({
section: sec.id,
title: t(e.titleKey),
@@ -410,8 +429,11 @@ export function SettingsHub() {
}
const ranked = query.trim() ? rankSettings(query, entries) : null;
const shown = ranked ? sections.filter((x) => ranked.some((r) => r.section === x.id)) : sections;
const first = ranked?.[0]?.section ?? null;
const shown = ranked
? navSections.filter((x) => ranked.some((r) => navId(r.section) === x.id))
: navSections;
const first = ranked?.[0] ? navId(ranked[0].section) : null;
const navActive = navId(active.id);
const results = ranked ? rankSettingsEntries(query, entries) : [];
const titleOf = (id: string) => sections.find((x) => x.id === id)?.title ?? id;
const hrefOf = (e: SettingsEntry) => `/settings/${e.section}${e.to ?? ""}`;
@@ -494,10 +516,10 @@ export function SettingsHub() {
<Link
key={id}
to={`/settings/${id}`}
aria-current={id === active.id ? "page" : undefined}
aria-current={id === navActive ? "page" : undefined}
data-first-match={id === first ? "true" : undefined}
className={`relative flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm transition-colors ${
id === active.id
id === navActive
? "bg-surface-3 font-medium text-fg-primary before:absolute before:inset-y-1.5 before:left-0 before:w-0.5 before:rounded-full before:bg-accent"
: id === first
? "bg-surface-3 text-fg-primary"
@@ -505,7 +527,7 @@ export function SettingsHub() {
}`}
>
<Icon
className={`h-4 w-4 shrink-0 ${id === active.id ? "text-accent" : "text-fg-muted"}`}
className={`h-4 w-4 shrink-0 ${id === navActive ? "text-accent" : "text-fg-muted"}`}
/>
<span className="truncate">{title}</span>
</Link>
@@ -521,12 +543,12 @@ export function SettingsHub() {
<div className="mb-4 md:hidden">
<select
aria-label={t("settingsNav.label")}
value={active.id}
value={navActive}
onChange={(e) => go(e.target.value)}
className="select w-full py-1.5"
>
{GROUPS.map((g) => {
const own = sections.filter((x) => x.group === g);
const own = navSections.filter((x) => x.group === g);
return own.length === 0 ? null : (
<optgroup key={g} label={t(`settingsNav.group.${g}`)}>
{own.map((x) => (
@@ -548,7 +570,21 @@ export function SettingsHub() {
<p className="mt-0.5 text-sm text-fg-muted">{active.description}</p>
</div>
</header>
{active.render()}
{navActive === "agents" && toolTabs.length > 0 ? (
<PageTabs
ariaLabel={t("settingsNav.toolTabs")}
activeId={active.id}
onSelect={go}
tabs={[
{ id: "agents", label: t("settingsNav.toolsTab"), Icon: Bot },
...toolTabs.map(({ id, title, Icon }) => ({ id, label: title, Icon })),
]}
>
<div className="pt-4">{active.render()}</div>
</PageTabs>
) : (
active.render()
)}
</div>
</div>
</div>
+5 -5
View File
@@ -25,7 +25,7 @@ import { ConfirmModal } from "../components/ConfirmModal";
import { SettingsSection } from "../components/settings/SettingsRow";
type Role = AuthUser["role"];
const ROLES: Role[] = ["admin", "analyst", "member"];
const ROLES: Role[] = ["admin", "analyst", "developer"];
type DialogKind = "add" | "role" | "disable" | "enable" | "password" | "workspace";
type Dialog = { kind: DialogKind; user?: ServerUserUsage };
@@ -61,11 +61,11 @@ export function Users() {
const [spinning, refresh] = useSpin(load);
useEffect(() => {
if (me && me.role !== "member") void load();
if (me && me.role !== "developer") void load();
}, [me, load]);
// Local mode, or a member: there is no Users page for them.
if (!me || me.role === "member") return <Navigate to="/" replace />;
// Local mode, or a developer: there is no Users page for them.
if (!me || me.role === "developer") return <Navigate to="/" replace />;
return (
<div className="animate-fade-in space-y-6">
@@ -265,7 +265,7 @@ function AccountDialog({
const name = user?.username ?? "";
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [role, setRole] = useState<Role>(user?.role ?? "member");
const [role, setRole] = useState<Role>(user?.role ?? "developer");
const [laneId, setLaneId] = useState<number | null>(null);
const [lanes, setLanes] = useState<Lane[] | null>(null);
const [current, setCurrent] = useState("");
+5 -5
View File
@@ -69,7 +69,7 @@ import type { Lane, LaneFeature, ProofFeature, Session } from "../lib/types";
import { toast } from "../lib/toast";
import { confirmDialog, confirmDiscard } from "../lib/confirm";
import { keepIfSame } from "../lib/keepIfSame";
import { useLanes } from "../hooks/useLanes";
import { useWorkLanes } from "../hooks/useLanes";
import { LaneConsolePane } from "../components/run/LaneConsolePane";
import {
ConsoleTabs,
@@ -168,7 +168,7 @@ function slotOf(runId: string | null | undefined): number {
* lane. The focused pane of the tab on screen is the URL's `?run=`: a plain
* open (a tab, a sidebar row) shows the tab holding that run — a session in
* no tab gets one of its own — and a split open (Ctrl/Cmd+click) brings it
* into the tab on screen, to the right of the focused pane. Every live run
* into the tab on screen, at the 2×2 grid's next place. Every live run
* stays MOUNTED, hidden with CSS while its tab is not shown, so neither
* switching tabs nor reshaping the panes ever drops a terminal. A pane whose
* run ends closes; the last tab gone leaves for the home. */
@@ -189,7 +189,7 @@ function ConsoleArea({
activeRuns: RunListResponse | null;
onHasActiveRunChange: (val: boolean) => void;
/** The opened run (`?run=`): its pane is the focused one. `split` opens it
* beside the focused pane instead of in it; `focusOnly` (the URL following a
* in a split (the grid's next place) instead; `focusOnly` (the URL following a
* focus change made here) never opens anything. */
attachRequest: { runId: string; nonce: string; split?: boolean; focusOnly?: boolean } | null;
/** Back to the workspace home, where a new session is started. */
@@ -519,7 +519,7 @@ export function Workspace() {
// Lane state: the list is the shared store the sidebar reads too; the
// selection is the URL's `?lane=` (the sidebar sets it, so does a
// notification click), falling back to the last one shown, then the first.
const { lanes, loaded: lanesLoaded, refresh: refreshLanes } = useLanes();
const { lanes, loaded: lanesLoaded, refresh: refreshLanes } = useWorkLanes();
const laneParam = searchParams.get("lane");
// `?run=` — a live session picked in the sidebar; the location key makes a
// second pick of the same session a new request.
@@ -529,7 +529,7 @@ export function Workspace() {
// conversation, with a prompt box whose message resumes it (SessionReplay).
const sessionParam = runParam ? null : searchParams.get("session");
const navigate = useNavigate();
// A Ctrl/Cmd+click (tab or sidebar) opens the run beside the focused pane;
// A Ctrl/Cmd+click (tab or sidebar) opens the run in a split;
// it rides on the navigation's state, never in the URL.
const navState = location.state as { split?: unknown; focusOnly?: unknown } | null;
const splitOpen = navState?.split === true;
@@ -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,