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
@@ -10,6 +10,8 @@
* (polled from GET /api/ai-tools/job; a finished job folds to one line).
* A right-click on an installed row opens its Update / Set default /
* Enable-Disable (plus Copy path) as a menu; the buttons stay.
* A click on an installed row (outside its buttons) opens that tool's settings
* tab when the hub gives it one (`settingsHref`); the name is the keyboard link.
* Refresh re-reads the login shell's PATH and re-detects. Icons come from
* `AiToolIcon`. A disabled tool is hidden from Add lane; existing lanes keep
* working.
@@ -19,12 +21,14 @@
import { Switch } from "../Switch";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom";
import { ChevronDown, Download, ExternalLink, RefreshCw } from "lucide-react";
import { useAiTools } from "../../hooks/useAiTools";
import { ConfirmModal } from "../ConfirmModal";
import { AiToolIcon } from "../AiToolIcon";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
import { api, type AiTool, type AiToolJob } from "../../lib/api";
import { usePermissions } from "../AuthGate";
type Confirm = { kind: "install" | "update"; tool: AiTool } | { kind: "update-all" };
@@ -115,9 +119,17 @@ function JobLog({ job }: { job: AiToolJob }) {
);
}
export function AiToolsSettings() {
export function AiToolsSettings({
settingsHref,
}: {
/** A tool's own settings page, when it has one. */
settingsHref?: (id: string) => string | undefined;
} = {}) {
const { t } = useTranslation("settings");
const navigate = useNavigate();
const { tools, loaded, defaultId, refresh, update, reload } = useAiTools();
// Installing, updating and switching tools changes the host: an admin's.
const { isAdmin } = usePermissions();
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [job, setJob] = useState<AiToolJob | null>(null);
@@ -218,7 +230,7 @@ export function AiToolsSettings() {
{t("aiTools.installed", { count: installed.length })}
</p>
<div className="flex items-center gap-2">
{updateAllTools.length > 0 && (
{isAdmin && updateAllTools.length > 0 && (
<button
type="button"
onClick={() => setConfirm({ kind: "update-all" })}
@@ -229,15 +241,17 @@ export function AiToolsSettings() {
{t("aiTools.updateAll", { count: updateAllTools.length })}
</button>
)}
<button
type="button"
onClick={() => void run("refresh", refresh)}
disabled={busy !== null}
className={GHOST_BTN}
>
<RefreshCw className={`w-3.5 h-3.5 ${busy === "refresh" ? "animate-spin" : ""}`} />
{t("aiTools.refresh")}
</button>
{isAdmin && (
<button
type="button"
onClick={() => void run("refresh", refresh)}
disabled={busy !== null}
className={GHOST_BTN}
>
<RefreshCw className={`w-3.5 h-3.5 ${busy === "refresh" ? "animate-spin" : ""}`} />
{t("aiTools.refresh")}
</button>
)}
</div>
</div>
{error && <p className="mb-2 text-xs text-status-danger">{error}</p>}
@@ -248,12 +262,18 @@ export function AiToolsSettings() {
<ul className="divide-y divide-border overflow-hidden rounded-lg border border-border bg-surface-2">
{installed.map((tool) => {
const isDefault = tool.id === defaultId;
const href = settingsHref?.(tool.id);
return (
<li
key={tool.id}
data-testid={`ai-tool-${tool.id}`}
onContextMenu={(e) => ctx.open(e, tool)}
className="px-3 py-2.5"
onClick={(e) => {
// The row's own buttons, switch, links and log keep their click.
if (href && !(e.target as Element).closest("button, a, input, pre"))
navigate(href);
}}
className={`px-3 py-2.5 ${href ? "cursor-pointer hover:bg-surface-3" : ""}`}
>
<div className="flex items-center gap-3">
<div
@@ -262,7 +282,17 @@ export function AiToolsSettings() {
<AiToolIcon tool={tool} />
<div className="min-w-0">
<div className="flex items-center gap-1.5 text-sm font-medium text-fg-primary">
<span className="truncate">{tool.label}</span>
{href ? (
<Link
to={href}
title={t("aiTools.openSettings", { tool: tool.label })}
className="truncate hover:text-accent hover:underline"
>
{tool.label}
</Link>
) : (
<span className="truncate">{tool.label}</span>
)}
<Homepage tool={tool} />
{isDefault && (
<span className="rounded-full bg-accent/15 px-2 py-0.5 text-[10px] font-medium text-accent">
@@ -279,31 +309,39 @@ export function AiToolsSettings() {
</div>
</div>
{/* Fixed slots, so the buttons line up from row to row. */}
<div className="flex min-w-20 shrink-0 justify-end">
{tool.update && (
<button
type="button"
disabled={jobBusy}
onClick={() => setConfirm({ kind: "update", tool })}
className={GHOST_BTN}
>
{t("aiTools.update")}
</button>
)}
</div>
<div className="flex min-w-28 shrink-0 justify-end">
{!isDefault && (
<button
type="button"
disabled={!tool.enabled || busy !== null}
onClick={() => setDefault(tool)}
className={GHOST_BTN}
>
{t("aiTools.setDefault")}
</button>
)}
</div>
<ToolSwitch tool={tool} disabled={busy !== null} onToggle={() => toggle(tool)} />
{isAdmin && (
<>
<div className="flex min-w-20 shrink-0 justify-end">
{tool.update && (
<button
type="button"
disabled={jobBusy}
onClick={() => setConfirm({ kind: "update", tool })}
className={GHOST_BTN}
>
{t("aiTools.update")}
</button>
)}
</div>
<div className="flex min-w-28 shrink-0 justify-end">
{!isDefault && (
<button
type="button"
disabled={!tool.enabled || busy !== null}
onClick={() => setDefault(tool)}
className={GHOST_BTN}
>
{t("aiTools.setDefault")}
</button>
)}
</div>
<ToolSwitch
tool={tool}
disabled={busy !== null}
onToggle={() => toggle(tool)}
/>
</>
)}
</div>
{logFor(tool.id)}
</li>
@@ -312,7 +350,7 @@ export function AiToolsSettings() {
</ul>
{ctx.menu && (
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="ai-tool-menu">
{ctx.menu.item.update && (
{isAdmin && ctx.menu.item.update && (
<MenuItem
disabled={jobBusy}
onSelect={pick((tool) => setConfirm({ kind: "update", tool }))}
@@ -320,7 +358,7 @@ export function AiToolsSettings() {
{t("aiTools.update")}
</MenuItem>
)}
{ctx.menu.item.id !== defaultId && (
{isAdmin && ctx.menu.item.id !== defaultId && (
<MenuItem
disabled={!ctx.menu.item.enabled || busy !== null}
onSelect={pick(setDefault)}
@@ -328,12 +366,14 @@ export function AiToolsSettings() {
{t("aiTools.setDefault")}
</MenuItem>
)}
<MenuItem disabled={busy !== null} onSelect={pick(toggle)}>
{ctx.menu.item.enabled ? t("rowMenu.disable") : t("rowMenu.enable")}
</MenuItem>
{isAdmin && (
<MenuItem disabled={busy !== null} onSelect={pick(toggle)}>
{ctx.menu.item.enabled ? t("rowMenu.disable") : t("rowMenu.enable")}
</MenuItem>
)}
{ctx.menu.item.path && (
<>
<MenuSeparator />
{isAdmin && <MenuSeparator />}
<MenuItem
onSelect={pick((tool) => void navigator.clipboard.writeText(tool.path ?? ""))}
>
@@ -376,7 +416,7 @@ export function AiToolsSettings() {
</span>
</span>
<Homepage tool={tool} />
{tool.install && (
{isAdmin && tool.install && (
<button
type="button"
disabled={jobBusy}
@@ -3,6 +3,7 @@
* @description Settings → Appearance (`/settings/appearance`): the interface language, the
* terminal font (`lib/terminalFont.ts`, with a preview line), the
* Dark / Light / System mode switch and one named-theme picker per mode, and the
* per-user Chat UI switch (`ChatUiSettings`, `#chat-ui`), the
* corner pet (`PetSettings`, deep-linked as `#pet`) — the old General and Pet
* pages redirect here. Theme state is in `hooks/useTheme.ts`, palettes in
* `index.css`. Each card's swatch re-scopes the theme's CSS variables on its
@@ -17,6 +18,7 @@ import { useTranslation } from "react-i18next";
import { Monitor, Moon, RotateCcw, Sun } from "lucide-react";
import { SettingsRow, SettingsSection, SettingsSegmented } from "./SettingsRow";
import { PetSettings } from "./PetSettings";
import { ChatUiSettings } from "./ChatUiSettings";
import { THEMES, useTheme, type Theme, type ThemeMode } from "../../hooks/useTheme";
import { confirmDialog } from "../../lib/confirm";
import { toast } from "../../lib/toast";
@@ -146,6 +148,8 @@ export function AppearanceSettings() {
</p>
</SettingsSection>
<ChatUiSettings />
<section id="theme" className="card p-5 space-y-5 scroll-mt-24">
<h3 className="text-sm font-semibold text-fg-primary">{t("appearance.theme")}</h3>
<SettingsRow
@@ -7,7 +7,9 @@
* tray Quit): `POST /api/settings/quit` closes every workspace terminal and
* stops the server, which stays stopped until `agenthub start` — or the next
* boot/login while the switch is on. Between them, the status-area icon switch
* (`GET/PUT /api/settings/tray`, on by default — server/lib/tray.js).
* (`GET/PUT /api/settings/tray`, on by default — server/lib/tray.js). Last,
* **Uninstall AgentHub**: `POST /api/settings/uninstall` removes AgentHub from
* the machine, database included (scripts/uninstall.js).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -74,6 +76,24 @@ export function AutostartSettings() {
}
};
const [uninstalling, setUninstalling] = useState(false);
const uninstall = async () => {
const ok = await confirmDialog({
title: t("uninstall.confirmTitle"),
message: t("uninstall.confirmMessage"),
confirmLabel: t("uninstall.button"),
});
if (!ok) return;
setUninstalling(true);
try {
const { log } = await api.settings.uninstall();
toast.success(t("uninstall.done", { log }));
} catch (err) {
setUninstalling(false);
toast.error(err instanceof Error ? err.message : String(err));
}
};
const note = !status
? undefined
: !status.available
@@ -113,6 +133,21 @@ export function AutostartSettings() {
</button>
}
/>
<SettingsRow
label={t("uninstall.label")}
description={t("uninstall.desc")}
control={
<button
type="button"
data-testid="uninstall-agenthub"
disabled={uninstalling}
onClick={() => void uninstall()}
className="btn-ghost text-xs text-status-danger disabled:opacity-40"
>
{t("uninstall.button")}
</button>
}
/>
</SettingsSection>
);
}
@@ -0,0 +1,202 @@
/**
* @file BackupSettings.tsx
* @description Settings → Data → Backup: the daily database backup of
* `server/lib/db-backup.js` — the folder (best on another disk), the server's
* local hour, how many to keep, "Back up now" (polls `GET /api/settings/backup`
* while it runs, then toasts the outcome) and the newest backup in the folder.
* Saved through `PUT /api/settings/backup`, sending only the fields changed; a
* field the environment set is read-only. Restoring is manual and the card says
* how.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { AlertTriangle, DatabaseBackup } from "lucide-react";
import { api } from "../../lib/api";
import type { BackupState } from "../../lib/api";
import { formatBytes, formatDateTime } from "../../lib/format";
import { toast } from "../../lib/toast";
import { Select } from "../Select";
import { SettingsSection } from "./SettingsRow";
const HOURS = Array.from({ length: 24 }, (_, h) => h);
const pad = (n: number) => String(n).padStart(2, "0");
export function BackupSettings() {
const { t } = useTranslation("settings");
const [state, setState] = useState<BackupState | null>(null);
const [dir, setDir] = useState("");
const [hour, setHour] = useState(2);
const [keep, setKeep] = useState("7");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const apply = (s: BackupState) => {
setState(s);
setDir(s.dir);
setHour(s.hour);
setKeep(String(s.keep));
};
useEffect(() => {
api.settings.backup
.get()
.then(apply)
.catch(() => undefined);
}, []);
// A backup takes tens of seconds on a large database: poll until it ends.
const running = !!state?.running;
useEffect(() => {
if (!running) return;
const id = window.setInterval(() => {
api.settings.backup
.get()
.then((s) => {
setState(s); // the form keeps its drafts
if (s.running || !s.last) return;
if (s.last.ok) toast.success(t("backup.done", { file: s.last.file }));
else toast.error(t("backup.failed", { error: s.last.error }));
})
.catch(() => undefined);
}, 2000);
return () => window.clearInterval(id);
}, [running, t]);
const keepN = Number(keep);
const keepValid = Number.isInteger(keepN) && keepN >= 1 && keepN <= 365;
const patch: { dir?: string; hour?: number; keep?: number } = {};
if (state) {
if (dir.trim() !== state.dir) patch.dir = dir.trim();
if (hour !== state.hour) patch.hour = hour;
if (keepN !== state.keep) patch.keep = keepN;
}
const changed = Object.keys(patch).length > 0;
async function save() {
setSaving(true);
setError(null);
try {
apply(await api.settings.backup.save(patch));
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setSaving(false);
}
}
async function runNow() {
try {
setState(await api.settings.backup.run());
} catch (err) {
toast.error(err);
}
}
const owned = state?.env_owned;
const newest = state?.files[0];
const total = state?.files.reduce((n, f) => n + f.size, 0) ?? 0;
return (
<SettingsSection
id="backup"
title={
<span className="flex items-center gap-2">
<DatabaseBackup className="w-4 h-4 text-fg-muted" />
{t("backup.title")}
</span>
}
description={t("backup.description")}
>
<div className="py-3 space-y-3">
<label className="block text-xs text-fg-muted">
{t("backup.folder")}
<input
className="input mt-1 w-full font-mono text-xs"
placeholder="/mnt/hdd/agenthub-backups"
value={dir}
disabled={!state || owned?.dir}
onChange={(e) => {
setDir(e.target.value);
setError(null);
}}
/>
</label>
<p className="text-xs text-fg-muted">{t("backup.folderHint")}</p>
<div className="flex flex-wrap items-end gap-3">
<div className="w-28">
<Select
value={String(hour)}
onChange={(v) => setHour(Number(v))}
label={t("backup.hour")}
disabled={!state || owned?.hour}
options={HOURS.map((h) => ({ value: String(h), label: `${pad(h)}:00` }))}
/>
</div>
<label className="text-xs text-fg-muted">
{t("backup.keep")}
<input
type="number"
min={1}
max={365}
step={1}
className="input mt-1 block w-24 text-sm"
value={keep}
disabled={!state || owned?.keep}
onChange={(e) => setKeep(e.target.value)}
/>
</label>
</div>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
className="btn-primary px-4 py-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
disabled={saving || !changed || !keepValid}
onClick={() => void save()}
>
{saving ? t("homeDir.saving") : t("homeDir.save")}
</button>
<button
type="button"
className="btn-ghost text-sm disabled:opacity-50 disabled:cursor-not-allowed"
disabled={!state?.dir || running || changed}
onClick={() => void runNow()}
>
<DatabaseBackup className={`w-4 h-4 ${running ? "animate-pulse" : ""}`} />{" "}
{running ? t("backup.running") : t("backup.runNow")}
</button>
</div>
{error && <p className="text-xs text-status-danger">{error}</p>}
<p className="text-xs text-fg-secondary">
{newest
? t("backup.newest", {
time: formatDateTime(newest.mtime),
size: formatBytes(newest.size),
n: state?.files.length,
total: formatBytes(total),
})
: state?.dir
? t("backup.none")
: t("backup.off")}
</p>
{state?.last && !state.last.ok && (
<p className="text-xs text-status-danger">
{t("backup.lastFailed", {
time: formatDateTime(state.last.at),
error: state.last.error,
})}
</p>
)}
{state?.dir_error && <p className="text-xs text-status-danger">{state.dir_error}</p>}
<p className="text-xs text-fg-muted">{t("backup.restoreHint")}</p>
{(owned?.dir || owned?.hour || owned?.keep) && (
<p className="text-xs text-status-warning flex items-start gap-1.5">
<AlertTriangle className="w-3.5 h-3.5 flex-shrink-0 mt-px" />
{t("privacy.envOwned")}
</p>
)}
</div>
</SettingsSection>
);
}
@@ -0,0 +1,33 @@
/**
* @file ChatUiSettings — Settings → Appearance → Chat UI: whether a Claude or
* Codex session opens as a chat like Claude Desktop (`PaneChat`) instead of
* its terminal. Saved per user on the server (`lib/chatUi.ts`), so each
* account on a shared dashboard keeps its own choice on any browser.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useTranslation } from "react-i18next";
import { saveChatUi, useChatUi } from "../../lib/chatUi";
import { toast } from "../../lib/toast";
import { SettingsSection, SettingsSwitchRow } from "./SettingsRow";
export function ChatUiSettings() {
const { t } = useTranslation("settings");
const chatUi = useChatUi();
return (
<SettingsSection id="chat-ui" title={t("chatUi.title")} description={t("chatUi.description")}>
<SettingsSwitchRow
label={t("chatUi.label")}
description={t("chatUi.labelDesc")}
checked={chatUi}
onChange={(on) =>
saveChatUi(on).catch((err: unknown) =>
toast.error(
t("chatUi.saveFailed", { message: err instanceof Error ? err.message : "" })
)
)
}
/>
</SettingsSection>
);
}
+47 -39
View File
@@ -33,6 +33,7 @@ import { confirmDialog } from "../../lib/confirm";
import { toast } from "../../lib/toast";
import { usePets, useWornPet } from "../Sagi/usePets";
import type { PetView } from "../../lib/api";
import { usePermissions } from "../AuthGate";
const PREVIEW = 72;
@@ -121,6 +122,8 @@ export function PetSettings() {
const { t } = useTranslation("settings");
const worn = useWornPet();
const { list, own, installed, busy, error, wear, install, add, remove } = usePets();
// Pets are stored for the whole install: adding or deleting one is an admin's.
const { isAdmin } = usePermissions();
const imageInput = useRef<HTMLInputElement>(null);
const bundleInput = useRef<HTMLInputElement>(null);
const wornPet = list?.pets.find((p) => p.id === worn.pet.id);
@@ -219,6 +222,7 @@ export function PetSettings() {
/>
{list?.featured.map((f) => {
const have = installed.get(f.id);
if (!have && !isAdmin) return null;
return (
<PetCard
key={f.id}
@@ -252,50 +256,54 @@ export function PetSettings() {
disabled={busy !== null}
preview={<Preview pet={p} size={PREVIEW} />}
onPick={() => wear(p)}
onDelete={() => void remove(p)}
onDelete={isAdmin ? () => void remove(p) : undefined}
deleteLabel={t("sagi.pet.delete", { name: p.displayName })}
/>
))}
</div>
<div className="mt-4 flex flex-wrap items-center gap-2">
<button
type="button"
className="btn-ghost text-xs"
disabled={busy !== null}
onClick={() => imageInput.current?.click()}
>
{busy === "image" ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Upload className="h-3.5 w-3.5" />
)}
{t("sagi.pet.upload")}
</button>
<button
type="button"
className="btn-ghost text-xs"
disabled={busy !== null}
onClick={() => bundleInput.current?.click()}
>
{busy === "bundle" ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<PackageOpen className="h-3.5 w-3.5" />
)}
{t("sagi.pet.import")}
</button>
<a
href="https://codex-pets.net/"
target="_blank"
rel="noreferrer noopener"
className="btn-ghost text-xs"
>
<ExternalLink className="h-3.5 w-3.5" />
{t("sagi.pet.browse")}
</a>
</div>
<p className="mt-2 text-xs text-fg-muted">{t("sagi.uploadHint")}</p>
{isAdmin && (
<>
<div className="mt-4 flex flex-wrap items-center gap-2">
<button
type="button"
className="btn-ghost text-xs"
disabled={busy !== null}
onClick={() => imageInput.current?.click()}
>
{busy === "image" ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Upload className="h-3.5 w-3.5" />
)}
{t("sagi.pet.upload")}
</button>
<button
type="button"
className="btn-ghost text-xs"
disabled={busy !== null}
onClick={() => bundleInput.current?.click()}
>
{busy === "bundle" ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<PackageOpen className="h-3.5 w-3.5" />
)}
{t("sagi.pet.import")}
</button>
<a
href="https://codex-pets.net/"
target="_blank"
rel="noreferrer noopener"
className="btn-ghost text-xs"
>
<ExternalLink className="h-3.5 w-3.5" />
{t("sagi.pet.browse")}
</a>
</div>
<p className="mt-2 text-xs text-fg-muted">{t("sagi.uploadHint")}</p>
</>
)}
{error && (
<p role="alert" className="mt-2 text-xs text-status-danger">
{error}
@@ -5,7 +5,8 @@
* a homepage link, Refresh re-detects, every updater gets Update without a version
* lookup, and Install/Update confirm the exact command, then show the polled
* job log, folded once it is done. An installed row's right-click menu runs
* the same Set default / enable switch as its controls.
* the same Set default / enable switch as its controls. A click on a row (or
* its name link) opens the tool's settings page; its buttons do not.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -13,6 +14,7 @@ import { act, fireEvent, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "i18next";
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
import { AiToolsSettings } from "../AiToolsSettings";
import { api, type AiTool, type AiToolJob, type AiToolsView } from "../../../lib/api";
import { __resetAiTools } from "../../../hooks/useAiTools";
@@ -92,9 +94,48 @@ const job = (over: Partial<AiToolJob> = {}): AiToolJob => ({
...over,
});
function Where() {
const loc = useLocation();
return <span data-testid="where">{loc.pathname}</span>;
}
describe("AiToolsSettings", () => {
it("opens a tool's settings from its row or name, never from its buttons", async () => {
const user = userEvent.setup();
render(
<MemoryRouter initialEntries={["/settings/agents"]}>
<Routes>
<Route
path="*"
element={
<>
<AiToolsSettings
settingsHref={(id) => (id === "gemini" ? `/settings/agents/${id}` : undefined)}
/>
<Where />
</>
}
/>
</Routes>
</MemoryRouter>
);
const gemini = await screen.findByTestId("ai-tool-gemini");
await user.click(within(gemini).getByText("Set default"));
expect(screen.getByTestId("where").textContent).toBe("/settings/agents");
await user.click(gemini);
expect(screen.getByTestId("where").textContent).toBe("/settings/agents/gemini");
expect(within(gemini).getByRole("link", { name: "Gemini" })).toHaveAttribute(
"href",
"/settings/agents/gemini"
);
// A tool without a page has no link.
expect(
within(screen.getByTestId("ai-tool-claude")).queryByRole("link", { name: "Claude Code" })
).toBeNull();
});
it("splits installed tools from the ones available to install", async () => {
render(<AiToolsSettings />);
render(<AiToolsSettings />, { wrapper: MemoryRouter });
expect(await screen.findByText("Installed (3 detected)")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Available to install (1)" }));
const claude = screen.getByTestId("ai-tool-claude");
@@ -107,7 +148,7 @@ describe("AiToolsSettings", () => {
it("toggles a tool and sets the default through the API", async () => {
const user = userEvent.setup();
render(<AiToolsSettings />);
render(<AiToolsSettings />, { wrapper: MemoryRouter });
const gemini = await screen.findByTestId("ai-tool-gemini");
await user.click(within(gemini).getByRole("switch", { name: "Gemini enabled" }));
expect(api.aiTools.update).toHaveBeenCalledWith("gemini", { enabled: false });
@@ -121,7 +162,7 @@ describe("AiToolsSettings", () => {
it("refreshes detection", async () => {
const user = userEvent.setup();
render(<AiToolsSettings />);
render(<AiToolsSettings />, { wrapper: MemoryRouter });
await screen.findByTestId("ai-tool-claude");
await user.click(screen.getByRole("button", { name: "Refresh" }));
expect(api.aiTools.refresh).toHaveBeenCalledOnce();
@@ -129,7 +170,7 @@ describe("AiToolsSettings", () => {
it("sets the default and disables a tool from its right-click menu", async () => {
const user = userEvent.setup();
render(<AiToolsSettings />);
render(<AiToolsSettings />, { wrapper: MemoryRouter });
const gemini = await screen.findByTestId("ai-tool-gemini");
await user.pointer({ keys: "[MouseRight]", target: gemini });
await user.click(screen.getByRole("menuitem", { name: "Set default" }));
@@ -182,7 +223,7 @@ describe("AiToolsSettings — install and update", () => {
vi.mocked(api.aiTools.job)
.mockResolvedValueOnce(null)
.mockResolvedValue(job({ status: "ok", exitCode: 0, log: "added 1 package\n" }));
render(<AiToolsSettings />);
render(<AiToolsSettings />, { wrapper: MemoryRouter });
await user.click(await screen.findByRole("button", { name: /Available to install/ }));
const qwen = await screen.findByTestId("ai-tool-qwen");
await user.click(within(qwen).getByRole("button", { name: "Install" }));
@@ -212,7 +253,7 @@ describe("AiToolsSettings — install and update", () => {
it("names the vendor script for a script install", async () => {
const user = userEvent.setup();
render(<AiToolsSettings />);
render(<AiToolsSettings />, { wrapper: MemoryRouter });
await openAvailable();
const cursor = await screen.findByTestId("ai-tool-cursor");
await user.click(within(cursor).getByRole("button", { name: "Install" }));
@@ -222,7 +263,7 @@ describe("AiToolsSettings — install and update", () => {
});
it("offers Update on every installed tool with an updater", async () => {
render(<AiToolsSettings />);
render(<AiToolsSettings />, { wrapper: MemoryRouter });
for (const id of ["claude", "codex", "gemini"]) {
const row = await screen.findByTestId(`ai-tool-${id}`);
expect(within(row).getByRole("button", { name: "Update" })).toBeInTheDocument();
@@ -231,7 +272,7 @@ describe("AiToolsSettings — install and update", () => {
it("Update all lists every installed tool's updater", async () => {
const user = userEvent.setup();
render(<AiToolsSettings />);
render(<AiToolsSettings />, { wrapper: MemoryRouter });
await user.click(await screen.findByRole("button", { name: "Update all (3)" }));
const cmd = screen.getByTestId("ai-tool-confirm-command");
expect(cmd).toHaveTextContent("claude update");
@@ -241,13 +282,13 @@ describe("AiToolsSettings — install and update", () => {
it("hides Update all when no tool has an updater", async () => {
vi.mocked(api.aiTools.list).mockResolvedValue(VIEW);
render(<AiToolsSettings />);
render(<AiToolsSettings />, { wrapper: MemoryRouter });
await screen.findByTestId("ai-tool-claude");
expect(screen.queryByRole("button", { name: /Update all/ })).toBeNull();
});
it("falls back to the letter when the icon fails", async () => {
const { container } = render(<AiToolsSettings />);
const { container } = render(<AiToolsSettings />, { wrapper: MemoryRouter });
await openAvailable();
await screen.findByTestId("ai-tool-qwen");
const img = container.querySelector('img[src="/api/ai-tools/qwen/icon"]')!;
@@ -2,7 +2,8 @@
* @file Tests Settings → About's Quit AgentHub button (issue #65): it asks
* first, says when start-at-login will bring the dashboard back, and only a
* confirmed ask reaches POST /api/settings/quit. Plus the status-area icon
* switch, which goes straight to PUT /api/settings/tray.
* switch, which goes straight to PUT /api/settings/tray, and Uninstall
* AgentHub, which also asks first.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
@@ -17,6 +18,7 @@ vi.mock("../../../lib/api", () => ({
autostart: { get: vi.fn(), set: vi.fn() },
tray: { get: vi.fn(), set: vi.fn() },
quit: vi.fn(),
uninstall: vi.fn(),
},
},
}));
@@ -65,3 +67,30 @@ describe("Quit AgentHub", () => {
await waitFor(() => expect(icon).toBeChecked());
});
});
describe("Uninstall AgentHub", () => {
beforeEach(() => {
vi.mocked(api.settings.autostart.get).mockResolvedValue(status as never);
vi.mocked(api.settings.tray.get).mockResolvedValue(trayOff);
vi.mocked(api.settings.uninstall)
.mockReset()
.mockResolvedValue({ ok: true, log: "/tmp/agenthub-uninstall-1.log" });
});
it("does nothing when the ask is cancelled", async () => {
vi.spyOn(confirm, "confirmDialog").mockResolvedValue(false);
render(<AutostartSettings />);
fireEvent.click(screen.getByTestId("uninstall-agenthub"));
await waitFor(() => expect(confirm.confirmDialog).toHaveBeenCalled());
expect(api.settings.uninstall).not.toHaveBeenCalled();
});
it("uninstalls once confirmed, saying the database goes too", async () => {
const ask = vi.spyOn(confirm, "confirmDialog").mockResolvedValue(true);
render(<AutostartSettings />);
fireEvent.click(screen.getByTestId("uninstall-agenthub"));
await waitFor(() => expect(api.settings.uninstall).toHaveBeenCalledTimes(1));
expect(ask.mock.calls[0]![0].message).toMatch(/database/);
expect(screen.getByTestId("uninstall-agenthub")).toBeDisabled();
});
});
@@ -0,0 +1,65 @@
/**
* @file BackupSettings.test.tsx
* @description Settings → Data → Backup: a save sends only the fields that
* changed, "Back up now" is off until a folder is saved, and the newest backup
* in the folder is shown.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "i18next";
const { backup } = vi.hoisted(() => ({
backup: { get: vi.fn(), save: vi.fn(), run: vi.fn() },
}));
vi.mock("../../../lib/api", () => ({ api: { settings: { backup } } }));
import { BackupSettings } from "../BackupSettings";
const off = {
dir: "",
hour: 2,
keep: 7,
running: false,
last: null,
files: [],
dir_error: null,
env_owned: { dir: false, hour: false, keep: false },
};
beforeEach(() => {
i18n.changeLanguage("en");
vi.clearAllMocks();
});
describe("BackupSettings", () => {
it("saves only the folder when only the folder changed, then allows a run", async () => {
const user = userEvent.setup();
backup.get.mockResolvedValue(off);
backup.save.mockImplementation((p) => Promise.resolve({ ok: true, ...off, ...p }));
render(<BackupSettings />);
const run = await screen.findByRole("button", { name: /Back up now/ });
expect(run).toBeDisabled();
expect(screen.getByText("Backups are off.")).toBeInTheDocument();
await user.type(screen.getByLabelText("Backup folder"), "/mnt/hdd/b");
await user.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(backup.save).toHaveBeenCalledWith({ dir: "/mnt/hdd/b" }));
await waitFor(() => expect(run).toBeEnabled());
});
it("shows the newest backup", async () => {
backup.get.mockResolvedValue({
...off,
dir: "/mnt/hdd/b",
files: [
{ name: "agenthub-20261008-020000.db", size: 2048, mtime: "2026-10-08T02:00:30Z" },
{ name: "agenthub-20261007-020000.db", size: 1024, mtime: "2026-10-07T02:00:30Z" },
],
});
render(<BackupSettings />);
expect(await screen.findByText(/2\.0 KB · 2 kept, 3\.0 KB in all/)).toBeInTheDocument();
});
});