feat(settings): data-safe editors, deep-linked search, Data/About/Cost pages

- EditorModal loads once per path (a parent re-render no longer reloads the
  file over the user's typing), refuses Save after a failed load, opens a
  truncated (>256 KB) file read-only, and asks before overwriting a file
  that changed on disk or could not be re-read.
- Settings/Hooks editors unset only keys from the snapshot taken at Edit,
  warn when the file changed underneath, keep a renamed key's value, and
  refuse a new key named like a redacted one (env) or a read-only hook
  event, which used to replace the secret/hooks.
- Project scope shows its resolved folder and is disabled inside the
  plugin cache. Account-profile, pricing-row and pet deletes confirm;
  two-click actions disarm after 3 s; unsaved drafts ask before closing.
- Search indexes every Claude Code / Codex sub-tab, opens it via ?tab= and
  scrolls to + highlights the row; the active config tab is in the URL.
- General splits into General / Data / About; Pricing moves to a Cost
  group with one shared load; config tabs group as Content / Extensions /
  Config with arrow-key roving; Promise.allSettled per tab.
- Data: browser-preferences export/import (allowlisted keys), per-section
  reset, housekeeping dry-run first, clear-all behind a typed phrase.
  Server: purge days must be a whole number >= 1 (INVALID_PURGE_DAYS) and
  dry_run returns exactly what the real run would delete.
- Alerts can be edited; webhooks/alerts/remote-source failures toast;
  copies go through copyText; modals get dialog roles and useDialog.
- Sagi: Ctrl/Cmd+B only when shown and not typing, default spot clear of
  row controls, arrow keys move it, reduced motion honoured; the dead
  /run launch link goes to the workspace.
This commit is contained in:
2026-10-05 13:22:08 +07:00
parent 03f1673fce
commit 6715fbd971
68 changed files with 4817 additions and 1374 deletions
+90 -29
View File
@@ -70,8 +70,8 @@ import {
BellOff,
Check,
CheckCheck,
ChevronDown,
ListChecks,
Pencil,
Plus,
RefreshCw,
Trash2,
@@ -88,6 +88,7 @@ import { Checkbox } from "./Checkbox";
import { FieldHelp } from "./FieldHelp";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "./ContextMenu";
import { timeAgo } from "../lib/format";
import { toast } from "../lib/toast";
import type { AlertEvent, AlertRule, AlertRuleType, WSMessage } from "../lib/types";
const PAGE_SIZE = 25;
@@ -155,6 +156,27 @@ const EMPTY_FORM: RuleFormState = {
cooldown_seconds: "300",
};
/** A rule's stored values as form fields, for editing it. */
function formFromRule(rule: AlertRule): RuleFormState {
const c = rule.config;
const str = (v: unknown, fallback: string) => (v == null ? fallback : String(v));
return {
name: rule.name,
rule_type: rule.rule_type,
event_type: str(c.event_type, ""),
tool_name: str(c.tool_name, ""),
summary_contains: str(c.summary_contains, ""),
count: str(c.count, EMPTY_FORM.count),
window_minutes: str(c.window_minutes, EMPTY_FORM.window_minutes),
minutes: str(c.minutes, EMPTY_FORM.minutes),
status: c.status === "waiting" ? "waiting" : "working",
total_tokens: str(c.total_tokens, EMPTY_FORM.total_tokens),
cooldown_seconds: String(rule.cooldown_seconds),
};
}
const errText = (err: unknown) => (err instanceof Error ? err.message : String(err));
function buildConfig(form: RuleFormState): AlertRule["config"] {
switch (form.rule_type) {
case "event_pattern": {
@@ -217,6 +239,8 @@ export function AlertsNotifications() {
const [loadingRules, setLoadingRules] = useState(true);
const [formOpen, setFormOpen] = useState(false);
const [form, setForm] = useState<RuleFormState>(EMPTY_FORM);
// The rule being edited, or null while the form creates a new one.
const [editingId, setEditingId] = useState<string | null>(null);
const [formError, setFormError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [confirmRule, setConfirmRule] = useState<AlertRule | null>(null);
@@ -241,11 +265,11 @@ export function AlertsNotifications() {
const res = await api.alerts.rules.list();
setRules(res.rules);
} catch (err) {
console.error("Failed to load alert rules:", err);
toast.error(t("errors.loadRules", { error: errText(err) }));
} finally {
setLoadingRules(false);
}
}, []);
}, [t]);
const loadAlerts = useCallback(async () => {
setLoadingAlerts(true);
@@ -259,11 +283,11 @@ export function AlertsNotifications() {
setTotal(res.total);
setUnacked(res.unacked);
} catch (err) {
console.error("Failed to load alerts:", err);
toast.error(t("errors.loadAlerts", { error: errText(err) }));
} finally {
setLoadingAlerts(false);
}
}, [unackedOnly]);
}, [unackedOnly, t]);
const loadMore = useCallback(async () => {
try {
@@ -276,9 +300,9 @@ export function AlertsNotifications() {
setTotal(res.total);
setUnacked(res.unacked);
} catch (err) {
console.error("Failed to load more alerts:", err);
toast.error(t("errors.loadAlerts", { error: errText(err) }));
}
}, [unackedOnly, alerts.length]);
}, [unackedOnly, alerts.length, t]);
useEffect(() => {
loadRules();
@@ -300,20 +324,33 @@ export function AlertsNotifications() {
const set = (patch: Partial<RuleFormState>) => setForm((prev) => ({ ...prev, ...patch }));
const onCreateRule = async () => {
if (saving) return;
const closeForm = () => {
setForm(EMPTY_FORM);
setEditingId(null);
setFormOpen(false);
setFormError(null);
};
const openEditRule = (rule: AlertRule) => {
setForm(formFromRule(rule));
setEditingId(rule.id);
setFormError(null);
setFormOpen(true);
};
const onSaveRule = async () => {
if (saving || !canSubmit) return;
setSaving(true);
setFormError(null);
try {
const cooldown = parseInt(form.cooldown_seconds, 10);
await api.alerts.rules.create({
const body = {
name: form.name.trim(),
rule_type: form.rule_type,
config: buildConfig(form),
cooldown_seconds: Number.isFinite(cooldown) && cooldown >= 0 ? cooldown : 300,
});
setForm(EMPTY_FORM);
setFormOpen(false);
cooldown_seconds: cooldownVal,
};
if (editingId) await api.alerts.rules.update(editingId, body);
else await api.alerts.rules.create({ ...body, rule_type: form.rule_type });
closeForm();
loadRules();
} catch (err) {
setFormError(err instanceof Error ? err.message : String(err));
@@ -327,7 +364,7 @@ export function AlertsNotifications() {
await api.alerts.rules.update(rule.id, { enabled: !rule.enabled });
loadRules();
} catch (err) {
console.error("Failed to toggle alert rule:", err);
toast.error(t("errors.toggleRule", { error: errText(err) }));
}
};
@@ -338,7 +375,8 @@ export function AlertsNotifications() {
loadRules();
loadAlerts();
} catch (err) {
console.error("Failed to delete alert rule:", err);
setConfirmRule(null);
toast.error(t("errors.deleteRule", { error: errText(err) }));
}
};
@@ -347,7 +385,7 @@ export function AlertsNotifications() {
await api.alerts.ack(id);
loadAlerts();
} catch (err) {
console.error("Failed to acknowledge alert:", err);
toast.error(t("errors.ack", { error: errText(err) }));
}
};
@@ -356,7 +394,7 @@ export function AlertsNotifications() {
await api.alerts.ackAll();
loadAlerts();
} catch (err) {
console.error("Failed to acknowledge alerts:", err);
toast.error(t("errors.ack", { error: errText(err) }));
}
};
@@ -366,8 +404,12 @@ export function AlertsNotifications() {
const tokensVal = parseInt(form.total_tokens, 10);
const countVal = parseInt(form.count, 10);
const windowVal = parseFloat(form.window_minutes);
// Cooldown is a whole number of seconds ≥ 0 — never silently replaced by 300.
const cooldownOk = /^\d+$/.test(form.cooldown_seconds.trim());
const cooldownVal = cooldownOk ? parseInt(form.cooldown_seconds, 10) : 0;
const canSubmit =
form.name.trim().length > 0 &&
cooldownOk &&
(form.rule_type !== "event_pattern" ||
(Boolean(form.event_type.trim() || form.tool_name.trim() || form.summary_contains.trim()) &&
Number.isFinite(countVal) &&
@@ -440,8 +482,11 @@ export function AlertsNotifications() {
</div>
<button
onClick={() => {
setFormOpen((open) => !open);
setFormError(null);
if (formOpen) closeForm();
else {
setFormOpen(true);
setFormError(null);
}
}}
className="btn-ghost border border-border inline-flex items-center gap-1.5 text-xs flex-shrink-0"
>
@@ -475,7 +520,9 @@ export function AlertsNotifications() {
<select
value={form.rule_type}
onChange={(e) => set({ rule_type: e.target.value as AlertRuleType })}
className="input w-full appearance-none pr-8"
disabled={!!editingId}
title={editingId ? t("rules.typeFixed") : undefined}
className="select w-full"
>
{RULE_TYPES.map((type) => (
<option key={type} value={type}>
@@ -483,7 +530,6 @@ export function AlertsNotifications() {
</option>
))}
</select>
<ChevronDown className="w-3.5 h-3.5 absolute right-2.5 top-1/2 -translate-y-1/2 text-fg-muted pointer-events-none" />
</div>
</label>
</div>
@@ -586,12 +632,11 @@ export function AlertsNotifications() {
<select
value={form.status}
onChange={(e) => set({ status: e.target.value as "working" | "waiting" })}
className="input w-full appearance-none pr-8"
className="select w-full"
>
<option value="working">working</option>
<option value="waiting">waiting</option>
</select>
<ChevronDown className="w-3.5 h-3.5 absolute right-2.5 top-1/2 -translate-y-1/2 text-fg-muted pointer-events-none" />
</div>
</label>
)}
@@ -644,18 +689,25 @@ export function AlertsNotifications() {
<input
type="number"
min={0}
step={1}
value={form.cooldown_seconds}
onChange={(e) => set({ cooldown_seconds: e.target.value })}
aria-invalid={!cooldownOk}
className="input w-40"
/>
{!cooldownOk && (
<span className="mt-1 block text-[11px] text-status-danger">
{t("rules.cooldownInvalid")}
</span>
)}
</label>
<button
onClick={onCreateRule}
onClick={onSaveRule}
disabled={!canSubmit || saving}
className="btn-primary inline-flex items-center gap-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
<Plus className="w-3.5 h-3.5" />
{saving ? t("rules.saving") : t("rules.create")}
{editingId ? <Check className="w-3.5 h-3.5" /> : <Plus className="w-3.5 h-3.5" />}
{saving ? t("rules.saving") : editingId ? t("rules.save") : t("rules.create")}
</button>
</div>
{formError && <p className="text-xs text-status-danger">{formError}</p>}
@@ -710,6 +762,14 @@ export function AlertsNotifications() {
>
{rule.enabled ? t("rules.enabled") : t("rules.disabled")}
</button>
<button
onClick={() => openEditRule(rule)}
className="p-1.5 rounded-md text-fg-muted hover:text-fg-primary hover:bg-surface-3 transition-colors"
title={t("rules.edit")}
aria-label={t("rules.edit")}
>
<Pencil className="w-3.5 h-3.5" />
</button>
<button
onClick={() => setConfirmRule(rule)}
className="p-1.5 rounded-md text-fg-muted hover:text-status-danger hover:bg-status-danger/10 transition-colors"
@@ -733,6 +793,7 @@ export function AlertsNotifications() {
<MenuItem onSelect={pickRule(onToggleRule)}>
{ruleCtx.menu.item.enabled ? t("rules.disable") : t("rules.enable")}
</MenuItem>
<MenuItem onSelect={pickRule(openEditRule)}>{t("rules.edit")}</MenuItem>
<MenuSeparator />
<MenuItem danger onSelect={pickRule(setConfirmRule)}>
{t("rules.delete")}
+22 -20
View File
@@ -294,7 +294,7 @@ export function ImportHistory() {
className="w-full flex items-center justify-between px-4 py-3 bg-surface-2 hover:bg-surface-3 transition-colors"
>
<span className="flex items-center gap-2 text-xs font-semibold text-fg-secondary uppercase tracking-wider">
<ListChecks className="w-3.5 h-3.5 text-blue-500" />
<ListChecks className="w-3.5 h-3.5 text-accent" />
{t("import.instructions")}
</span>
<span className="text-[11px] text-fg-muted">{instructionsOpen ? "▾" : "▸"}</span>
@@ -455,8 +455,8 @@ export function ImportHistory() {
{mode === "upload" && (
<div className="space-y-3">
<div
onClick={() => fileInputRef.current?.click()}
<label
htmlFor="import-upload-files"
onDragOver={(e) => {
e.preventDefault();
setDragging(true);
@@ -467,9 +467,9 @@ export function ImportHistory() {
setDragging(false);
onSelectFiles(e.dataTransfer.files);
}}
className={`border-2 border-dashed rounded-lg px-4 py-8 text-center cursor-pointer transition-colors ${
className={`block border-2 border-dashed rounded-lg px-4 py-8 text-center cursor-pointer transition-colors focus-within:ring-2 focus-within:ring-accent/50 ${
dragging
? "border-blue-500 bg-blue-600/5"
? "border-accent bg-accent/5"
: "border-border hover:border-border-light bg-surface-1"
}`}
>
@@ -477,14 +477,15 @@ export function ImportHistory() {
<p className="text-sm text-fg-secondary">{t("import.dropzoneHint")}</p>
<p className="text-[11px] text-fg-muted mt-1">{t("import.dropzoneSub")}</p>
<input
id="import-upload-files"
ref={fileInputRef}
type="file"
multiple
accept=".jsonl,.json,.zip,.tar,.tgz,.gz,application/gzip,application/zip,application/x-tar,application/octet-stream"
onChange={(e) => onSelectFiles(e.target.files)}
className="hidden"
className="sr-only"
/>
</div>
</label>
{files.length > 0 && (
<div className="flex flex-wrap items-center justify-between gap-2 text-xs bg-surface-3 rounded-md px-3 py-2">
<span className="text-fg-secondary">
@@ -522,8 +523,8 @@ export function ImportHistory() {
{mode === "backup" && (
<div className="space-y-3">
<div
onClick={() => backupInputRef.current?.click()}
<label
htmlFor="import-backup-file"
onDragOver={(e) => {
e.preventDefault();
setDragging(true);
@@ -535,9 +536,9 @@ export function ImportHistory() {
const f = e.dataTransfer.files?.[0];
if (f) setBackupFile(f);
}}
className={`border-2 border-dashed rounded-lg px-4 py-8 text-center cursor-pointer transition-colors ${
className={`block border-2 border-dashed rounded-lg px-4 py-8 text-center cursor-pointer transition-colors focus-within:ring-2 focus-within:ring-accent/50 ${
dragging
? "border-blue-500 bg-blue-600/5"
? "border-accent bg-accent/5"
: "border-border hover:border-border-light bg-surface-1"
}`}
>
@@ -545,13 +546,14 @@ export function ImportHistory() {
<p className="text-sm text-fg-secondary">{t("import.backupHint")}</p>
<p className="text-[11px] text-fg-muted mt-1">{t("import.backupSub")}</p>
<input
id="import-backup-file"
ref={backupInputRef}
type="file"
accept=".json,application/json"
onChange={(e) => setBackupFile(e.target.files?.[0] || null)}
className="hidden"
className="sr-only"
/>
</div>
</label>
{backupFile && (
<div className="flex flex-wrap items-center justify-between gap-2 text-xs bg-surface-3 rounded-md px-3 py-2">
<span className="text-fg-secondary min-w-0">
@@ -591,7 +593,7 @@ export function ImportHistory() {
{/* In-flight progress */}
{running && progressText && (
<div className="flex items-center gap-2 text-xs text-fg-secondary bg-surface-2 border border-border rounded-md px-3 py-2">
<Loader2 className="w-3.5 h-3.5 animate-spin text-blue-500 flex-shrink-0" />
<Loader2 className="w-3.5 h-3.5 animate-spin text-accent flex-shrink-0" />
<span className="truncate">{progressText}</span>
{progress?.current && (
<code className="font-mono text-[11px] text-fg-muted truncate">
@@ -625,7 +627,7 @@ export function ImportHistory() {
<ResultStat
label={t("import.result.backfilled", { count: result.backfilled ?? 0 })}
value={result.backfilled ?? 0}
color="text-blue-700 dark:text-blue-400"
color="text-accent"
/>
<ResultStat
label={t("import.result.skipped", { count: result.skipped })}
@@ -672,7 +674,7 @@ export function ImportHistory() {
<ResultStat
label={t("import.backupResult.events", { count: backupResult.events })}
value={backupResult.events}
color="text-violet-700 dark:text-violet-300"
color="text-fg-primary"
/>
<ResultStat
label={t("import.backupResult.pricing", { count: backupResult.model_pricing })}
@@ -728,16 +730,16 @@ function ModeButton({
}) {
return (
<button
type="button"
aria-pressed={active}
onClick={onClick}
className={`text-left p-3 rounded-lg border transition-colors ${
active
? "border-blue-600/40 bg-blue-600/10"
: "border-border bg-surface-2 hover:bg-surface-3"
active ? "border-accent/40 bg-accent/10" : "border-border bg-surface-2 hover:bg-surface-3"
}`}
>
<div
className={`flex items-center gap-1.5 text-xs font-medium mb-1 ${
active ? "text-blue-700 dark:text-blue-400" : "text-fg-secondary"
active ? "text-accent" : "text-fg-secondary"
}`}
>
{icon}
+37 -8
View File
@@ -82,10 +82,14 @@ import {
import { api } from "../lib/api";
import type { RemoteSource, RemoteSourceInput } from "../lib/api";
import { eventBus } from "../lib/eventBus";
import { toast } from "../lib/toast";
import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
import { useDataScope } from "../lib/dataScope";
import type { ScopeMode } from "../lib/dataScope";
/** Data-scope choices in display order (the radio group's arrow-key order). */
const SCOPE_MODES: ScopeMode[] = ["all", "local", "selected"];
const EMPTY_FORM: RemoteSourceInput = {
label: "",
host: "",
@@ -258,8 +262,8 @@ export function RemoteSources() {
try {
await api.remoteSources.update(s.id, { enabled: !s.enabled });
load();
} catch {
/* surfaced via reload */
} catch (err) {
toast.error(err);
} finally {
setBusyId(null);
}
@@ -301,8 +305,10 @@ export function RemoteSources() {
try {
await api.remoteSources.syncAll();
load();
} catch {
/* per-source errors surface via each source's status on reload */
} catch (err) {
// Per-source errors still surface on each source's status after the reload.
toast.error(err);
load();
} finally {
setSyncingAll(false);
}
@@ -316,8 +322,9 @@ export function RemoteSources() {
await api.remoteSources.remove(id, purge);
setConfirmDelete(null);
load();
} catch {
/* surfaced via reload */
} catch (err) {
setConfirmDelete(null);
toast.error(err);
} finally {
setBusyId(null);
}
@@ -358,7 +365,26 @@ export function RemoteSources() {
</p>
{/* Card selector — one card per scope mode, each with a short explanation
so the choice is self-describing rather than a bare radio label. */}
<div className="grid gap-2.5 sm:grid-cols-3" role="radiogroup" aria-label="Data scope">
<div
className="grid gap-2.5 sm:grid-cols-3"
role="radiogroup"
aria-label={t("remoteSources.scopeTitle", "Data scope")}
onKeyDown={(e) => {
// Arrow keys move the choice, like a native radio group.
const step =
e.key === "ArrowRight" || e.key === "ArrowDown"
? 1
: e.key === "ArrowLeft" || e.key === "ArrowUp"
? -1
: 0;
if (!step) return;
e.preventDefault();
const i = SCOPE_MODES.indexOf(scope.mode);
const next = SCOPE_MODES[(i + step + SCOPE_MODES.length) % SCOPE_MODES.length]!;
setMode(next);
e.currentTarget.querySelector<HTMLElement>(`[data-mode="${next}"]`)?.focus();
}}
>
{(
[
{
@@ -394,8 +420,11 @@ export function RemoteSources() {
return (
<button
key={mode}
type="button"
role="radio"
aria-checked={active}
tabIndex={active ? 0 : -1}
data-mode={mode}
onClick={() => setMode(mode)}
className={`relative text-left rounded-xl border p-3 transition-colors ${
active
@@ -492,7 +521,7 @@ export function RemoteSources() {
</label>
<input
className="input w-full"
placeholder="Dev box"
placeholder={t("remoteSources.fieldLabelPlaceholder", "Dev box")}
value={form.label}
onChange={(e) => setForm((f) => ({ ...f, label: e.target.value }))}
/>
+2
View File
@@ -28,6 +28,7 @@ import {
} from "lucide-react";
import { PET_SIZE, sagiPrefs, type PetChoice } from "./prefs";
import { usePets } from "./usePets";
import { toast } from "../../lib/toast";
const ITEM =
"flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm text-fg-secondary hover:bg-surface-4 hover:text-fg-primary disabled:opacity-50";
@@ -84,6 +85,7 @@ export function PetMenu({
className={ITEM}
onClick={() => {
sagiPrefs.setEnabled(false);
toast.info(t("sagi.pet.hiddenToast"));
onClose();
}}
>
+33 -12
View File
@@ -8,8 +8,11 @@
* useSagiPosition, and the bubble/panel render in a self-clamping flyout so
* they never spill off any screen edge regardless of where the mascot is docked.
*
* The "do the job" path reuses the existing Run page: unmatched Ask queries
* deep-link to /run?prompt=…&autostart=1 - no new LLM backend.
* The "do the job" path: an unmatched Ask query starts a claude session in
* the "Sagi" workspace carrying it as the first prompt, then opens that
* workspace's session view (`/workspace?lane=<id>&run=<runId>`) — no new LLM
* backend. ⌘B/Ctrl+B is taken only while the mascot is shown and focus is not
* in an editable field (a terminal's Ctrl+B is tmux's prefix).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -70,6 +73,7 @@ import {
type ReactNode,
} from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { SagiAvatar } from "./SagiAvatar";
import { PetSprite } from "./PetSprite";
import { PetMenu } from "./PetMenu";
@@ -85,6 +89,17 @@ import "./sagi.css";
const FLYOUT_GAP = 10; // px between avatar and flyout
const VIEWPORT_MARGIN = 12; // min gap from any screen edge
/** True while focus sits where typing happens (an input, a terminal's textarea). */
function isEditable(el: EventTarget | null): boolean {
if (!(el instanceof HTMLElement)) return false;
return (
el.isContentEditable ||
el.tagName === "INPUT" ||
el.tagName === "TEXTAREA" ||
el.tagName === "SELECT"
);
}
interface Anchor {
left: number;
top: number;
@@ -94,7 +109,8 @@ interface Anchor {
dragging: boolean;
}
function usePrefersReducedMotion(): boolean {
/** The OS "reduce motion" preference, followed live. */
export function usePrefersReducedMotion(): boolean {
const [reduced, setReduced] = useState(
() =>
typeof window !== "undefined" &&
@@ -180,6 +196,7 @@ function SagiFlyout({
}
export function Sagi() {
const { t } = useTranslation("settings");
const [enabled, setEnabled] = useState(() => sagiPrefs.getEnabled());
const [pet, setPet] = useState(() => sagiPrefs.getPet());
const [size, setSize] = useState(() => sagiPrefs.getSize());
@@ -202,10 +219,13 @@ export function Sagi() {
[]
);
// ⌘B / Ctrl+B toggles the panel; Esc closes it.
// ⌘B / Ctrl+B toggles the panel; Esc closes it. Only while the mascot is
// shown, and never from an editable field, where Ctrl+B belongs to the field.
useEffect(() => {
if (!enabled) return;
const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "b") {
if (isEditable(e.target) || isEditable(document.activeElement)) return;
e.preventDefault();
setOpen((v) => !v);
} else if (e.key === "Escape") {
@@ -214,7 +234,7 @@ export function Sagi() {
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
}, [enabled]);
const onNavigate = useCallback(
(route: string) => {
@@ -230,7 +250,7 @@ export function Sagi() {
if (result.kind === "answer") return result.text;
// Handoff: ensure the "Sagi" lane exists for this dashboard, spawn a
// fresh claude session in it carrying the user's prompt as its first
// message, then jump to the Run page so the live pane is observable.
// message, then open that workspace's session view on the new pane.
// Fire-and-forget — the panel already clears its composer on null and
// closes; errors surface as an answer instead of silently swallowing.
const prompt = result.prompt;
@@ -238,24 +258,24 @@ export function Sagi() {
(async () => {
try {
const { lane } = await api.lanes.ensure({ title: "Sagi" });
await api.run.start({
const run = await api.run.start({
laneId: lane.id,
cwd: lane.cwd,
initialPrompt: prompt,
permissionMode: "acceptEdits",
});
navigate(`/run?laneId=${lane.id}&autostart=1`);
navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(run.id)}`);
setOpen(false);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
brain.setBubble(`couldn't start that session: ${msg}`);
brain.setBubble(t("sagi.startFailed", { error: msg }));
} finally {
brain.setThinking(false);
}
})();
return null;
},
[brain, navigate]
[brain, navigate, t]
);
if (!enabled) return null;
@@ -314,6 +334,7 @@ export function Sagi() {
onPointerDown={place.onPointerDown}
onPointerMove={place.onPointerMove}
onPointerUp={place.onPointerUp}
onKeyDown={place.onKeyDown}
onClick={() => {
// A drag just ended - swallow the synthetic click so the panel
// doesn't toggle when the user only repositioned the avatar.
@@ -326,9 +347,9 @@ export function Sagi() {
setOpen(false);
setMenu(true);
}}
aria-label={open ? "Close Sagi" : "Open Sagi companion"}
aria-label={open ? t("sagi.closeAvatar") : t("sagi.open")}
aria-expanded={open}
title="Sagi - ⌘B · drag to move · right-click for pets"
title={t("sagi.avatarTitle")}
>
{pet.id === "sagi" || !("kind" in pet) ? (
<SagiAvatar mood={brain.mood} reducedMotion={reducedMotion} size={size} />
+27 -17
View File
@@ -63,6 +63,7 @@ import {
Radio,
type LucideIcon,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import type { SagiStatus } from "./brain";
interface SagiPanelProps {
@@ -85,6 +86,7 @@ export function SagiPanel({
onAsk,
onClose,
}: SagiPanelProps) {
const { t } = useTranslation("settings");
const [query, setQuery] = useState("");
const [answer, setAnswer] = useState<string | null>(null);
@@ -97,9 +99,9 @@ export function SagiPanel({
return (
<div
className="w-72 overflow-hidden rounded-2xl border border-border-light bg-surface-2/95 shadow-2xl shadow-black/50 backdrop-blur-md animate-slide-up"
className="w-72 overflow-hidden rounded-2xl border border-border-light bg-surface-2/95 shadow-2xl backdrop-blur-md animate-slide-up"
role="dialog"
aria-label="Sagi companion"
aria-label={t("sagi.panel.label")}
>
{/* header */}
<div className="flex items-center justify-between gap-2 border-b border-border/70 bg-gradient-to-r from-accent/10 to-transparent px-3.5 py-2.5">
@@ -121,13 +123,13 @@ export function SagiPanel({
}`}
aria-hidden
/>
{status.connected ? "Live" : "Offline"}
{status.connected ? t("sagi.panel.live") : t("sagi.panel.offline")}
</span>
</div>
<button
className="rounded-md p-1 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-secondary"
onClick={onClose}
aria-label="Close Sagi"
aria-label={t("sagi.panel.close")}
>
<X size={15} />
</button>
@@ -138,19 +140,19 @@ export function SagiPanel({
<div className="mb-3 grid grid-cols-3 gap-1.5">
<StatChip
icon={Radio}
label="live"
label={t("sagi.panel.statLive")}
value={status.liveCount}
tone={status.liveCount > 0 ? "accent" : "muted"}
/>
<StatChip
icon={Hourglass}
label="waiting"
label={t("sagi.panel.statWaiting")}
value={status.waitingCount}
tone={status.waitingCount > 0 ? "amber" : "muted"}
/>
<StatChip
icon={AlertTriangle}
label="errored"
label={t("sagi.panel.statErrored")}
value={status.errorCount}
tone={status.errorCount > 0 ? "red" : "muted"}
/>
@@ -158,27 +160,35 @@ export function SagiPanel({
{/* quick actions */}
<div className="mb-3 grid grid-cols-2 gap-1.5">
<ActionButton icon={Play} label="Run Claude" onClick={() => onNavigate("/workspace")} />
<ActionButton icon={Activity} label="Activity" onClick={() => onNavigate("/activity")} />
<ActionButton
icon={Play}
label={t("sagi.panel.runClaude")}
onClick={() => onNavigate("/workspace")}
/>
<ActionButton
icon={Activity}
label={t("sagi.panel.activity")}
onClick={() => onNavigate("/activity")}
/>
<ActionButton
icon={LayoutList}
label="Sessions"
label={t("sagi.panel.sessions")}
onClick={() => onNavigate("/sessions")}
/>
<ActionButton
icon={AlertTriangle}
label="Errored"
label={t("sagi.panel.errored")}
disabled={status.errorCount === 0}
onClick={() => onNavigate("/sessions")}
/>
<ActionButton
icon={muted ? BellOff : Bell}
label={muted ? "Unmute" : "Mute"}
label={muted ? t("sagi.panel.unmute") : t("sagi.panel.mute")}
onClick={onToggleMute}
/>
<ActionButton
icon={Trash2}
label="Clear alerts"
label={t("sagi.panel.clearAlerts")}
disabled={status.errorCount === 0}
onClick={onClearAlerts}
/>
@@ -188,15 +198,15 @@ export function SagiPanel({
<form onSubmit={submit} className="flex items-center gap-1.5">
<input
className="flex-1 rounded-lg border border-border bg-surface-1 px-2.5 py-1.5 text-xs text-fg-secondary placeholder-fg-muted transition-colors focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/30"
placeholder="Ask Sagi… (e.g. any errors?)"
placeholder={t("sagi.panel.askPlaceholder")}
value={query}
onChange={(e) => setQuery(e.target.value)}
aria-label="Ask Sagi"
aria-label={t("sagi.panel.ask")}
/>
<button
type="submit"
className="flex items-center justify-center rounded-lg bg-accent px-2.5 py-2 text-white transition-colors hover:bg-accent-hover"
aria-label="Send"
className="btn-primary justify-center px-2.5 py-2"
aria-label={t("sagi.panel.send")}
>
<Send size={14} />
</button>
+4 -1
View File
@@ -51,6 +51,8 @@
*
* ----------------------------------------------------------------------------- */
import { useTranslation } from "react-i18next";
/** Props for {@link SpeechBubble}. */
interface SpeechBubbleProps {
/** Quip text to display inside the bubble. */
@@ -64,13 +66,14 @@ interface SpeechBubbleProps {
* @param props See {@link SpeechBubbleProps}.
*/
export function SpeechBubble({ text, onDismiss }: SpeechBubbleProps) {
const { t } = useTranslation("settings");
return (
<div
className="sagi-bubble sagi-bubble-enter"
role="status"
aria-live="polite"
onClick={onDismiss}
title="Dismiss"
title={t("sagi.bubbleDismiss")}
>
{text}
</div>
@@ -63,6 +63,16 @@ import { PetSprite } from "../PetSprite";
import { PetMenu } from "../PetMenu";
import { PetSettings } from "../../settings/PetSettings";
import { sagiPrefs } from "../prefs";
import { settleConfirm, subscribeConfirm, type PendingConfirm } from "../../../lib/confirm";
import { subscribeToasts, type ToastItem } from "../../../lib/toast";
/** Answer the pending confirmDialog (asserting one is open). */
function answerConfirm(ok: boolean) {
let pending: PendingConfirm | null = null;
subscribeConfirm((p) => (pending = p))();
expect(pending).not.toBeNull();
act(() => settleConfirm(ok));
}
describe("PetSprite", () => {
beforeEach(() => vi.useFakeTimers());
@@ -144,8 +154,13 @@ describe("PetMenu", () => {
renderMenu();
fireEvent.change(screen.getByTestId("pet-size"), { target: { value: "250" } });
expect(sagiPrefs.getSize()).toBe(250);
let toasts: ToastItem[] = [];
const off = subscribeToasts((i) => (toasts = i));
fireEvent.click(screen.getByText("Hide pet"));
expect(sagiPrefs.getEnabled()).toBe(false);
// Says where to bring it back.
expect(toasts.some((x) => /Settings → Pet/.test(x.message))).toBe(true);
off();
});
it("downloads a featured pet on its first pick, then wears it", async () => {
@@ -161,6 +176,9 @@ describe("PetMenu", () => {
renderMenu();
fireEvent.click(screen.getByTestId("pet-choose"));
fireEvent.click(await screen.findByTestId("pet-delete-p-aaaaaaaaaaaa"));
// Asks first: nothing is deleted on one click.
expect(api.pets.remove).not.toHaveBeenCalled();
answerConfirm(true);
await waitFor(() => expect(sagiPrefs.getPet()).toEqual({ id: "sagi" }));
expect(api.pets.remove).toHaveBeenCalledWith("p-aaaaaaaaaaaa");
});
@@ -207,6 +225,7 @@ describe("PetSettings", () => {
fireEvent.click(screen.getByTestId("pet-card-own-p-aaaaaaaaaaaa"));
expect(sagiPrefs.getPet()).toEqual({ id: "p-aaaaaaaaaaaa", kind: "image" });
fireEvent.click(screen.getByTestId("pet-card-own-p-aaaaaaaaaaaa-delete"));
answerConfirm(true);
await waitFor(() => expect(sagiPrefs.getPet()).toEqual({ id: "sagi" }));
expect(api.pets.remove).toHaveBeenCalledWith("p-aaaaaaaaaaaa");
});
@@ -219,4 +238,27 @@ describe("PetSettings", () => {
expect(sagiPrefs.getPet()).toEqual({ id: "p-bbbbbbbbbbbb", kind: "image" })
);
});
it("a cancelled delete keeps the pet", async () => {
render(<PetSettings />);
fireEvent.click(await screen.findByTestId("pet-card-own-p-aaaaaaaaaaaa-delete"));
answerConfirm(false);
await act(async () => {});
expect(api.pets.remove).not.toHaveBeenCalled();
});
it("disables size and bubbles while hidden, and resets to defaults", async () => {
sagiPrefs.setEnabled(false);
sagiPrefs.setSize(200);
sagiPrefs.setMuted(true);
render(<PetSettings />);
expect(screen.getByTestId("pet-settings-size")).toBeDisabled();
expect(screen.getByRole("switch", { name: /Speech bubbles/ })).toBeDisabled();
fireEvent.click(screen.getByRole("button", { name: /Reset to defaults/ }));
answerConfirm(true);
await waitFor(() => expect(sagiPrefs.getEnabled()).toBe(true));
expect(sagiPrefs.getSize()).toBe(68);
expect(sagiPrefs.getMuted()).toBe(false);
expect(sagiPrefs.getPet()).toEqual({ id: "sagi" });
});
});
@@ -147,4 +147,48 @@ describe("Sagi widget", () => {
// Left-docked → inline left equals the edge margin (16px).
expect(btn.style.left).toBe("16px");
});
it("Ctrl/Cmd+B is ignored while typing in a field", () => {
renderSagi();
const ta = document.createElement("textarea");
document.body.appendChild(ta);
ta.focus();
act(() => {
fireEvent.keyDown(ta, { key: "b", ctrlKey: true });
});
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
ta.remove();
});
it("Ctrl/Cmd+B is left to the browser while the mascot is hidden", () => {
localStorage.setItem("agent-dashboard-sagi-enabled", "false");
renderSagi();
const ev = new KeyboardEvent("keydown", { key: "b", ctrlKey: true, cancelable: true });
window.dispatchEvent(ev);
expect(ev.defaultPrevented).toBe(false);
});
it("docks bottom-right above the status bar by default", () => {
renderSagi();
const btn = screen.getByRole("button", { name: /open sagi companion/i }) as HTMLElement;
const top = parseFloat(btn.style.top);
const size = parseFloat(btn.style.width);
// Bottom edge sits above the 24px status bar plus the edge margin.
expect(top + size).toBe(window.innerHeight - 16 - 24);
});
it("arrow keys move the focused avatar and persist it", () => {
renderSagi();
const btn = screen.getByRole("button", { name: /open sagi companion/i }) as HTMLElement;
const before = parseFloat(btn.style.top);
act(() => {
fireEvent.keyDown(btn, { key: "ArrowUp" });
});
expect(parseFloat(btn.style.top)).toBeLessThan(before);
act(() => {
fireEvent.keyDown(btn, { key: "ArrowLeft" });
});
expect(btn.style.left).toBe("16px");
expect(JSON.parse(localStorage.getItem("agent-dashboard-sagi-pos") || "{}").side).toBe("left");
});
});
+14 -4
View File
@@ -3,14 +3,16 @@
* right-click menu (`PetMenu.tsx`) and Settings → Pet (`settings/PetSettings`):
* `GET /api/pets`, wearing a pet (a viewer preference, `prefs.ts`), installing
* a featured pet (downloaded on its first pick), uploading an image or a
* `.codex-pet.zip` (worn at once), and deleting your own (Sagi comes back
* when it was the one worn). Plus `useWornPet`, the live worn pet / size /
* `.codex-pet.zip` (worn at once), and deleting your own after a confirm
* (Sagi comes back when it was the one worn). Plus `useWornPet`, the live worn pet / size /
* shown / speech-bubble prefs.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState } from "react";
import { api, type PetList, type PetView } from "../../lib/api";
import { confirmDialog } from "../../lib/confirm";
import i18n from "../../i18n";
import { sagiPrefs, type PetChoice } from "./prefs";
const message = (err: unknown) => (err instanceof Error ? err.message : String(err));
@@ -57,12 +59,20 @@ export function usePets() {
wear(pet);
await reload();
});
const remove = (p: PetView) =>
run(p.id, async () => {
// One place both callers route through, so neither can delete on one click.
const remove = async (p: PetView) => {
const ok = await confirmDialog({
title: i18n.t("settings:sagi.pet.deleteTitle", { name: p.displayName }),
message: i18n.t("settings:sagi.pet.deleteMessage"),
confirmLabel: i18n.t("settings:sagi.pet.deleteConfirm"),
});
if (!ok) return;
await run(p.id, async () => {
await api.pets.remove(p.id);
if (sagiPrefs.getPet().id === p.id) sagiPrefs.setPet({ id: "sagi" });
await reload();
});
};
const own = list?.pets.filter((p) => p.source === "upload") ?? [];
// Featured id → the installed copy, once downloaded.
+39 -6
View File
@@ -5,7 +5,10 @@
* keeps tracking even if the cursor outruns it), and on release snaps to the
* nearest left/right edge, remembering its vertical offset (persisted as a
* viewport fraction so it survives resizes). A small movement threshold tells
* a drag apart from a tap so dragging never opens the panel.
* a drag apart from a tap so dragging never opens the panel. The arrow keys
* move a focused avatar too (WCAG 2.5.7): ←/→ dock it to an edge, ↑/↓ step it.
* It never rests over the fixed bottom status bar; by default it docks
* bottom-right just above it.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -70,23 +73,28 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { sagiPrefs, type SagiPos } from "./prefs";
import type { PointerEvent as ReactPointerEvent } from "react";
import type { KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent } from "react";
// Avatar footprint + edge gap, in px. SIZE matches SagiAvatar's default size.
export const SAGI_SIZE = 68;
export const SAGI_MARGIN = 16;
const DRAG_THRESHOLD = 5;
// The fixed bottom StatusBar (`h-6`); the avatar never rests over it.
const STATUS_BAR = 24;
// One arrow-key step, in px.
const KEY_STEP = 40;
const vw = () => (typeof window !== "undefined" ? window.innerWidth : 1024);
const vh = () => (typeof window !== "undefined" ? window.innerHeight : 768);
function defaultPos(): SagiPos {
return { side: "right", y: 0.5 }; // right edge, vertically centered
// Bottom-right, above the status bar: a centred dock covered row controls.
return { side: "right", y: 1 };
}
/** Resting top-left screen coords for a docked position. */
function restingScreen(pos: SagiPos, size: number) {
const avail = Math.max(0, vh() - size - 2 * SAGI_MARGIN);
const avail = Math.max(0, vh() - size - 2 * SAGI_MARGIN - STATUS_BAR);
const left = pos.side === "left" ? SAGI_MARGIN : vw() - size - SAGI_MARGIN;
const top = SAGI_MARGIN + pos.y * avail;
return { left, top };
@@ -106,6 +114,8 @@ export interface SagiPlacement {
onPointerUp: (e: ReactPointerEvent) => void;
/** Returns true (once) if a drag just ended, so the click handler can skip. */
consumeDrag: () => boolean;
/** Arrow keys on the focused avatar: ←/→ dock to an edge, ↑/↓ step. */
onKeyDown: (e: ReactKeyboardEvent) => void;
}
/** `size`: the avatar's current footprint (the pet size pref), in px. */
@@ -156,7 +166,10 @@ export function useSagiPosition(size: number = SAGI_SIZE): SagiPlacement {
if (!movedRef.current && Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
movedRef.current = true;
const left = Math.min(vw() - size - SAGI_MARGIN, Math.max(SAGI_MARGIN, start.left + dx));
const top = Math.min(vh() - size - SAGI_MARGIN, Math.max(SAGI_MARGIN, start.top + dy));
const top = Math.min(
vh() - size - SAGI_MARGIN - STATUS_BAR,
Math.max(SAGI_MARGIN, start.top + dy)
);
liveRef.current = { left, top };
setDrag({ left, top });
},
@@ -174,7 +187,7 @@ export function useSagiPosition(size: number = SAGI_SIZE): SagiPlacement {
if (live) {
draggedRef.current = true;
const side: "left" | "right" = live.left + size / 2 < vw() / 2 ? "left" : "right";
const avail = Math.max(1, vh() - size - 2 * SAGI_MARGIN);
const avail = Math.max(1, vh() - size - 2 * SAGI_MARGIN - STATUS_BAR);
const y = Math.min(1, Math.max(0, (live.top - SAGI_MARGIN) / avail));
const next: SagiPos = { side, y };
sagiPrefs.setPos(next);
@@ -188,6 +201,25 @@ export function useSagiPosition(size: number = SAGI_SIZE): SagiPlacement {
[size]
);
const onKeyDown = useCallback(
(e: ReactKeyboardEvent) => {
if (e.altKey || e.ctrlKey || e.metaKey) return;
const avail = Math.max(1, vh() - size - 2 * SAGI_MARGIN - STATUS_BAR);
let next: SagiPos | null = null;
if (e.key === "ArrowLeft") next = { ...pos, side: "left" };
else if (e.key === "ArrowRight") next = { ...pos, side: "right" };
else if (e.key === "ArrowUp" || e.key === "ArrowDown") {
const dy = (e.key === "ArrowUp" ? -KEY_STEP : KEY_STEP) / avail;
next = { ...pos, y: Math.min(1, Math.max(0, pos.y + dy)) };
}
if (!next) return;
e.preventDefault();
sagiPrefs.setPos(next);
setPos(next);
},
[pos, size]
);
const consumeDrag = useCallback(() => {
const was = draggedRef.current;
draggedRef.current = false;
@@ -205,5 +237,6 @@ export function useSagiPosition(size: number = SAGI_SIZE): SagiPlacement {
onPointerMove,
onPointerUp,
consumeDrag,
onKeyDown,
};
}
+47 -25
View File
@@ -16,7 +16,9 @@
* — and the poll waits for the old server to actually go DOWN first, because it
* keeps answering while the chain installs. New skills/slash commands still
* need `/reload-plugins` in an already-open Claude Code session; nothing a
* background process can do for it.
* background process can do for it. Escape, the backdrop and "Not now" close it
* for this page load only (never while applying); "Skip this version" is the
* one persistent, per-version dismiss.
*
* ## State sources
* - Initial fetch via `api.updates.status()` on mount.
@@ -76,11 +78,13 @@
*
* ----------------------------------------------------------------------------- */
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Download, X, Copy, Check, RefreshCw, Zap, ExternalLink } from "lucide-react";
import { api } from "../lib/api";
import { eventBus } from "../lib/eventBus";
import { copyText } from "../lib/clipboard";
import { useDialog } from "../hooks/useDialog";
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
/** `localStorage` key storing the dismissed release tag (or upstream SHA). */
@@ -125,6 +129,10 @@ export function UpdateNotifier() {
const { t } = useTranslation("updates");
const [status, setStatus] = useState<UpdateStatusPayload | null>(null);
const [dismissedSha, setDismissedSha] = useState<string | null>(loadDismissedSha);
// Closed for THIS page load only (Escape, backdrop, ×, "Not now") — the
// persistent per-version dismiss is the explicit "Skip this version".
const [closedKey, setClosedKey] = useState<string | null>(null);
const panelRef = useRef<HTMLDivElement>(null);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [checking, setChecking] = useState(false);
@@ -192,36 +200,37 @@ export function UpdateNotifier() {
status?.latest_release ||
status?.remote_sha ||
(installed ? `installed:${installed.root}` : null);
const show = Boolean(status?.update_available && dismissKey && dismissedSha !== dismissKey);
const show = Boolean(
status?.update_available &&
dismissKey &&
dismissedSha !== dismissKey &&
closedKey !== dismissKey
);
const busy = applying || restarting;
const closeForNow = useCallback(() => {
if (busy || !dismissKey) return;
setClosedKey(dismissKey);
}, [busy, dismissKey]);
const dismiss = useCallback(() => {
if (restarting || !dismissKey) return;
if (busy || !dismissKey) return;
try {
localStorage.setItem(DISMISS_KEY, dismissKey);
} catch {
/* ignore */
}
setDismissedSha(dismissKey);
}, [restarting, dismissKey]);
}, [busy, dismissKey]);
// Escape to dismiss - standard modal affordance.
useEffect(() => {
if (!show) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") dismiss();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [show, dismiss]);
// Escape closes for now — never while an update is applying.
useDialog(show, panelRef, busy ? undefined : closeForNow);
const copyCmd = async () => {
if (!status?.manual_command) return;
try {
await navigator.clipboard.writeText(status.manual_command);
if (await copyText(status.manual_command, true)) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
/* ignore */
}
};
@@ -304,17 +313,20 @@ export function UpdateNotifier() {
return (
<div
className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-fade-in"
className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-surface-0/70 backdrop-blur-sm animate-fade-in"
role="dialog"
aria-modal="true"
aria-labelledby="update-notifier-title"
onClick={(e) => {
if (e.target === e.currentTarget) dismiss();
if (e.target === e.currentTarget) closeForNow();
}}
>
{/* max-w-2xl, not lg: the footer carries up to four buttons and the
longest label is a whole sentence ("restarting the dashboard…"). */}
<div className="w-full max-w-2xl card shadow-2xl animate-slide-up overflow-hidden">
<div
ref={panelRef}
className="w-full max-w-2xl card shadow-2xl animate-slide-up overflow-hidden"
>
{/* Header */}
<div className="flex items-start justify-between gap-3 px-5 py-4 border-b border-border">
<div className="flex items-center gap-3 min-w-0">
@@ -343,8 +355,9 @@ export function UpdateNotifier() {
</div>
<button
type="button"
onClick={dismiss}
aria-label={t("dismiss")}
onClick={closeForNow}
disabled={busy}
aria-label={t("later")}
className="p-1.5 -m-1 rounded-lg text-fg-muted hover:text-fg-secondary hover:bg-surface-4 transition-colors flex-shrink-0"
>
<X className="w-4 h-4" />
@@ -460,10 +473,19 @@ export function UpdateNotifier() {
<button
type="button"
onClick={dismiss}
disabled={restarting}
disabled={busy}
title={t("skipVersionHint")}
className="btn-ghost disabled:opacity-60 disabled:cursor-not-allowed"
>
{t("dismiss")}
{t("skipVersion")}
</button>
<button
type="button"
onClick={closeForNow}
disabled={busy}
className="btn-ghost disabled:opacity-60 disabled:cursor-not-allowed"
>
{t("later")}
</button>
{status.manual_command ? (
<button
+32 -17
View File
@@ -86,6 +86,7 @@ import { Checkbox } from "./Checkbox";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "./ContextMenu";
import { WEBHOOK_DOCS } from "./webhookGuides";
import { timeAgo } from "../lib/format";
import { toast } from "../lib/toast";
import type {
AlertRule,
WebhookProvider,
@@ -94,20 +95,21 @@ import type {
WebhookTestResult,
} from "../lib/types";
// Brand-ish accent per provider type; anything unmapped falls back to neutral.
// Brand-ish accent per provider type, on the BORDER only: brand hues fail
// contrast as text on several themes, so the label stays text-fg-primary.
const TYPE_STYLES: Partial<Record<WebhookType, string>> = {
slack: "text-[#E01E5A] bg-[#E01E5A]/10 border-[#E01E5A]/20",
discord: "text-[#5865F2] bg-[#5865F2]/10 border-[#5865F2]/20",
teams: "text-[#6264A7] bg-[#6264A7]/10 border-[#6264A7]/20",
google_chat: "text-[#1A73E8] bg-[#1A73E8]/10 border-[#1A73E8]/20",
mattermost: "text-[#0058CC] bg-[#0058CC]/10 border-[#0058CC]/20",
rocketchat: "text-[#F5455C] bg-[#F5455C]/10 border-[#F5455C]/20",
telegram: "text-[#26A5E4] bg-[#26A5E4]/10 border-[#26A5E4]/20",
pagerduty: "text-[#06AC38] bg-[#06AC38]/10 border-[#06AC38]/20",
opsgenie: "text-[#2684FF] bg-[#2684FF]/10 border-[#2684FF]/20",
splunk_oncall: "text-[#F99D1C] bg-[#F99D1C]/10 border-[#F99D1C]/20",
slack: "border-[#E01E5A]/60",
discord: "border-[#5865F2]/60",
teams: "border-[#6264A7]/60",
google_chat: "border-[#1A73E8]/60",
mattermost: "border-[#0058CC]/60",
rocketchat: "border-[#F5455C]/60",
telegram: "border-[#26A5E4]/60",
pagerduty: "border-[#06AC38]/60",
opsgenie: "border-[#2684FF]/60",
splunk_oncall: "border-[#F99D1C]/60",
};
const NEUTRAL_STYLE = "text-fg-secondary bg-surface-2 border-border";
const NEUTRAL_STYLE = "border-border";
interface HeaderRow {
key: string;
@@ -168,11 +170,13 @@ export function WebhookSettings() {
const res = await api.webhooks.list();
setTargets(res.targets);
} catch (err) {
console.error("Failed to load webhook targets:", err);
toast.error(
t("webhooks.loadFailed", { error: err instanceof Error ? err.message : String(err) })
);
} finally {
setLoading(false);
}
}, []);
}, [t]);
useEffect(() => {
load();
@@ -252,6 +256,9 @@ export function WebhookSettings() {
const canSubmit = useMemo(() => {
if (!form || !provider) return false;
if (!form.name.trim()) return false;
// An empty rule list means ALL rules server-side — never save that from
// "only selected rules".
if (!form.scopeAll && form.ruleIds.length === 0) return false;
if (isEdit) return true; // server merge - existing values fill the gaps
if (provider.url_required && !form.url.trim()) return false;
for (const f of provider.fields) {
@@ -326,7 +333,9 @@ export function WebhookSettings() {
await api.webhooks.update(target.id, { enabled: !target.enabled });
load();
} catch (err) {
console.error("Failed to toggle webhook:", err);
toast.error(
t("webhooks.toggleFailed", { error: err instanceof Error ? err.message : String(err) })
);
}
};
@@ -336,7 +345,10 @@ export function WebhookSettings() {
setConfirmDelete(null);
load();
} catch (err) {
console.error("Failed to delete webhook:", err);
setConfirmDelete(null);
toast.error(
t("webhooks.deleteFailed", { error: err instanceof Error ? err.message : String(err) })
);
}
};
@@ -402,7 +414,7 @@ export function WebhookSettings() {
>
<div className="flex items-center gap-3 flex-wrap">
<span
className={`inline-flex items-center gap-1 text-[10px] font-semibold uppercase tracking-wide px-2 py-0.5 rounded border ${
className={`inline-flex items-center gap-1 text-[10px] font-semibold uppercase tracking-wide px-2 py-0.5 rounded border bg-surface-2 text-fg-primary ${
TYPE_STYLES[target.type] || NEUTRAL_STYLE
}`}
>
@@ -765,6 +777,9 @@ export function WebhookSettings() {
))}
</div>
)}
{!form.scopeAll && form.ruleIds.length === 0 && (
<p className="mt-1.5 text-[11px] text-status-warning">{t("webhooks.pickRule")}</p>
)}
</div>
)}
@@ -0,0 +1,79 @@
/**
* @file AlertsNotifications.test.tsx
* @description Alert rules are editable through the create form (PATCH, type
* fixed), and an invalid cooldown blocks saving instead of silently becoming 300.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
const update = vi.fn(async () => ({ rule: {} }));
const create = vi.fn(async () => ({ rule: {} }));
vi.mock("../../lib/api", () => ({
api: {
alerts: {
rules: {
list: vi.fn(async () => ({
rules: [
{
id: "r1",
name: "Idle",
rule_type: "inactivity",
config: { minutes: 30 },
enabled: true,
cooldown_seconds: 60,
},
],
})),
update: (...a: unknown[]) => update(...(a as [])),
create: (...a: unknown[]) => create(...(a as [])),
remove: vi.fn(),
},
list: vi.fn(async () => ({ alerts: [], total: 0, unacked: 0 })),
ack: vi.fn(),
ackAll: vi.fn(),
},
webhooks: {
list: vi.fn(async () => ({ targets: [] })),
providers: vi.fn(async () => ({ providers: [] })),
},
},
}));
import { AlertsNotifications } from "../AlertsNotifications";
const renderIt = () =>
render(
<MemoryRouter>
<AlertsNotifications />
</MemoryRouter>
);
describe("AlertsNotifications rules", () => {
it("edits a rule in place and validates the cooldown", async () => {
renderIt();
await userEvent.click(await screen.findByRole("button", { name: /edit rule/i }));
const name = screen.getByDisplayValue("Idle");
const cooldown = screen.getByDisplayValue("60");
await userEvent.clear(cooldown);
await userEvent.type(cooldown, "-5");
const save = screen.getByRole("button", { name: /save rule/i });
expect(save).toBeDisabled();
await userEvent.clear(cooldown);
await userEvent.type(cooldown, "120");
await userEvent.clear(name);
await userEvent.type(name, "Idle long");
await userEvent.click(save);
await waitFor(() =>
expect(update).toHaveBeenCalledWith("r1", {
name: "Idle long",
config: { minutes: 30 },
cooldown_seconds: 120,
})
);
expect(create).not.toHaveBeenCalled();
}, 15_000); // many typed fields: slow under a parallel run
});
@@ -221,3 +221,28 @@ describe("UpdateNotifier — Update now", () => {
expect(screen.queryByText(i18n.t("updates:restarting"))).not.toBeInTheDocument();
});
});
describe("UpdateNotifier — closing vs skipping", () => {
it("Escape only closes for now; Skip this version is the persistent dismiss", async () => {
const { unmount } = render(<UpdateNotifier />);
await screen.findByText(i18n.t("updates:title"));
await userEvent.keyboard("{Escape}");
await waitFor(() =>
expect(screen.queryByText(i18n.t("updates:title"))).not.toBeInTheDocument()
);
expect(localStorage.getItem("agent-monitor-update-dismissed-sha")).toBeNull();
unmount();
render(<UpdateNotifier />);
await userEvent.click(await screen.findByText(i18n.t("updates:skipVersion")));
expect(localStorage.getItem("agent-monitor-update-dismissed-sha")).toBe("bbb");
});
it("ignores Escape while an update is applying", async () => {
applyMock.mockReturnValue(new Promise(() => {}));
render(<UpdateNotifier />);
await userEvent.click(await screen.findByText(i18n.t("updates:updateNow")));
await userEvent.keyboard("{Escape}");
expect(screen.getByText(i18n.t("updates:title"))).toBeInTheDocument();
});
});
@@ -0,0 +1,85 @@
/**
* @file WebhookSettings.test.tsx
* @description The webhook form must not save "only selected rules" with no
* rule ticked (the server reads an empty list as ALL rules), and a failed
* toggle must surface as an error toast instead of only a console line.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
const toastError = vi.fn();
vi.mock("../../lib/toast", () => ({
toast: { error: (m: unknown) => toastError(m), success: vi.fn(), info: vi.fn() },
}));
const update = vi.fn();
vi.mock("../../lib/api", () => ({
api: {
webhooks: {
list: vi.fn(async () => ({
targets: [
{
id: "w1",
name: "Team",
type: "generic",
url_preview: "https://x/…",
enabled: true,
rule_ids: [],
config: {},
},
],
})),
providers: vi.fn(async () => ({
providers: [
{
type: "generic",
label: "Generic",
url_required: true,
has_default_url: false,
supports_secret: false,
supports_headers: false,
fields: [],
},
],
})),
update: (...a: unknown[]) => update(...a),
create: vi.fn(),
remove: vi.fn(),
test: vi.fn(),
},
alerts: { rules: { list: vi.fn(async () => ({ rules: [{ id: "r1", name: "Rule one" }] })) } },
},
}));
import { WebhookSettings } from "../WebhookSettings";
beforeEach(() => {
toastError.mockReset();
update.mockReset();
});
describe("WebhookSettings", () => {
it("blocks 'only selected rules' with none ticked", async () => {
render(<WebhookSettings />);
await userEvent.click(await screen.findByRole("button", { name: /add/i }));
const inputs = screen.getAllByRole("textbox");
await userEvent.type(inputs[0]!, "Hook");
await userEvent.type(inputs[1]!, "https://example.com/h");
const create = screen.getByRole("button", { name: /^create/i });
expect(create).toBeEnabled();
await userEvent.click(screen.getByRole("checkbox", { name: /all/i }));
expect(create).toBeDisabled();
await userEvent.click(screen.getByRole("checkbox", { name: "Rule one" }));
expect(create).toBeEnabled();
});
it("toasts a failed toggle", async () => {
update.mockRejectedValue(new Error("boom"));
render(<WebhookSettings />);
await userEvent.click(await screen.findByRole("switch"));
await waitFor(() => expect(toastError).toHaveBeenCalled());
});
});
+61 -102
View File
@@ -1,19 +1,17 @@
/**
* @file ConfigTabBar.tsx
* @description The tab strip both config explorers wear: one horizontally
* scrollable row with fade edges and chevrons that appear only when there is
* something to scroll to.
* @description The tab strip both config explorers wear: tabs WRAP instead of
* scrolling behind a hidden scrollbar, an optional `group` per tab draws a
* labelled separator between groups (Content · Extensions · Config), and the
* strip is a real ARIA tablist — roving tabindex, ←/→/Home/End move and
* select, each tab `aria-controls` the panel.
*
* Shared rather than copied because the two pages must look the same. Codex
* originally wrapped its tabs onto a second row — fine at eight tabs, but it
* read as a different component sitting next to Claude Code's twelve-tab
* scroller. One row that scrolls is the same shape at any tab count.
* Shared rather than copied because the two pages must look the same.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useRef, useState, type ComponentType } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { Fragment, useRef, type ComponentType, type KeyboardEvent } from "react";
export interface ConfigTab<K extends string> {
key: K;
@@ -21,15 +19,19 @@ export interface ConfigTab<K extends string> {
label: string;
/** Badge number, or null for tabs that count nothing (e.g. Overview). */
count: number | null;
/** Group label; a change of group between neighbours draws a separator. */
group?: string;
}
interface ConfigTabBarProps<K extends string> {
tabs: ConfigTab<K>[];
current: K;
onSelect: (key: K) => void;
/** `data-testid` becomes `<prefix>-<key>`, e.g. "cc-tab-plugins". */
/** `data-testid` (and the tab's id) becomes `<prefix>-<key>`, e.g. "cc-tab-plugins". */
testIdPrefix: string;
ariaLabel: string;
/** The id of the tabpanel element every tab controls. */
panelId?: string;
}
export function ConfigTabBar<K extends string>({
@@ -38,90 +40,63 @@ export function ConfigTabBar<K extends string>({
onSelect,
testIdPrefix,
ariaLabel,
panelId,
}: ConfigTabBarProps<K>) {
const scrollRef = useRef<HTMLDivElement | null>(null);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);
const listRef = useRef<HTMLDivElement | null>(null);
// Update scroll affordances when content size or scroll position changes.
const updateAffordances = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
setCanScrollLeft(el.scrollLeft > 1);
setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
}, []);
useEffect(() => {
updateAffordances();
const el = scrollRef.current;
if (!el) return;
el.addEventListener("scroll", updateAffordances, { passive: true });
const ro = new ResizeObserver(updateAffordances);
ro.observe(el);
return () => {
el.removeEventListener("scroll", updateAffordances);
ro.disconnect();
};
}, [updateAffordances]);
// Scroll the active tab into view when it changes (e.g. user picks a tab
// that's offscreen, or window resize hides the active one).
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
const active = el.querySelector<HTMLElement>('[data-tab-active="true"]');
if (!active) return;
const elRect = el.getBoundingClientRect();
const activeRect = active.getBoundingClientRect();
if (activeRect.left < elRect.left + 8) {
el.scrollBy({ left: activeRect.left - elRect.left - 16, behavior: "smooth" });
} else if (activeRect.right > elRect.right - 8) {
el.scrollBy({ left: activeRect.right - elRect.right + 16, behavior: "smooth" });
}
}, [current]);
const scrollByButton = (dir: 1 | -1) => {
const el = scrollRef.current;
if (!el) return;
el.scrollBy({ left: dir * Math.max(200, el.clientWidth * 0.6), behavior: "smooth" });
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
const i = tabs.findIndex((t) => t.key === current);
const next =
e.key === "ArrowRight"
? (i + 1) % tabs.length
: e.key === "ArrowLeft"
? (i - 1 + tabs.length) % tabs.length
: e.key === "Home"
? 0
: e.key === "End"
? tabs.length - 1
: -1;
if (next < 0) return;
e.preventDefault();
const key = tabs[next]!.key;
onSelect(key);
listRef.current?.querySelector<HTMLElement>(`[data-testid="${testIdPrefix}-${key}"]`)?.focus();
};
return (
<div className="relative rounded-xl border border-border bg-surface-1">
{/* Left edge gradient + chevron */}
<div
className={`pointer-events-none absolute left-0 top-0 bottom-0 w-12 rounded-l-xl bg-gradient-to-r from-surface-1 to-transparent transition-opacity z-10 ${
canScrollLeft ? "opacity-100" : "opacity-0"
}`}
/>
{canScrollLeft && (
<button
onClick={() => scrollByButton(-1)}
aria-label="scroll tabs left"
className="absolute left-1 top-1/2 -translate-y-1/2 z-20 rounded-md w-7 h-7 flex items-center justify-center bg-surface-2 border border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
>
<ChevronLeft className="w-4 h-4" />
</button>
)}
<div
ref={scrollRef}
role="tablist"
aria-label={ariaLabel}
className="flex gap-1 p-1 overflow-x-auto scroll-smooth [&::-webkit-scrollbar]:hidden"
style={{ scrollbarWidth: "none" }}
>
{tabs.map(({ key, icon: Icon, label, count }) => {
const active = current === key;
return (
<div
ref={listRef}
role="tablist"
aria-label={ariaLabel}
onKeyDown={onKeyDown}
className="flex flex-wrap items-center gap-1 rounded-xl border border-border bg-surface-1 p-1"
>
{tabs.map(({ key, icon: Icon, label, count, group }, i) => {
const active = current === key;
const newGroup = group && group !== tabs[i - 1]?.group;
return (
<Fragment key={key}>
{newGroup && (
<span
aria-hidden
className={`flex items-center gap-1 px-1 text-[10px] font-semibold uppercase tracking-wider text-fg-muted ${
i > 0 ? "ml-1 border-l border-border pl-2" : ""
}`}
>
{group}
</span>
)}
<button
key={key}
type="button"
id={`${testIdPrefix}-${key}`}
data-testid={`${testIdPrefix}-${key}`}
data-tab-active={active ? "true" : undefined}
role="tab"
aria-selected={active}
aria-controls={panelId}
tabIndex={active ? 0 : -1}
onClick={() => onSelect(key)}
className={`flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors flex-shrink-0 whitespace-nowrap ${
className={`flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors whitespace-nowrap focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 ${
active
? "bg-accent/15 text-accent border border-accent/30"
: "text-fg-secondary hover:text-fg-primary hover:bg-surface-3 border border-transparent"
@@ -139,25 +114,9 @@ export function ConfigTabBar<K extends string>({
</span>
)}
</button>
);
})}
</div>
{/* Right edge gradient + chevron */}
<div
className={`pointer-events-none absolute right-0 top-0 bottom-0 w-12 rounded-r-xl bg-gradient-to-l from-surface-1 to-transparent transition-opacity z-10 ${
canScrollRight ? "opacity-100" : "opacity-0"
}`}
/>
{canScrollRight && (
<button
onClick={() => scrollByButton(1)}
aria-label="scroll tabs right"
className="absolute right-1 top-1/2 -translate-y-1/2 z-20 rounded-md w-7 h-7 flex items-center justify-center bg-surface-2 border border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
>
<ChevronRight className="w-4 h-4" />
</button>
)}
</Fragment>
);
})}
</div>
);
}
@@ -6,7 +6,8 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState } from "react";
import { useCallback, useRef, useState } from "react";
import { useDialog } from "../../hooks/useDialog";
import { useTranslation } from "react-i18next";
import { ShieldAlert, Trash2 } from "lucide-react";
import type { CcArtifactType } from "../../lib/api";
@@ -41,13 +42,10 @@ export function ConfirmDeleteModal<
const { t } = useTranslation("ccConfig");
const [busy, setBusy] = useState(false);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onCancel();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onCancel]);
const panelRef = useRef<HTMLDivElement>(null);
const cancelRef = useRef<HTMLButtonElement>(null);
// Focus starts on Cancel — the safe answer — and Escape cancels.
useDialog(true, panelRef, busy ? undefined : onCancel, cancelRef);
const handleConfirm = useCallback(async () => {
setBusy(true);
@@ -60,27 +58,36 @@ export function ConfirmDeleteModal<
return (
<div
className="fixed inset-0 bg-black/60 z-40 flex items-center justify-center p-4"
onClick={onCancel}
className="fixed inset-0 bg-surface-0/70 z-50 flex items-center justify-center p-4"
onClick={busy ? undefined : onCancel}
>
<div
ref={panelRef}
role="alertdialog"
aria-modal="true"
aria-labelledby="cc-confirm-delete-title"
aria-describedby="cc-confirm-delete-body"
className="relative w-full max-w-lg rounded-xl border border-status-danger/40 bg-surface-1"
onClick={(e) => e.stopPropagation()}
>
<div className="border-b border-border px-4 py-2.5 flex items-center gap-2">
<ShieldAlert className="w-4 h-4 text-status-danger" />
<span className="text-sm font-medium text-fg-primary flex-1">
<span id="cc-confirm-delete-title" className="text-sm font-medium text-fg-primary flex-1">
{t("edit.confirmDelete")}
</span>
</div>
<div className="p-4 space-y-3">
<p className="text-xs text-fg-secondary leading-relaxed">{t("edit.confirmDeleteBody")}</p>
<p id="cc-confirm-delete-body" className="text-xs text-fg-secondary leading-relaxed">
{t("edit.confirmDeleteBody")}
</p>
<div className="rounded-md border border-border bg-surface-2 px-3 py-2 font-mono text-[11px] text-fg-secondary break-all">
{t("edit.confirmDeletePath", { path: state.path })}
</div>
</div>
<div className="border-t border-border px-4 py-3 flex items-center justify-end gap-2">
<button
ref={cancelRef}
type="button"
onClick={onCancel}
disabled={busy}
className="text-[12px] font-medium px-3 py-1.5 rounded-md border border-border bg-surface-2 hover:bg-surface-3 text-fg-secondary disabled:opacity-60"
@@ -88,6 +95,7 @@ export function ConfirmDeleteModal<
{t("edit.cancel")}
</button>
<button
type="button"
onClick={handleConfirm}
disabled={busy}
className="text-[12px] font-medium px-3 py-1.5 rounded-md border border-status-danger/50 bg-status-danger/15 hover:bg-status-danger/25 text-status-danger inline-flex items-center gap-1.5 disabled:opacity-60"
+144 -38
View File
@@ -3,14 +3,27 @@
* @description Create/edit modal for text-file config artifacts (skills,
* agents, commands, output styles, memory). Tool-agnostic: it takes a
* `fetchContent` callback instead of reaching for any particular tool's API,
* so the same modal serves both the Claude Code and Codex config pages.
* so the same modal serves both the Claude Code and Codex config pages. It
* loads a file ONCE per path (never over the user's typing), refuses to save
* over a failed or truncated read, asks before discarding edits or overwriting
* a file changed on disk, and is a real dialog (focus trap, Escape).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Pencil, X, Save, AlertCircle } from "lucide-react";
import type { CcArtifactType } from "../../lib/api";
import { confirmDialog, confirmDiscard } from "../../lib/confirm";
import { useDialog } from "../../hooks/useDialog";
import { useUnsavedGuard } from "../../hooks/useUnsavedGuard";
/** What a fetcher may answer: the text, or the API's `{text, truncated}`. */
type Fetched = string | { text: string; truncated?: boolean };
const textOf = (r: Fetched) => (typeof r === "string" ? r : r.text);
/** Same rule the server applies to an artifact name. */
const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
/** Claude Code's own scope union, kept as the default so existing callers
* (CcConfig.tsx) don't need to name it. Codex has no `auto-memory` scope —
@@ -48,7 +61,7 @@ interface EditorModalProps<
TScope extends string = DefaultScope,
> {
state: NonNullable<EditorState<TType, TScope>>;
fetchContent: (filePath: string) => Promise<string>;
fetchContent: (filePath: string) => Promise<Fetched>;
onClose: () => void;
onSave: (args: {
type: TType;
@@ -60,6 +73,8 @@ interface EditorModalProps<
/** Which `state.type` values are nameless singleton files. Defaults to
* Claude Code's own `["memory"]` so existing callers are unaffected. */
namelessTypes?: readonly string[];
/** Refuse the Project target (its folder is the plugin cache, wiped on update). */
projectDisabled?: boolean;
}
export function EditorModal<
@@ -71,6 +86,7 @@ export function EditorModal<
onClose,
onSave,
namelessTypes = DEFAULT_NAMELESS_TYPES,
projectDisabled = false,
}: EditorModalProps<TType, TScope>) {
const { t } = useTranslation("ccConfig");
const isCreate = state.mode === "create";
@@ -88,41 +104,110 @@ export function EditorModal<
const [loading, setLoading] = useState(!isCreate);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// "ok" once an edit's text is in; a failed or truncated load never saves —
// Save over a failed load wrote an EMPTY file, over a truncated one a cut file.
const [loadState, setLoadState] = useState<"ok" | "failed" | "truncated">(
isCreate ? "ok" : "failed"
);
// The text as opened; `dirty` and the changed-on-disk check compare to it.
const original = useRef<string>(isCreate ? state.template : "");
const panelRef = useRef<HTMLDivElement>(null);
const titleId = "cc-editor-title";
const dirty = !loading && content !== original.current;
useUnsavedGuard(dirty);
// For edit mode, fetch the actual file content
useEffect(() => {
if (state.mode === "edit") {
setLoading(true);
fetchContent(state.filePath)
.then((text) => {
setContent(text);
setLoading(false);
})
.catch((err: unknown) => {
const msg = err instanceof Error ? err.message : "unknown";
setError(msg);
setLoading(false);
});
}
}, [state, fetchContent]);
// Read through a ref: callers pass an inline fetcher, and a dependency on it
// reloaded the file on every parent render — overwriting the user's typing.
const fetchRef = useRef(fetchContent);
fetchRef.current = fetchContent;
const editPath = state.mode === "edit" ? state.filePath : null;
// Esc to close
// `t` through a ref too: a language switch must not re-run the load below.
const tRef = useRef(t);
tRef.current = t;
// For edit mode, fetch the file once per path.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
if (editPath === null) return;
let live = true;
setLoading(true);
fetchRef
.current(editPath)
.then((r) => {
if (!live) return;
const text = textOf(r);
original.current = text;
setContent(text);
const cut = typeof r !== "string" && !!r.truncated;
setLoadState(cut ? "truncated" : "ok");
if (cut) setError(tRef.current("edit.truncatedReadOnly"));
setLoading(false);
})
.catch((err: unknown) => {
if (!live) return;
const msg = err instanceof Error ? err.message : "unknown";
setLoadState("failed");
setError(tRef.current("edit.loadError", { message: msg }));
setLoading(false);
});
return () => {
live = false;
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
}, [editPath]);
const requestClose = useCallback(async () => {
if (dirty && !(await confirmDiscard())) return;
onClose();
}, [dirty, onClose]);
// Escape closes — but not while typing in the textarea, where it only leaves
// the field (an editor's Escape must never cost a draft).
useDialog(true, panelRef, () => {
const el = document.activeElement;
if (el instanceof HTMLTextAreaElement) {
el.blur();
return;
}
void requestClose();
});
const handleSave = useCallback(async () => {
setSaving(true);
setError(null);
try {
if (!isNameless && state.mode === "create" && !name) {
setError(t("edit.nameLabel"));
setSaving(false);
return;
if (!isNameless && state.mode === "create") {
if (!name.trim()) {
setError(t("edit.nameRequired"));
return;
}
if (!isAutoMemory && !NAME_RE.test(name)) {
setError(t("edit.nameInvalid"));
return;
}
}
// The file changed on disk since it was opened (a running session, a
// terminal): say so instead of silently overwriting that change.
if (state.mode === "edit") {
const fresh = await fetchRef
.current(state.filePath)
.then(textOf)
.catch(() => null);
// Could not re-read it: say so rather than saving blind.
const ask =
fresh === null
? {
title: t("edit.unverifiedTitle"),
message: t("edit.unverifiedBody"),
confirmLabel: t("edit.saveAnyway"),
}
: fresh !== original.current
? {
title: t("edit.changedOnDiskTitle"),
message: t("edit.changedOnDiskBody"),
confirmLabel: t("edit.overwrite"),
}
: null;
if (ask && !(await confirmDialog(ask))) return;
}
// For auto-memory creates, append a .md extension when the user omits it.
const createName = isAutoMemory && !/\.md$/i.test(name) ? `${name}.md` : name;
@@ -140,13 +225,14 @@ export function EditorModal<
content,
project: state.project,
});
original.current = content;
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "unknown";
setError(t("edit.writeError", { message: msg }));
} finally {
setSaving(false);
}
}, [state, targetScope, name, content, isAutoMemory, onSave, t]);
}, [state, targetScope, name, content, isAutoMemory, isNameless, onSave, t]);
const titleText = isCreate
? isAutoMemory
@@ -156,18 +242,25 @@ export function EditorModal<
return (
<div
className="fixed inset-0 bg-black/60 z-40 flex items-center justify-center p-4"
onClick={onClose}
className="fixed inset-0 bg-surface-0/70 z-50 flex items-center justify-center p-4"
onClick={() => void requestClose()}
>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
className="relative w-full max-w-4xl max-h-[90vh] rounded-xl border border-border bg-surface-1 flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
<Pencil className="w-4 h-4 text-fg-muted" />
<span className="text-sm font-medium text-fg-primary flex-1 truncate">{titleText}</span>
<span id={titleId} className="text-sm font-medium text-fg-primary flex-1 truncate">
{titleText}
</span>
<button
onClick={onClose}
type="button"
onClick={() => void requestClose()}
className="text-fg-secondary hover:text-fg-primary p-1 rounded-md hover:bg-surface-3"
aria-label={t("common.close")}
>
@@ -214,8 +307,14 @@ export function EditorModal<
{(["user", "project"] as const).map((s) => (
<button
key={s}
type="button"
aria-pressed={targetScope === s}
disabled={s === "project" && projectDisabled}
title={
s === "project" && projectDisabled ? t("scope.projectInCache") : undefined
}
onClick={() => setTargetScope(s)}
className={`px-3 py-1 text-[11px] font-medium rounded ${
className={`px-3 py-1 text-[11px] font-medium rounded disabled:opacity-40 disabled:cursor-not-allowed ${
targetScope === s
? "bg-accent/20 text-accent border border-accent/30"
: "text-fg-secondary hover:text-fg-primary"
@@ -240,6 +339,8 @@ export function EditorModal<
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
readOnly={loadState !== "ok"}
aria-label={t("edit.contentLabel")}
spellCheck={false}
className="w-full h-72 bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50 resize-y"
/>
@@ -247,7 +348,10 @@ export function EditorModal<
</div>
{error && (
<div className="rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-xs text-status-danger inline-flex items-center gap-2">
<div
role="alert"
className="rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-xs text-status-danger inline-flex items-center gap-2"
>
<AlertCircle className="w-3.5 h-3.5" />
{error}
</div>
@@ -256,14 +360,16 @@ export function EditorModal<
<div className="border-t border-border px-4 py-3 flex items-center justify-end gap-2">
<button
onClick={onClose}
type="button"
onClick={() => void requestClose()}
className="text-[12px] font-medium px-3 py-1.5 rounded-md border border-border bg-surface-2 hover:bg-surface-3 text-fg-secondary"
>
{t("edit.cancel")}
</button>
<button
type="button"
onClick={handleSave}
disabled={saving || loading}
disabled={saving || loading || loadState !== "ok"}
className="text-[12px] font-medium px-3 py-1.5 rounded-md border border-accent/40 bg-accent/15 hover:bg-accent/25 text-accent inline-flex items-center gap-1.5 disabled:opacity-60"
>
<Save className="w-3.5 h-3.5" />
+14 -10
View File
@@ -8,7 +8,8 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useRef, useState } from "react";
import { useDialog } from "../../hooks/useDialog";
import { useTranslation } from "react-i18next";
import { FileText, X, AlertCircle, Eye, Code2 } from "lucide-react";
import type { CcFileResponse } from "../../lib/api";
@@ -25,25 +26,27 @@ export function FileViewer({
const { t } = useTranslation("ccConfig");
const [preview, setPreview] = useState(false);
const canPreview = isMarkdown(state.path) && !!state.data;
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const panelRef = useRef<HTMLDivElement>(null);
useDialog(true, panelRef, onClose);
return (
<div
className="fixed inset-0 bg-black/60 z-40 flex items-center justify-center p-4"
className="fixed inset-0 bg-surface-0/70 z-50 flex items-center justify-center p-4"
onClick={onClose}
>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-labelledby="cc-file-viewer-title"
className="relative w-full max-w-4xl max-h-[85vh] rounded-xl border border-border bg-surface-1 flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
<FileText className="w-4 h-4 text-fg-muted" />
<span className="font-mono text-[12px] text-fg-secondary truncate flex-1">
<span
id="cc-file-viewer-title"
className="font-mono text-[12px] text-fg-secondary truncate flex-1"
>
{state.path}
</span>
{canPreview && (
@@ -59,6 +62,7 @@ export function FileViewer({
)}
<CopyButton value={state.path} />
<button
type="button"
onClick={onClose}
className="text-fg-secondary hover:text-fg-primary p-1 rounded-md hover:bg-surface-3"
aria-label={t("common.close")}
+2 -4
View File
@@ -13,6 +13,7 @@ import { useMemo, type MouseEvent } from "react";
import { useTranslation } from "react-i18next";
import { ExternalLink, Pencil, Trash2 } from "lucide-react";
import type { CcArtifactType, CcMdItem } from "../../lib/api";
import { copyText } from "../../lib/clipboard";
import {
ContextMenu,
MenuItem,
@@ -83,10 +84,7 @@ export function FileItemMenu({
<MenuItem testId="config-item-menu-edit" onSelect={pick(onEdit)}>
{l.edit}
</MenuItem>
<MenuItem
testId="config-item-menu-copy"
onSelect={pick(() => void navigator.clipboard.writeText(path))}
>
<MenuItem testId="config-item-menu-copy" onSelect={pick(() => void copyText(path))}>
{l.copyPath}
</MenuItem>
<MenuSeparator />
@@ -8,15 +8,19 @@
* The directory list is `GET /api/run/cwds` (the dashboard's known projects),
* offered as suggestions on a plain text input rather than a closed dropdown:
* a project you have never run through the dashboard is exactly the case this
* modal exists for, so it must stay typeable.
* modal exists for, so it must stay typeable. Typed text is a draft: closing
* asks first, and it is a real dialog (focus trap, Escape).
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useId, useState } from "react";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { BookOpen, X, Save, AlertCircle } from "lucide-react";
import { api, type CwdSuggestion } from "../../lib/api";
import { confirmDiscard } from "../../lib/confirm";
import { useDialog } from "../../hooks/useDialog";
import { useUnsavedGuard } from "../../hooks/useUnsavedGuard";
interface ProjectMemoryModalProps {
/** i18n namespace of the calling page — "ccConfig" or "codexConfig". */
@@ -55,13 +59,23 @@ export function ProjectMemoryModal({
.catch(() => undefined);
}, []);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
// Typed text is a draft: closing (X, Cancel, backdrop, Escape) asks first,
// and Escape inside the textarea only leaves the field.
const dirty = dir.trim() !== "" || content !== template;
useUnsavedGuard(dirty);
const panelRef = useRef<HTMLDivElement>(null);
const requestClose = useCallback(async () => {
if (dirty && !(await confirmDiscard())) return;
onClose();
}, [dirty, onClose]);
useDialog(true, panelRef, () => {
const el = document.activeElement;
if (el instanceof HTMLTextAreaElement) {
el.blur();
return;
}
void requestClose();
});
const handleCreate = useCallback(async () => {
const target = dir.trim();
@@ -82,22 +96,28 @@ export function ProjectMemoryModal({
return (
<div
className="fixed inset-0 bg-black/60 z-40 flex items-center justify-center p-4"
onClick={onClose}
className="fixed inset-0 bg-surface-0/70 z-50 flex items-center justify-center p-4"
onClick={() => void requestClose()}
>
<div
ref={panelRef}
role="dialog"
aria-label={t("projectMemory.title", { file: filename })}
aria-modal="true"
aria-labelledby={`${listId}-title`}
className="relative w-full max-w-3xl max-h-[90vh] rounded-xl border border-border bg-surface-1 flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
<BookOpen className="w-4 h-4 text-fg-muted" />
<span className="text-sm font-medium text-fg-primary flex-1 truncate">
<span
id={`${listId}-title`}
className="text-sm font-medium text-fg-primary flex-1 truncate"
>
{t("projectMemory.title", { file: filename })}
</span>
<button
onClick={onClose}
type="button"
onClick={() => void requestClose()}
className="text-fg-secondary hover:text-fg-primary p-1 rounded-md hover:bg-surface-3"
aria-label={t("projectMemory.close")}
>
@@ -151,7 +171,10 @@ export function ProjectMemoryModal({
</div>
{error && (
<div className="rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-xs text-status-danger inline-flex items-center gap-2">
<div
role="alert"
className="rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-xs text-status-danger inline-flex items-center gap-2"
>
<AlertCircle className="w-3.5 h-3.5" />
{error}
</div>
@@ -160,12 +183,14 @@ export function ProjectMemoryModal({
<div className="border-t border-border px-4 py-3 flex items-center justify-end gap-2">
<button
onClick={onClose}
type="button"
onClick={() => void requestClose()}
className="text-[12px] font-medium px-3 py-1.5 rounded-md border border-border bg-surface-2 hover:bg-surface-3 text-fg-secondary"
>
{t("projectMemory.cancel")}
</button>
<button
type="button"
onClick={handleCreate}
disabled={saving}
className="text-[12px] font-medium px-3 py-1.5 rounded-md border border-accent/40 bg-accent/15 hover:bg-accent/25 text-accent inline-flex items-center gap-1.5 disabled:opacity-60"
+9 -37
View File
@@ -1,46 +1,18 @@
/**
* @file ToastNotice.tsx
* @description Bottom-right success/error toast for the config explorer.
* Tool-agnostic — the page owns the 5s auto-dismiss timer and passes the
* message down.
* @description The config explorers' `{kind, message}` toast shape and
* `showToast`, which hands it to the app-wide toast queue (`lib/toast.ts`): a
* success fades on its own, an error stays until dismissed. The panels keep
* taking an `onToast(Toast)` prop, so this is the one place it is rendered.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { AlertCircle, Check, X } from "lucide-react";
import { toast } from "../../lib/toast";
export type Toast = { kind: "success" | "error"; message: string } | null;
export function ToastNotice({
toast,
onDismiss,
}: {
toast: NonNullable<Toast>;
onDismiss: () => void;
}) {
const isErr = toast.kind === "error";
return (
<div className="fixed bottom-6 right-6 z-50 max-w-md">
<div
className={`rounded-lg border px-3 py-2 shadow-lg flex items-start gap-2 ${
isErr
? "border-status-danger/50 bg-status-danger/15 text-status-danger"
: "border-status-success/40 bg-status-success/10 text-status-success"
}`}
>
{isErr ? (
<AlertCircle className="w-4 h-4 flex-shrink-0 mt-0.5" />
) : (
<Check className="w-4 h-4 flex-shrink-0 mt-0.5" />
)}
<span className="text-xs leading-relaxed flex-1 break-all">{toast.message}</span>
<button
onClick={onDismiss}
className="text-current/70 hover:text-current p-0.5"
aria-label="dismiss"
>
<X className="w-3 h-3" />
</button>
</div>
</div>
);
/** Show a config-page toast through the global queue. */
export function showToast(t: NonNullable<Toast>): void {
if (t.kind === "error") toast.error(t.message);
else toast.success(t.message);
}
@@ -7,9 +7,11 @@
*/
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { render, screen, act } from "@testing-library/react";
import i18n from "../../../i18n";
import userEvent from "@testing-library/user-event";
import { EditorModal } from "../EditorModal";
import { settleConfirm, subscribeConfirm, type PendingConfirm } from "../../../lib/confirm";
describe("EditorModal", () => {
it("loads the existing body through the injected fetcher, not a hardcoded API", async () => {
@@ -46,3 +48,156 @@ describe("EditorModal", () => {
expect(onSave).not.toHaveBeenCalled();
});
});
describe("EditorModal — data safety", () => {
const edit = {
mode: "edit" as const,
type: "agents" as const,
scope: "user" as const,
name: "a",
filePath: "/r/a.md",
};
it("loads once per file: a parent re-render with a new inline fetcher never overwrites typing", async () => {
let calls = 0;
const mk = () => async () => {
calls++;
return "disk";
};
const { rerender } = render(
<EditorModal
state={{ ...edit }}
fetchContent={mk()}
onClose={() => {}}
onSave={async () => {}}
/>
);
const box = await screen.findByDisplayValue("disk");
await userEvent.type(box, " mine");
rerender(
<EditorModal
state={{ ...edit }}
fetchContent={mk()}
onClose={() => {}}
onSave={async () => {}}
/>
);
await new Promise((r) => setTimeout(r, 20));
expect(calls).toBe(1);
expect(screen.getByDisplayValue("disk mine")).toBeInTheDocument();
});
it("disables Save after a failed load, so it can never write an empty file", async () => {
const onSave = vi.fn();
render(
<EditorModal
state={edit}
fetchContent={async () => {
throw new Error("nope");
}}
onClose={() => {}}
onSave={onSave}
/>
);
expect(await screen.findByText(/nope/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: /save/i })).toBeDisabled();
});
it("opens a truncated file read-only with Save disabled", async () => {
render(
<EditorModal
state={edit}
fetchContent={async () => ({ text: "partial", truncated: true })}
onClose={() => {}}
onSave={async () => {}}
/>
);
const box = await screen.findByDisplayValue("partial");
expect(box).toHaveAttribute("readonly");
expect(screen.getByRole("button", { name: /save/i })).toBeDisabled();
});
it("Escape inside the textarea does not close the editor", async () => {
const onClose = vi.fn();
render(
<EditorModal
state={edit}
fetchContent={async () => "x"}
onClose={onClose}
onSave={async () => {}}
/>
);
const box = await screen.findByDisplayValue("x");
box.focus();
await userEvent.keyboard("{Escape}");
expect(onClose).not.toHaveBeenCalled();
});
it("closing with unsaved edits asks first", async () => {
const onClose = vi.fn();
render(
<EditorModal
state={edit}
fetchContent={async () => "x"}
onClose={onClose}
onSave={async () => {}}
/>
);
await userEvent.type(await screen.findByDisplayValue("x"), "y");
await userEvent.click(screen.getByRole("button", { name: /cancel/i }));
expect(onClose).not.toHaveBeenCalled();
let pending: PendingConfirm | null = null;
const off = subscribeConfirm((p) => (pending = p));
expect(pending).not.toBeNull();
settleConfirm(true);
off();
await vi.waitFor(() => expect(onClose).toHaveBeenCalled());
});
it("asks before saving when the on-disk check cannot be read", async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
let n = 0;
render(
<EditorModal
state={edit}
fetchContent={async () => {
n++;
if (n > 1) throw new Error("offline");
return "x";
}}
onClose={() => {}}
onSave={onSave}
/>
);
await userEvent.type(await screen.findByDisplayValue("x"), "y");
let pending: PendingConfirm | null = null;
const off = subscribeConfirm((p) => (pending = p));
await userEvent.click(screen.getByRole("button", { name: /save/i }));
await vi.waitFor(() => expect(pending).not.toBeNull());
expect(onSave).not.toHaveBeenCalled();
settleConfirm(true);
off();
await vi.waitFor(() => expect(onSave).toHaveBeenCalled());
});
it("a language switch mid-edit does not reload the file over typing", async () => {
const fetchContent = vi.fn().mockResolvedValue("disk");
render(
<EditorModal
state={edit}
fetchContent={fetchContent}
onClose={() => {}}
onSave={async () => {}}
/>
);
await userEvent.type(await screen.findByDisplayValue("disk"), " mine");
await act(async () => {
await i18n.changeLanguage("vi");
});
await act(async () => {
await i18n.changeLanguage("en");
});
expect(fetchContent).toHaveBeenCalledTimes(1);
expect(screen.getByDisplayValue("disk mine")).toBeInTheDocument();
});
});
@@ -10,23 +10,23 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Copy, Check } from "lucide-react";
import { copyText } from "../../lib/clipboard";
export function CopyButton({ value }: { value: string }) {
const { t } = useTranslation("ccConfig");
const [copied, setCopied] = useState(false);
return (
<button
type="button"
onClick={async () => {
try {
await navigator.clipboard.writeText(value);
if (await copyText(value, true)) {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
/* clipboard unavailable */
}
}}
className="text-[10px] font-medium px-1.5 py-1 rounded border border-border bg-surface-1 hover:bg-surface-3 text-fg-secondary hover:text-fg-primary inline-flex items-center gap-1 flex-shrink-0"
title={t("common.copyPath")}
aria-label={t("common.copyPath")}
>
{copied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
</button>
@@ -5,13 +5,16 @@
* `index.css`). Each card's swatch re-scopes the theme's CSS variables on its
* own element (`dark` class or `data-mode`, plus `data-theme`), so the preview
* is painted by the same tokens as the app — no hex is duplicated here.
* **Reset to defaults** (after a confirm) returns to Dark with both stock themes.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useTranslation } from "react-i18next";
import { Monitor, Moon, Sun } from "lucide-react";
import { Monitor, Moon, RotateCcw, Sun } from "lucide-react";
import { SettingsRow, SettingsSegmented } from "./SettingsRow";
import { THEMES, useTheme, type Theme, type ThemeMode } from "../../hooks/useTheme";
import { confirmDialog } from "../../lib/confirm";
import { toast } from "../../lib/toast";
const THEME_LABELS: Record<string, string> = {
darcula: "Darcula",
@@ -61,6 +64,19 @@ export function AppearanceSettings() {
const { t } = useTranslation("settings");
const { mode, theme, themeFor, setMode, setThemeFor } = useTheme();
const reset = async () => {
const ok = await confirmDialog({
title: t("appearance.resetTitle"),
message: t("appearance.resetBody"),
confirmLabel: t("appearance.reset"),
});
if (!ok) return;
setMode("dark");
setThemeFor("dark", "default");
setThemeFor("light", "default");
toast.success(t("appearance.resetDone"));
};
return (
<div className="card p-5 space-y-5">
<SettingsRow
@@ -116,6 +132,13 @@ export function AppearanceSettings() {
</div>
</div>
))}
<div className="flex justify-end border-t border-border pt-3">
<button type="button" onClick={() => void reset()} className="btn-ghost text-xs">
<RotateCcw className="w-3.5 h-3.5" />
{t("appearance.reset")}
</button>
</div>
</div>
);
}
@@ -4,7 +4,7 @@
* under Settings → Claude Code → Overview: the CLAUDE_HOME the server reads
* transcripts and config from, the AgentHub hooks installed into Claude Code
* with a Reinstall button, and Model Pricing for Claude models (the shared
* `PricingSettings` table, with Reset Defaults).
* `PricingSettings` table, with Reset Defaults). Changing CLAUDE_HOME asks first.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -128,8 +128,9 @@ export function ClaudeAgentHubSettings({ onHomeChanged }: { onHomeChanged?: () =
id="claude-home"
title={t("claudeHome.title")}
description={t("claudeHome.description")}
note={t("cursorPathsNote")}
note={t("claudeHome.note")}
placeholder={t("claudeHome.placeholder")}
confirm={{ title: t("claudeHome.confirmTitle"), message: t("claudeHome.confirmBody") }}
load={() =>
api.settings.claudeHome
.get()
@@ -5,14 +5,18 @@
* server's refusal inline, the saved value while the field differs from it,
* and a warning when the real
* environment sets the variable (the environment wins at the next start, so a
* saved value would not survive a restart). The server validates and persists;
* this component only loads, submits and reports.
* saved value would not survive a restart). Enter saves; a caller may ask for
* a confirmation first (`confirm`). The server validates and persists; this
* component only loads, submits and reports.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { AlertTriangle, FolderOpen } from "lucide-react";
import { confirmDialog } from "../../lib/confirm";
import { isSubmitEnter } from "../../lib/keys";
import { SettingsSection } from "./SettingsRow";
export interface HomeDirValue {
value: string;
@@ -29,6 +33,8 @@ interface Props {
/** Optional second line under the description. */
note?: string;
placeholder: string;
/** Ask this before saving (a change with wide effects, like CLAUDE_HOME). */
confirm?: { title: string; message?: string };
load: () => Promise<HomeDirValue>;
save: (path: string) => Promise<HomeDirValue>;
}
@@ -37,7 +43,16 @@ interface Props {
* An editable home directory with its own load/save cycle.
* @returns The section, or its skeleton-free shell while loading.
*/
export function HomeDirSetting({ id, title, description, note, placeholder, load, save }: Props) {
export function HomeDirSetting({
id,
title,
description,
note,
placeholder,
confirm,
load,
save,
}: Props) {
const { t } = useTranslation("settings");
const [current, setCurrent] = useState<HomeDirValue | null>(null);
const [input, setInput] = useState("");
@@ -61,7 +76,8 @@ export function HomeDirSetting({ id, title, description, note, placeholder, load
}, []);
const handleSave = async () => {
if (!current || input === current.value) return;
if (!current || input === current.value || saving) return;
if (confirm && !(await confirmDialog({ ...confirm, confirmLabel: t("homeDir.save") }))) return;
setSaving(true);
setError(null);
try {
@@ -76,15 +92,18 @@ export function HomeDirSetting({ id, title, description, note, placeholder, load
};
return (
<section id={id} className="scroll-mt-24">
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
<FolderOpen className="w-4 h-4 text-fg-muted" />
{title}
</h3>
<p className="text-xs text-fg-muted mb-1">{description}</p>
{note && <p className="text-[11px] text-fg-muted italic mb-4 leading-snug">{note}</p>}
<div className={`card p-5 space-y-4 ${note ? "" : "mt-3"}`}>
<SettingsSection
id={id}
title={
<span className="flex items-center gap-2">
<FolderOpen className="w-4 h-4 text-fg-muted" />
{title}
</span>
}
description={description}
>
<div className="space-y-4">
{note && <p className="text-[11px] text-fg-muted italic leading-snug">{note}</p>}
<div className="flex items-center gap-3">
<input
type="text"
@@ -94,10 +113,17 @@ export function HomeDirSetting({ id, title, description, note, placeholder, load
setInput(e.target.value);
setError(null);
}}
className="flex-1 bg-surface-4 border border-surface-3 rounded-lg px-3 py-2 text-sm text-fg-secondary font-mono focus:outline-none focus:border-violet-500/50"
onKeyDown={(e) => {
if (isSubmitEnter(e)) {
e.preventDefault();
void handleSave();
}
}}
className="input flex-1 font-mono"
placeholder={placeholder}
/>
<button
type="button"
onClick={handleSave}
disabled={saving || !current || input === current.value}
className="btn-primary px-4 py-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
@@ -125,6 +151,6 @@ export function HomeDirSetting({ id, title, description, note, placeholder, load
</p>
)}
</div>
</section>
</SettingsSection>
);
}
+55 -6
View File
@@ -6,17 +6,31 @@
* uploading an image or importing a `.codex-pet.zip`. Everything here is the
* same state and calls as the mascot's right-click menu (`Sagi/usePets.ts`,
* `Sagi/prefs.ts`): the choice and size are per browser, the pets live on the
* server (`/api/pets`).
* server (`/api/pets`). Previews follow the OS reduced-motion preference; size
* and bubbles are disabled while the pet is hidden; a delete asks first; and
* **Reset to defaults** puts show / bubbles / size / pet back.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useRef, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Check, Download, ExternalLink, Loader2, PackageOpen, Trash2, Upload } from "lucide-react";
import {
Check,
Download,
ExternalLink,
Loader2,
PackageOpen,
RotateCcw,
Trash2,
Upload,
} from "lucide-react";
import { SettingsRow, SettingsSwitchRow } from "./SettingsRow";
import { SagiAvatar } from "../Sagi/SagiAvatar";
import { PetSprite } from "../Sagi/PetSprite";
import { PET_SIZE, sagiPrefs } from "../Sagi/prefs";
import { usePrefersReducedMotion } from "../Sagi/Sagi";
import { confirmDialog } from "../../lib/confirm";
import { toast } from "../../lib/toast";
import { usePets, useWornPet } from "../Sagi/usePets";
import type { PetView } from "../../lib/api";
@@ -78,7 +92,9 @@ function PetCard({
disabled={disabled}
aria-label={deleteLabel}
title={deleteLabel}
className="absolute right-1.5 top-1.5 rounded p-1 text-fg-muted opacity-0 transition-opacity hover:bg-surface-4 hover:text-status-danger focus:opacity-100 group-hover:opacity-100"
className={`absolute right-1.5 top-1.5 rounded p-1 text-fg-muted transition-opacity hover:bg-surface-4 hover:text-status-danger focus:opacity-100 group-hover:opacity-100 group-focus-within:opacity-100 ${
selected ? "opacity-100" : "opacity-0"
}`}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
@@ -88,13 +104,14 @@ function PetCard({
}
function Preview({ pet, size }: { pet: PetView; size: number }) {
const reducedMotion = usePrefersReducedMotion();
return (
<PetSprite
id={pet.id}
kind={pet.kind}
mood="idle"
size={size}
reducedMotion={false}
reducedMotion={reducedMotion}
onBroken={() => {}}
/>
);
@@ -108,6 +125,22 @@ export function PetSettings() {
const bundleInput = useRef<HTMLInputElement>(null);
const wornPet = list?.pets.find((p) => p.id === worn.pet.id);
const sizePreview = Math.min(worn.size, 120);
const reducedMotion = usePrefersReducedMotion();
const resetDefaults = async () => {
const ok = await confirmDialog({
title: t("sagi.pet.reset"),
message: t("sagi.pet.resetMessage"),
confirmLabel: t("sagi.pet.reset"),
destructive: false,
});
if (!ok) return;
sagiPrefs.setEnabled(true);
sagiPrefs.setMuted(false);
sagiPrefs.setSize(PET_SIZE.default);
sagiPrefs.setPet({ id: "sagi" });
toast.success(t("sagi.pet.resetDone"));
};
return (
<div className="space-y-6">
@@ -123,6 +156,7 @@ export function PetSettings() {
onChange={(v) => sagiPrefs.setMuted(!v)}
label={t("sagi.bubbles")}
description={t("sagi.bubblesDesc")}
disabled={!worn.shown}
/>
<SettingsRow
label={t("sagi.pet.size")}
@@ -136,8 +170,9 @@ export function PetSettings() {
min={PET_SIZE.min}
max={PET_SIZE.max}
value={worn.size}
disabled={!worn.shown}
onChange={(e) => sagiPrefs.setSize(Number(e.target.value))}
className="w-40 accent-[rgb(var(--accent))]"
className="w-40 accent-[rgb(var(--accent))] disabled:opacity-50"
/>
<span className="w-12 text-right text-xs tabular-nums text-fg-muted">
{worn.size}px
@@ -152,9 +187,23 @@ export function PetSettings() {
{wornPet ? (
<Preview pet={wornPet} size={sizePreview} />
) : (
<SagiAvatar mood="idle" reducedMotion={false} size={sizePreview} />
<SagiAvatar mood="idle" reducedMotion={reducedMotion} size={sizePreview} />
)}
</div>
{worn.size > 120 && (
<p className="mt-1.5 text-[11px] text-fg-muted">
{t("sagi.pet.previewCapped", { size: worn.size })}
</p>
)}
{!worn.shown && (
<p className="mt-1.5 text-[11px] text-fg-muted">{t("sagi.pet.hiddenNote")}</p>
)}
<div className="mt-3 flex justify-end">
<button type="button" className="btn-ghost text-xs" onClick={() => void resetDefaults()}>
<RotateCcw className="h-3.5 w-3.5" />
{t("sagi.pet.reset")}
</button>
</div>
</div>
<div className="card p-5">
@@ -9,7 +9,10 @@
* filter turns it on rather than vanishing. Reset Defaults reseeds Claude's
* rates, so only the Claude tab offers it; the server re-fetches any row the
* automatic sync owns (OpenAI's, Gemini's) straight after. A right-click on a
* rule row opens its Edit / Delete (plus Copy model pattern) as a menu.
* rule row opens its Edit / Delete (plus Copy model pattern) as a menu. A
* delete asks first (the sync never re-adds a deleted pattern). Every mounted
* table shares ONE list request and reloads together after any change, so the
* Pricing page's per-vendor tables never go stale against each other.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -23,6 +26,9 @@ import { isRemoteDataRefreshMessage } from "../../lib/remoteDataEvents";
import { getCurrentLocale } from "../../lib/format";
import { Skeleton } from "../Skeleton";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
import { confirmDialog } from "../../lib/confirm";
import { copyText } from "../../lib/clipboard";
import { useArmed } from "../../hooks/useArmed";
import type { ModelPricing, WSMessage } from "../../lib/types";
interface EditRow {
@@ -185,6 +191,20 @@ function PricingInfoTooltip({ vendor }: { vendor: PricingVendor }) {
export type PricingVendor = "claude" | "openai" | "google";
// Every mounted table's `load`: a change in one reloads them all, and loads
// fired together share one request.
const tables = new Set<() => void>();
let inflight: ReturnType<typeof api.pricing.list> | null = null;
function listPricing() {
inflight ??= api.pricing.list().finally(() => {
inflight = null;
});
return inflight;
}
function reloadAllTables() {
for (const load of tables) load();
}
const VENDOR_PATTERN: Record<PricingVendor, RegExp> = {
claude: /^claude-/,
openai: /^(gpt-|o\d|codex-)/,
@@ -216,7 +236,9 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing", he
const [error, setError] = useState<string | null>(null);
const [showAll, setShowAll] = useState(false);
const [resetting, setResetting] = useState(false);
const [confirmReset, setConfirmReset] = useState(false);
// Reset Defaults is a two-click that disarms itself after 3 s.
const resetArm = useArmed();
const confirmReset = resetArm.isArmed("reset");
const ctx = useContextMenu<ModelPricing>();
// A menu item: close the menu, then act on the rule it was opened on.
const pick = (fn: (rule: ModelPricing) => unknown) => () => {
@@ -232,7 +254,7 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing", he
const load = useCallback(async () => {
try {
const res = await api.pricing.list();
const res = await listPricing();
setPricing(res.pricing);
setError(null);
} catch (err) {
@@ -244,6 +266,11 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing", he
useEffect(() => {
load();
const reload = () => void load();
tables.add(reload);
return () => {
tables.delete(reload);
};
}, [load]);
// A remote source can bring its own pricing rules; reload when one syncs.
@@ -332,7 +359,7 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing", he
if (!ours(editRow.model_pattern.trim())) setShowAll(true);
setEditingPattern(null);
setAdding(false);
await load();
reloadAllTables();
} catch (err) {
setError(err instanceof Error ? err.message : t("messages.failedSave"));
} finally {
@@ -341,9 +368,17 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing", he
};
const deleteRule = async (pattern: string) => {
if (
!(await confirmDialog({
title: t("pricing.deleteConfirmTitle", { pattern }),
message: t("pricing.deleteConfirmBody"),
confirmLabel: t("common:delete"),
}))
)
return;
try {
await api.pricing.delete(pattern);
await load();
reloadAllTables();
} catch (err) {
setError(err instanceof Error ? err.message : t("messages.failedDelete"));
}
@@ -352,7 +387,7 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing", he
const handleResetPricing = async () => {
setResetting(true);
setActionResult(null);
setConfirmReset(false);
resetArm.disarm();
try {
// Only the Claude tab offers the button, and it resets only Claude's rows.
const res = await api.settings.resetPricing("claude");
@@ -363,7 +398,7 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing", he
}),
isError: false,
});
await load();
reloadAllTables();
} catch (err) {
setActionResult({
key: "reset-pricing",
@@ -591,7 +626,7 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing", he
)}
{allowReset && (
<button
onClick={() => (confirmReset ? handleResetPricing() : setConfirmReset(true))}
onClick={() => (confirmReset ? handleResetPricing() : resetArm.arm("reset"))}
disabled={isEditing || resetting}
className={`text-xs px-3 py-1.5 rounded-md transition-colors disabled:opacity-50 inline-flex items-center gap-1.5 ${
confirmReset
@@ -749,7 +784,7 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing", he
<button
onClick={() => startEdit(rule)}
disabled={isEditing}
className="p-1.5 rounded-md text-fg-secondary hover:text-blue-500 hover:bg-blue-600/10 transition-colors disabled:opacity-30"
className="p-1.5 rounded-md text-fg-secondary hover:text-accent hover:bg-accent/10 transition-colors disabled:opacity-30"
title={t("common:edit")}
>
<Pencil className="w-3.5 h-3.5" />
@@ -782,9 +817,7 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing", he
<MenuItem disabled={isEditing} onSelect={pick(startEdit)}>
{t("common:edit")}
</MenuItem>
<MenuItem
onSelect={pick((rule) => void navigator.clipboard.writeText(rule.model_pattern))}
>
<MenuItem onSelect={pick((rule) => void copyText(rule.model_pattern))}>
{t("rowMenu.copyPattern")}
</MenuItem>
<MenuSeparator />
+27 -5
View File
@@ -2,25 +2,29 @@
* @file SettingsRow — the settings page's row primitives: a titled card
* (`SettingsSection`), a label/description | control row (`SettingsRow`), a
* switch row (`SettingsSwitchRow`) and a segmented radio group
* (`SettingsSegmented`). Rows divide themselves with a hairline, none after the
* last, so a section is just its rows in order.
* (`SettingsSegmented`, arrow keys + one roving tab stop). Rows divide
* themselves with a hairline, none after the last, so a section is just its
* rows in order.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { Switch } from "../Switch";
import type { ReactNode } from "react";
import type { KeyboardEvent, ReactNode } from "react";
export function SettingsSection({
id,
title,
description,
children,
}: {
/** `<section id>` — a deep-link target. */
id?: string;
title: ReactNode;
description?: ReactNode;
children: ReactNode;
}) {
return (
<section className="card p-5">
<section id={id} className={id ? "card p-5 scroll-mt-24" : "card p-5"}>
<h3 className="text-sm font-semibold text-fg-primary">{title}</h3>
{description && <p className="mt-0.5 text-xs text-fg-muted">{description}</p>}
<div className="mt-2">{children}</div>
@@ -87,18 +91,36 @@ export function SettingsSegmented<T extends string>({
onChange: (v: T) => void;
ariaLabel?: string;
}) {
// A radio group's keyboard contract: one tab stop (the checked option), and
// the arrow keys move the selection — wrapping — and the focus with it.
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
const step = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[e.key];
if (!step || options.length === 0) return;
e.preventDefault();
const at = Math.max(
0,
options.findIndex((o) => o.value === value)
);
const next = (at + step + options.length) % options.length;
onChange(options[next]!.value);
(e.currentTarget.children[next] as HTMLElement | undefined)?.focus();
};
return (
<div
role="radiogroup"
aria-label={ariaLabel}
onKeyDown={onKeyDown}
className="inline-flex rounded-lg border border-border bg-surface-2 p-0.5"
>
{options.map((o) => (
{options.map((o, i) => (
<button
key={o.value}
type="button"
role="radio"
aria-checked={value === o.value}
tabIndex={
value === o.value || (i === 0 && !options.some((x) => x.value === value)) ? 0 : -1
}
onClick={() => onChange(o.value)}
className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
value === o.value
@@ -13,6 +13,12 @@ import i18n from "i18next";
import { ClaudeAgentHubSettings } from "../ClaudeAgentHubSettings";
import { api } from "../../../lib/api";
import type { ModelPricing } from "../../../lib/types";
import { settleConfirm, subscribeConfirm } from "../../../lib/confirm";
/** Say yes to every confirm (a CLAUDE_HOME change now asks first). */
function confirmYes() {
return subscribeConfirm((p) => p && queueMicrotask(() => settleConfirm(true)));
}
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
@@ -89,8 +95,10 @@ describe("ClaudeAgentHubSettings", () => {
await user.clear(input);
await user.type(input, "/srv/claude");
const home = container.querySelector("section#claude-home") as HTMLElement;
const off = confirmYes();
await user.click(within(home).getByRole("button"));
expect(api.settings.claudeHome.set).toHaveBeenCalledWith("/srv/claude");
await waitFor(() => expect(api.settings.claudeHome.set).toHaveBeenCalledWith("/srv/claude"));
off();
const hooks = container.querySelector("section#hooks") as HTMLElement;
await user.click(within(hooks).getByRole("button"));
@@ -106,8 +114,10 @@ describe("ClaudeAgentHubSettings", () => {
await user.type(input, "/srv/claude");
const home = container.querySelector("section#claude-home") as HTMLElement;
const reads = vi.mocked(api.settings.info).mock.calls.length;
const off = confirmYes();
await user.click(within(home).getByRole("button"));
await waitFor(() => expect(onHomeChanged).toHaveBeenCalledTimes(1));
off();
expect(vi.mocked(api.settings.info).mock.calls.length).toBeGreaterThan(reads);
});
@@ -12,6 +12,7 @@ import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "i18next";
import { HomeDirSetting, type HomeDirValue } from "../HomeDirSetting";
import { settleConfirm, subscribeConfirm, type PendingConfirm } from "../../../lib/confirm";
beforeEach(() => {
i18n.changeLanguage("en");
@@ -80,4 +81,42 @@ describe("HomeDirSetting", () => {
await user.click(screen.getByRole("button", { name: "Save" }));
expect(await screen.findByText("lane 3 not updated")).toBeInTheDocument();
});
it("saves on Enter", async () => {
const user = userEvent.setup();
const { saveFn } = setup({ value: "/a", envOwned: false }, (p) =>
Promise.resolve({ value: p, envOwned: false })
);
const input = await screen.findByDisplayValue("/a");
await user.clear(input);
await user.type(input, "/b{Enter}");
expect(saveFn).toHaveBeenCalledWith("/b");
});
it("asks first when given a confirm, and saves nothing on cancel", async () => {
let pending: PendingConfirm | null = null;
const off = subscribeConfirm((p) => (pending = p));
const user = userEvent.setup();
const saveFn = vi.fn((p: string) => Promise.resolve({ value: p, envOwned: false }));
render(
<HomeDirSetting
id="x-home"
title="X_HOME"
description="desc"
placeholder="/path"
confirm={{ title: "Change X_HOME?", message: "sure?" }}
load={() => Promise.resolve({ value: "/a", envOwned: false })}
save={saveFn}
/>
);
const input = await screen.findByDisplayValue("/a");
await user.clear(input);
await user.type(input, "/b");
await user.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(pending?.title).toBe("Change X_HOME?"));
settleConfirm(false);
await waitFor(() => expect(pending).toBeNull());
expect(saveFn).not.toHaveBeenCalled();
off();
});
});
@@ -15,6 +15,18 @@ import i18n from "i18next";
import { PricingSettings } from "../PricingSettings";
import { api } from "../../../lib/api";
import type { ModelPricing } from "../../../lib/types";
import { settleConfirm, subscribeConfirm, type PendingConfirm } from "../../../lib/confirm";
/** Answer every confirm dialog with `ok`; returns the asked titles + unsubscribe. */
function answerConfirms(ok: boolean) {
const asked: PendingConfirm[] = [];
const off = subscribeConfirm((p) => {
if (!p) return;
asked.push(p);
queueMicrotask(() => settleConfirm(ok));
});
return { asked, off };
}
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
@@ -113,16 +125,56 @@ describe("PricingSettings", () => {
vi.mocked(api.pricing.delete)
.mockReset()
.mockResolvedValue({} as never);
const confirm = answerConfirms(true);
const user = userEvent.setup();
render(<PricingSettings vendor="openai" />);
const row = (await screen.findByText("gpt-5.6-sol%")).closest("tr")!;
await user.pointer({ keys: "[MouseRight]", target: row });
await user.click(screen.getByRole("menuitem", { name: "Delete" }));
expect(api.pricing.delete).toHaveBeenCalledWith("gpt-5.6-sol%");
await vi.waitFor(() => expect(api.pricing.delete).toHaveBeenCalledWith("gpt-5.6-sol%"));
confirm.off();
await user.pointer({ keys: "[MouseRight]", target: row });
await user.click(screen.getByRole("menuitem", { name: "Edit" }));
expect(screen.getByDisplayValue("GPT-5.6 Sol")).toBeInTheDocument();
});
it("asks before deleting a rule, saying the sync will not bring it back", async () => {
vi.mocked(api.pricing.delete)
.mockReset()
.mockResolvedValue({} as never);
const confirm = answerConfirms(false);
const user = userEvent.setup();
render(<PricingSettings vendor="openai" />);
await screen.findByText("gpt-5.6-sol%");
await user.click(screen.getByTitle("Delete"));
await vi.waitFor(() => expect(confirm.asked).toHaveLength(1));
expect(confirm.asked[0]!.message).toMatch(/sync/i);
expect(api.pricing.delete).not.toHaveBeenCalled();
confirm.off();
});
it("refreshes every pricing table on the page after a change in one", async () => {
vi.mocked(api.pricing.delete)
.mockReset()
.mockResolvedValue({} as never);
const confirm = answerConfirms(true);
const user = userEvent.setup();
render(
<>
<PricingSettings vendor="claude" id="a" />
<PricingSettings vendor="openai" id="b" />
</>
);
await screen.findByText("gpt-5.6-sol%");
await screen.findByText("claude-opus-5-5%");
vi.mocked(api.pricing.list).mockResolvedValue({
pricing: [...ROWS, rule("claude-fable-5%", "Claude Fable 5")],
});
// The second table (openai) deletes its row; the first must reload too.
await user.click(screen.getAllByTitle("Delete")[1]!);
expect(await screen.findByText("claude-fable-5%")).toBeInTheDocument();
confirm.off();
});
});
@@ -0,0 +1,45 @@
/**
* @file SettingsRow.test.tsx
* @description `SettingsSegmented` is a real radio group: only the checked
* option is in the tab order, and the arrow keys move (and select) within it.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { describe, expect, it } from "vitest";
import { SettingsSegmented } from "../SettingsRow";
function Group() {
const [v, setV] = useState<"a" | "b" | "c">("a");
return (
<SettingsSegmented
ariaLabel="mode"
value={v}
onChange={setV}
options={[
{ value: "a", label: "A" },
{ value: "b", label: "B" },
{ value: "c", label: "C" },
]}
/>
);
}
describe("SettingsSegmented", () => {
it("roves the tab stop and moves with the arrow keys", async () => {
const user = userEvent.setup();
render(<Group />);
const [a, b, c] = screen.getAllByRole("radio");
expect(a).toHaveAttribute("tabindex", "0");
expect(b).toHaveAttribute("tabindex", "-1");
a!.focus();
await user.keyboard("{ArrowRight}");
expect(b).toHaveAttribute("aria-checked", "true");
expect(b).toHaveFocus();
await user.keyboard("{ArrowLeft}{ArrowLeft}");
expect(c).toHaveAttribute("aria-checked", "true");
expect(c).toHaveFocus();
});
});
+12 -1
View File
@@ -64,7 +64,11 @@
"minutesStatus": "Fire when an agent stays in the selected status with no activity for this many minutes (a stuck agent).",
"totalTokens": "Fire when a session's cumulative tokens (input + output + cache read + cache write) cross this number. e.g. 1000000 = 1M tokens.",
"cooldown": "Minimum seconds between repeat alerts for the same rule + session + agent. Prevents noisy duplicates. Default 300 (5 min); 0 = no cooldown."
}
},
"edit": "Edit rule",
"save": "Save rule",
"typeFixed": "A rule's type cannot be changed — create a new rule instead.",
"cooldownInvalid": "Enter a whole number of seconds (0 or more)."
},
"feed": {
"title": "Alert feed",
@@ -76,5 +80,12 @@
"emptyTitle": "No alerts",
"emptyDescription": "Alerts fired by your rules will appear here in real time.",
"emptyUnacked": "Everything is acknowledged. New alerts will appear here as they fire."
},
"errors": {
"loadRules": "Could not load alert rules: {{error}}",
"loadAlerts": "Could not load alerts: {{error}}",
"toggleRule": "Could not change the rule: {{error}}",
"deleteRule": "Could not delete the rule: {{error}}",
"ack": "Could not acknowledge: {{error}}"
}
}
+58 -6
View File
@@ -10,7 +10,9 @@
"user": "User",
"project": "Project",
"projectLocal": "Project (local)",
"home": "Home"
"home": "Home",
"projectIs": "Project = {{path}}",
"projectInCache": "The dashboard runs from Claude Code's plugin cache, which is replaced on every plugin update — project files written there would be lost. Project scope is off."
},
"tabs": {
"overview": "Overview",
@@ -155,7 +157,9 @@
"fileMissing": "(no settings file at this scope)"
},
"settings": {
"fileMissing": "(no settings file at this scope)"
"fileMissing": "(no settings file at this scope)",
"structured": "Structured",
"rawJson": "Raw JSON"
},
"memory": {
"userScope": "User memory",
@@ -222,7 +226,17 @@
"output-styles": "---\nname: my-style\ndescription: Output style description\n---\n\nStyle instructions.\n",
"memory": "# Project memory\n\nClaude reads this on session start.\n",
"auto-memory": "---\nname: my-fact\ndescription: One-line summary of the remembered fact\nmetadata:\n type: feedback\n---\n\nThe fact to remember.\n"
}
},
"nameRequired": "Enter a name first.",
"nameInvalid": "Use letters, digits, dot, dash or underscore (start with a letter or digit, max 64).",
"loadError": "Could not load the file: {{message}}. Saving is disabled so it cannot be overwritten.",
"truncatedReadOnly": "This file is larger than 256 KB and was only partly loaded, so it opens read-only. Edit it in your own editor.",
"changedOnDiskTitle": "File changed on disk",
"changedOnDiskBody": "Something else changed this file since you opened it. Saving now replaces that change with yours (a backup is still made).",
"overwrite": "Overwrite",
"unverifiedTitle": "Couldn't verify the file on disk",
"unverifiedBody": "The file could not be re-read to check whether something else changed it since you opened it. Save anyway? (A backup of the current file is still made.)",
"saveAnyway": "Save anyway"
},
"backups": {
"title": "Backups",
@@ -230,7 +244,7 @@
"viewLink": "Show backup history",
"openButton": "Backups",
"subtitle": "Every save / delete in the editor writes a copy here first. Backup folders live outside the directories Claude Code scans, so they don't reappear as ghosts in your CLI.",
"restoreHint": "Restore by renaming the backup back into place:",
"restoreHint": "Restore by copying the backup back into place (the backup is kept):",
"size": "Size",
"created": "Created",
"type": "Type",
@@ -268,7 +282,9 @@
"renameLabel": "Profile name",
"renameHint": "Click to rename",
"rename": "Rename",
"renameSuccess": "Renamed to \"{{name}}\"."
"renameSuccess": "Renamed to \"{{name}}\".",
"deleteConfirmTitle": "Delete profile “{{name}}”?",
"deleteConfirmBody": "This removes the saved login (OAuth credentials) for this account. To use it again you must run `claude auth login` and save it once more."
},
"explain": {
"hooks": {
@@ -390,6 +406,42 @@
"settingsBadJson": "Value of {{key}} is not valid JSON.",
"settingsSaved": "Settings saved to {{file}}.",
"settingsHelp": "Values are JSON: \"opus\", true, 42, [\"a\"], {\"k\":1}. Only the keys you change are written; the rest of the file is left as-is.",
"settingsRedactedKeys": "Not editable here because their values are redacted: {{keys}}"
"settingsRedactedKeys": "Not editable here because their values are redacted: {{keys}}",
"hooksChangedBody": "These hooks changed on disk since you started editing. Saving writes your version of the events you edited; events added meanwhile are kept.",
"settingsChangedBody": "This settings file changed on disk since you started editing (the running CLI rewrites it). Saving writes the keys you changed; keys added meanwhile are kept.",
"settingsRedactedKey": "“{{key}}” holds redacted secrets and can't be edited here — saving it would replace the real values with what you typed. Edit it in the file itself.",
"hookReadOnlyEvent": "“{{event}}” already has hooks that can't be edited here (read-only) — a new row with that name would replace them. Use another event name, or edit the file itself."
},
"tabGroups": {
"content": "Content",
"extensions": "Extensions",
"config": "Config"
},
"currentConfig": {
"title": "Current configuration",
"setCount_one": "{{count}} option set · the rest use defaults",
"setCount_other": "{{count}} options set · the rest use defaults",
"default": "default",
"groups": {
"model": "Model & reasoning",
"output": "Output & display",
"session": "Session & input"
},
"keys": {
"model": "Model",
"effortLevel": "Effort level",
"alwaysThinkingEnabled": "Always thinking",
"outputStyle": "Output style",
"verbose": "Verbose output",
"theme": "Theme",
"language": "Language",
"spinnerTipsEnabled": "Spinner tips",
"autoScrollEnabled": "Auto-scroll",
"autoCompactEnabled": "Auto-compact",
"fileCheckpointingEnabled": "File checkpointing",
"editorMode": "Editor mode",
"preferredNotifChannel": "Notifications",
"awaySummaryEnabled": "Away summary"
}
}
}
+1 -1
View File
@@ -116,7 +116,7 @@
"title": "Backups",
"subtitle": "Every save / delete writes a copy here first. Backup folders live outside the directories the CLI scans, so they don't reappear as ghosts in your CLI.",
"empty": "No backups yet - every edit or delete creates a timestamped backup that lands here.",
"restoreHint": "Restore by renaming the backup back into place:"
"restoreHint": "Restore by copying the backup back into place (the backup is kept):"
},
"snippet": {
"copy": "Copy",
+118 -18
View File
@@ -20,13 +20,46 @@
"upload": "Upload your own…",
"import": "Import .codex-pet bundle…",
"browse": "Find more on codex-pets.net",
"settings": "Pet settings…"
"settings": "Pet settings…",
"hiddenToast": "Pet hidden. Show it again in Settings → Pet.",
"deleteTitle": "Delete {{name}}?",
"deleteMessage": "The pet's files are removed from this install. A featured pet can be downloaded again; an upload is gone for good.",
"deleteConfirm": "Delete pet",
"reset": "Reset to defaults",
"resetDone": "Pet settings reset to defaults.",
"hiddenNote": "The pet is hidden — size and speech bubbles apply once it is shown again.",
"resetMessage": "Show the pet again with speech bubbles on, at 68 px, wearing Sagi. Your uploaded and downloaded pets are kept.",
"previewCapped": "The preview is drawn at 120 px; the pet itself is {{size}} px."
},
"bubbles": "Speech bubbles",
"bubblesDesc": "Let the pet comment on your sessions in a bubble",
"sizeDesc": "{{min}}–{{max}} px; the preview is capped at 120 px",
"chooseDesc": "Featured pets come from codex-pets.net and are downloaded into this install the first time you pick one. Your choice and size are saved in this browser.",
"uploadHint": "An image (PNG, JPEG, GIF, WebP — no SVG) is shown still; a .codex-pet.zip (pet.json + a 1536×1872 spritesheet.webp) is animated by mood."
"uploadHint": "An image (PNG, JPEG, GIF, WebP — no SVG) is shown still; a .codex-pet.zip (pet.json + a 1536×1872 spritesheet.webp) is animated by mood.",
"panel": {
"label": "Sagi companion",
"live": "Live",
"offline": "Offline",
"close": "Close Sagi",
"statLive": "live",
"statWaiting": "waiting",
"statErrored": "errored",
"runClaude": "Run Claude",
"activity": "Activity",
"sessions": "Sessions",
"errored": "Errored",
"mute": "Mute",
"unmute": "Unmute",
"clearAlerts": "Clear alerts",
"askPlaceholder": "Ask Sagi… (e.g. any errors?)",
"ask": "Ask Sagi",
"send": "Send"
},
"bubbleDismiss": "Dismiss",
"open": "Open Sagi companion",
"closeAvatar": "Close Sagi",
"avatarTitle": "Sagi — Ctrl/⌘+B · drag or arrow keys to move · right-click for pets",
"startFailed": "Couldn't start that session: {{error}}"
},
"pricing": {
"vendor": {
@@ -85,12 +118,17 @@
}
},
"autoBadge": "Auto",
"autoBadgeTitle": "Kept at the published rate automatically (LiteLLM's price list, checked daily). Edit it to keep your own rate."
"autoBadgeTitle": "Kept at the published rate automatically (LiteLLM's price list, checked daily). Edit it to keep your own rate.",
"deleteConfirmTitle": "Delete the pricing rule {{pattern}}?",
"deleteConfirmBody": "Sessions on this model will be priced by another matching rule, or not at all. The automatic price sync will NOT add this pattern back; add it again by hand if you change your mind."
},
"claudeHome": {
"title": "CLAUDE_HOME",
"description": "Root directory for Claude Code session data. Changing this updates the scan path for transcripts and imports immediately.",
"placeholder": "/path/to/claude-home"
"placeholder": "/path/to/claude-home",
"note": "Nothing is moved or copied: AgentHub just starts reading transcripts, config and hooks from the new folder. Cursor sessions use the same ~/.claude paths, so they follow it too.",
"confirmTitle": "Change CLAUDE_HOME?",
"confirmBody": "AgentHub will read sessions, config, skills and hooks from the new folder from now on. Sessions under the old folder stop being imported until you switch back. Nothing is moved or deleted."
},
"hooks": {
"title": "Hook Configuration",
@@ -106,7 +144,16 @@
"description": "Machine-wide workspace housekeeping.",
"runHousekeeping": "Run housekeeping",
"dryRun": "Dry run",
"gcResult": "Reaped {{reaped}} process(es), capped {{capped}} log(s)"
"gcResult": "Reaped {{reaped}} process(es), capped {{capped}} log(s)",
"explain": "Cleans up after workspace sessions: stops MCP server processes that ended sessions left running, and trims workspace log files that grew too large. Nothing in your repositories is touched.",
"preview": "Preview housekeeping",
"gcReapLine": "stop leftover process {{pid}}",
"gcCapLine": "trim {{path}} ({{size}})",
"gcNothing": "Nothing to clean up — no leftover processes and no oversized logs.",
"gcConfirmTitle": "Run housekeeping?",
"gcConfirmBody": "This will:",
"gcMore": "and {{count}} more",
"gcPreviewResult": "Dry run: would stop {{reaped}} process(es) and trim {{capped}} log(s)"
},
"notifications": {
"awaitingInput": "Claude needs confirmation",
@@ -127,7 +174,7 @@
"testFailed": "Nothing could be shown.",
"testLocal": "Your browser has no push subscription, so this was shown locally. Web Push needs a secure origin (localhost or HTTPS) and a registered service worker.",
"testSent": "Sent via push — check your notification centre.",
"testTitle": "Agent Monitor",
"testTitle": "AgentHub",
"title": "Notifications",
"turnComplete": "Claude finishes a turn",
"sound": {
@@ -140,7 +187,16 @@
"pop": "Pop",
"alert": "Alert",
"forType": "Sound for this type"
}
},
"scopeNote": "These notification settings are saved in this browser only. Alert rules and webhooks below are server-wide: they apply to everyone using this dashboard.",
"insecureOrigin": "This page is served from {{origin}}, which is not a secure origin. Browsers only show notifications on HTTPS or localhost — open the dashboard through one of those to get them.",
"unsupported": "This browser does not support notifications.",
"deniedHelp": "Notifications are blocked for this site. To allow them again, open the site settings (the icon left of the address bar), set Notifications to Allow, then reload this page.",
"pushFailed": "Notifications are on, but push could not be set up ({{message}}). They will still show while this tab is open.",
"reset": "Reset to defaults",
"resetTitle": "Reset notification settings?",
"resetBody": "Every notification type and sound goes back to its default. Whether notifications are on stays as it is.",
"resetDone": "Notification settings reset."
},
"import": {
"title": "Import History",
@@ -230,7 +286,10 @@
"runCleanup": "Run Cleanup",
"nothingToClean": "Nothing to clean up",
"abandonedResult": " sessions abandoned",
"purgedResult": " sessions purged ({{events}} events, {{agents}} agents)"
"purgedResult": " sessions purged ({{events}} events, {{agents}} agents)",
"wholeNumber": "Enter a whole number, 1 or more (or leave blank to skip).",
"cleanupConfirmTitle": "Run session cleanup?",
"cleanupConfirmBody": "This marks {{abandoned}} stale session(s) abandoned and permanently deletes {{sessions}} old session(s) with {{events}} event(s) and {{agents}} agent(s). Deleted sessions cannot be restored — export first if you may need them."
},
"danger": {
"title": "Danger Zone",
@@ -238,7 +297,11 @@
"warning": "This will delete all sessions, agents, events, and token data. Are you sure?",
"yesClearAll": "Yes, Clear All",
"clearAllData": "Clear All Data",
"clearedResult": "Cleared {{count}} rows"
"clearedResult": "Cleared {{count}} rows",
"dialogTitle": "Clear all data?",
"exportFirst": "Export your data first.",
"typeToConfirm": "Type “{{phrase}}” to confirm:",
"phrase": "clear all data"
},
"about": {
"title": "About",
@@ -322,7 +385,11 @@
"deleteMessage": "Delete \"{{name}}\"? Fired alerts will stop being delivered to this target. This cannot be undone.",
"guideToggle": "How to set up {{provider}}",
"guideDocs": "Official {{provider}} docs",
"guideStaleNote": "Provider setup screens change often, so these steps may be out of date - always confirm against the official docs."
"guideStaleNote": "Provider setup screens change often, so these steps may be out of date - always confirm against the official docs.",
"loadFailed": "Could not load webhook targets: {{error}}",
"toggleFailed": "Could not change the webhook: {{error}}",
"deleteFailed": "Could not delete the webhook: {{error}}",
"pickRule": "Tick at least one rule, or choose all rules."
},
"alertsHub": {
"title": "Alerts",
@@ -493,7 +560,8 @@
"purgeData": "Also delete the sessions imported from this source (cannot be undone)",
"deleteAndPurge": "Remove + delete data",
"removeKeep": "Remove (keep data)",
"remoteSourceBadgeTitle": "Collected from a remote machine"
"remoteSourceBadgeTitle": "Collected from a remote machine",
"fieldLabelPlaceholder": "Dev box"
},
"cursorPathsNote": "Informational: Cursor sessions count here too — Cursor happens to use the same ~/.claude paths as Claude Code (locally and on synced remotes).",
"aiRouter": {
@@ -579,7 +647,11 @@
"darkThemes": "Dark themes",
"lightThemes": "Light themes",
"active": "· in use now",
"default": "Default"
"default": "Default",
"reset": "Reset to defaults",
"resetTitle": "Reset appearance?",
"resetBody": "Mode goes back to Dark and both modes to their default theme.",
"resetDone": "Appearance reset."
},
"gitCreds": {
"title": "Git credentials",
@@ -597,7 +669,9 @@
"colUser": "Username",
"colSaved": "Saved",
"empty": "No git login saved. The git bar asks for one the first time a host needs it.",
"badUrl": "Enter the server as a URL, e.g. https://git.example.com"
"badUrl": "Enter the server as a URL, e.g. https://git.example.com",
"saved": "Login saved for {{host}}.",
"loading": "Loading…"
},
"homeDir": {
"save": "Save",
@@ -660,7 +734,11 @@
"missing": "This file does not exist yet",
"save": "Save",
"close": "Close",
"saved": "Saved. Backup: {{backup}}"
"saved": "Saved. Backup: {{backup}}",
"defaultModelSaved": "Default model saved.",
"staleBody": "This file changed on disk since you opened it, so nothing was saved. Copy your version if you need it, then reload.",
"copyMine": "Copy my version",
"reloadDiscard": "Reload (discard mine)"
},
"settingsNav": {
"label": "Settings sections",
@@ -669,12 +747,13 @@
"general": "General",
"agents": "Agents",
"routing": "Routing",
"git": "Git"
"git": "Git",
"cost": "Cost"
},
"section": {
"general": {
"title": "General",
"description": "Workspace housekeeping, data and about."
"description": "The dashboard's display language."
},
"appearance": {
"title": "Appearance",
@@ -718,12 +797,23 @@
"worktrees": {
"title": "Worktrees",
"description": "Which untracked git worktrees a workspace's Add folder suggests."
},
"data": {
"title": "Data",
"description": "Cost and database overview, import, remote sources, export, cleanup and the danger zone."
},
"about": {
"title": "About",
"description": "Server version, uptime and runtime."
}
}
},
"backPrevious": "Back",
"unknownSection": "There is no “{{section}}” settings page — showing General instead."
},
"search": {
"placeholder": "Search settings",
"noMatch": "No setting matches “{{query}}”."
"noMatch": "No setting matches “{{query}}”.",
"results": "Search results"
},
"worktrees": {
"title": "Worktree suggestions",
@@ -751,5 +841,15 @@
"copyPath": "Copy path",
"enable": "Enable",
"disable": "Disable"
},
"prefsBackup": {
"title": "This browser's preferences",
"description": "Theme, language, notification settings, pet, sidebar and workspace layout are kept in this browser. Save them to a file to move them to another browser, or restore them from one.",
"export": "Export preferences",
"import": "Import preferences…",
"importTitle": "Import preferences?",
"importBody": "The file's preferences replace this browser's, then the page reloads.",
"imported": "Imported {{count}} preference(s). Reloading…",
"badFile": "That file is not an AgentHub preferences file."
}
}
+4 -1
View File
@@ -27,5 +27,8 @@
"pluginUpdating": "Pulling the new plugin version and reinstalling the runtime — this takes a few minutes, the page reloads itself when the new server is up...",
"lastPluginUpdateFailed": "The last plugin update failed at step \"{{step}}\": {{error}} — run /agenthub-update in Claude Code to recover.",
"restartingShort": "Restarting…",
"installedTitle": "Version {{version}} is installed but not running"
"installedTitle": "Version {{version}} is installed but not running",
"later": "Not now",
"skipVersion": "Skip this version",
"skipVersionHint": "Don't ask again about this version; a newer one will still be offered."
}
+12 -1
View File
@@ -64,7 +64,11 @@
"minutesStatus": "Kích hoạt khi agent ở trạng thái đã chọn mà không có hoạt động trong bấy nhiêu phút (agent bị kẹt).",
"totalTokens": "Kích hoạt khi tổng token của phiên (input + output + cache read + cache write) vượt số này. vd 1000000 = 1 triệu token.",
"cooldown": "Số giây tối thiểu giữa các cảnh báo lặp cho cùng quy tắc + phiên + agent. Tránh trùng lặp gây nhiễu. Mặc định 300 (5 phút); 0 = không có thời gian chờ."
}
},
"edit": "Sửa quy tắc",
"save": "Lưu quy tắc",
"typeFixed": "Không đổi được loại của quy tắc — hãy tạo quy tắc mới.",
"cooldownInvalid": "Nhập số giây nguyên (từ 0 trở lên)."
},
"feed": {
"title": "Luồng cảnh báo",
@@ -76,5 +80,12 @@
"emptyTitle": "Không có cảnh báo",
"emptyDescription": "Cảnh báo do các quy tắc của bạn kích hoạt sẽ hiển thị tại đây theo thời gian thực.",
"emptyUnacked": "Tất cả đã được xác nhận. Cảnh báo mới sẽ hiển thị tại đây khi được kích hoạt."
},
"errors": {
"loadRules": "Không tải được quy tắc cảnh báo: {{error}}",
"loadAlerts": "Không tải được cảnh báo: {{error}}",
"toggleRule": "Không thay đổi được quy tắc: {{error}}",
"deleteRule": "Không xoá được quy tắc: {{error}}",
"ack": "Không xác nhận được: {{error}}"
}
}
+58 -6
View File
@@ -10,7 +10,9 @@
"user": "Người dùng",
"project": "Dự án",
"projectLocal": "Dự án (cục bộ)",
"home": "Home"
"home": "Home",
"projectIs": "Dự án = {{path}}",
"projectInCache": "Dashboard đang chạy từ bộ nhớ đệm plugin của Claude Code, thư mục này bị thay mới mỗi lần cập nhật plugin — tệp dự án ghi vào đó sẽ mất. Đã tắt phạm vi Dự án."
},
"tabs": {
"overview": "Tổng quan",
@@ -150,7 +152,9 @@
"fileMissing": "(không có file settings trong phạm vi này)"
},
"settings": {
"fileMissing": "(không có file settings trong phạm vi này)"
"fileMissing": "(không có file settings trong phạm vi này)",
"structured": "Có cấu trúc",
"rawJson": "JSON thô"
},
"memory": {
"userScope": "Bộ nhớ người dùng",
@@ -217,7 +221,17 @@
"output-styles": "---\nname: my-style\ndescription: Mô tả kiểu xuất\n---\n\nChỉ thị về kiểu.\n",
"memory": "# Bộ nhớ dự án\n\nClaude đọc tệp này khi bắt đầu phiên.\n",
"auto-memory": "---\nname: my-fact\ndescription: Tóm tắt một dòng về sự kiện được ghi nhớ\nmetadata:\n type: feedback\n---\n\nNội dung cần ghi nhớ.\n"
}
},
"nameRequired": "Hãy nhập tên trước.",
"nameInvalid": "Chỉ dùng chữ, số, dấu chấm, gạch ngang hoặc gạch dưới (bắt đầu bằng chữ hoặc số, tối đa 64 ký tự).",
"loadError": "Không tải được tệp: {{message}}. Đã tắt Lưu để tệp không bị ghi đè.",
"truncatedReadOnly": "Tệp lớn hơn 256 KB nên chỉ tải được một phần, vì vậy chỉ mở ở chế độ đọc. Hãy sửa bằng trình soạn thảo của bạn.",
"changedOnDiskTitle": "Tệp đã thay đổi trên đĩa",
"changedOnDiskBody": "Có tiến trình khác đã sửa tệp này sau khi bạn mở. Lưu bây giờ sẽ thay thay đổi đó bằng nội dung của bạn (vẫn có bản sao lưu).",
"overwrite": "Ghi đè",
"unverifiedTitle": "Không kiểm tra được tệp trên đĩa",
"unverifiedBody": "Không đọc lại được tệp để kiểm tra xem có gì khác đã sửa nó từ khi bạn mở hay chưa. Vẫn lưu? (Bản sao lưu của tệp hiện tại vẫn được tạo.)",
"saveAnyway": "Vẫn lưu"
},
"backups": {
"title": "Bản sao lưu",
@@ -225,7 +239,7 @@
"viewLink": "Xem lịch sử sao lưu",
"openButton": "Bản sao lưu",
"subtitle": "Mỗi thao tác lưu / xoá đều ghi bản sao tới đây trước. Thư mục sao lưu nằm ngoài các vị trí Claude Code quét, nên không xuất hiện như mục thừa trong CLI.",
"restoreHint": "Khôi phục bằng cách đổi tên bản sao về vị trí cũ:",
"restoreHint": "Khôi phục bằng cách sao chép bản sao lưu về chỗ cũ (bản sao lưu vẫn được giữ):",
"size": "Kích thước",
"created": "Tạo lúc",
"type": "Loại",
@@ -263,7 +277,9 @@
"renameLabel": "Tên profile",
"renameHint": "Bấm để đổi tên",
"rename": "Đổi tên",
"renameSuccess": "Đã đổi tên thành \"{{name}}\"."
"renameSuccess": "Đã đổi tên thành \"{{name}}\".",
"deleteConfirmTitle": "Xóa hồ sơ “{{name}}”?",
"deleteConfirmBody": "Thao tác này xóa thông tin đăng nhập đã lưu (OAuth) của tài khoản này. Muốn dùng lại, bạn phải chạy `claude auth login` và lưu lại một lần nữa."
},
"explain": {
"hooks": {
@@ -385,6 +401,42 @@
"settingsBadJson": "Giá trị của {{key}} không phải JSON hợp lệ.",
"settingsSaved": "Đã lưu settings vào {{file}}.",
"settingsHelp": "Giá trị là JSON: \"opus\", true, 42, [\"a\"], {\"k\":1}. Chỉ những khóa bạn sửa mới được ghi; phần còn lại của file giữ nguyên.",
"settingsRedactedKeys": "Không sửa được ở đây vì giá trị đã bị che: {{keys}}"
"settingsRedactedKeys": "Không sửa được ở đây vì giá trị đã bị che: {{keys}}",
"hooksChangedBody": "Các hook này đã thay đổi trên đĩa kể từ khi bạn bắt đầu sửa. Lưu sẽ ghi phiên bản của bạn cho các sự kiện bạn đã sửa; các sự kiện mới thêm trong lúc đó vẫn được giữ.",
"settingsChangedBody": "Tệp cài đặt này đã thay đổi trên đĩa kể từ khi bạn bắt đầu sửa (CLI đang chạy ghi lại nó). Lưu sẽ ghi các khóa bạn đã đổi; các khóa mới thêm trong lúc đó vẫn được giữ.",
"settingsRedactedKey": "“{{key}}” chứa giá trị bí mật đã bị ẩn nên không sửa được ở đây — lưu sẽ thay giá trị thật bằng nội dung bạn nhập. Hãy sửa trực tiếp trong tệp.",
"hookReadOnlyEvent": "“{{event}}” đã có hook không sửa được ở đây (chỉ đọc) — một dòng mới cùng tên sẽ thay thế chúng. Hãy dùng tên sự kiện khác hoặc sửa trực tiếp trong tệp."
},
"tabGroups": {
"content": "Nội dung",
"extensions": "Mở rộng",
"config": "Cấu hình"
},
"currentConfig": {
"title": "Cấu hình hiện tại",
"setCount_one": "Đã đặt {{count}} tùy chọn · còn lại dùng mặc định",
"setCount_other": "Đã đặt {{count}} tùy chọn · còn lại dùng mặc định",
"default": "mặc định",
"groups": {
"model": "Mô hình & suy luận",
"output": "Đầu ra & hiển thị",
"session": "Phiên & nhập liệu"
},
"keys": {
"model": "Mô hình",
"effortLevel": "Mức nỗ lực",
"alwaysThinkingEnabled": "Luôn suy nghĩ",
"outputStyle": "Kiểu đầu ra",
"verbose": "Đầu ra chi tiết",
"theme": "Giao diện",
"language": "Ngôn ngữ",
"spinnerTipsEnabled": "Mẹo khi chờ",
"autoScrollEnabled": "Tự cuộn",
"autoCompactEnabled": "Tự nén ngữ cảnh",
"fileCheckpointingEnabled": "Điểm khôi phục tệp",
"editorMode": "Chế độ soạn thảo",
"preferredNotifChannel": "Thông báo",
"awaySummaryEnabled": "Tóm tắt khi vắng mặt"
}
}
}
+1 -1
View File
@@ -116,7 +116,7 @@
"title": "Bản sao lưu",
"subtitle": "Mỗi thao tác lưu / xoá đều ghi bản sao tới đây trước. Thư mục sao lưu nằm ngoài các vị trí CLI quét, nên không xuất hiện như mục thừa trong CLI.",
"empty": "Chưa có bản sao lưu - mỗi lần sửa hoặc xoá sẽ tạo bản sao có dấu thời gian.",
"restoreHint": "Khôi phục bằng cách đổi tên bản sao về vị trí cũ:"
"restoreHint": "Khôi phục bằng cách sao chép bản sao lưu về chỗ cũ (bản sao lưu vẫn được giữ):"
},
"snippet": {
"copy": "Sao chép",
+120 -20
View File
@@ -20,13 +20,46 @@
"upload": "Tải ảnh của bạn lên…",
"import": "Nhập bundle .codex-pet…",
"browse": "Tìm thêm trên codex-pets.net",
"settings": "Cài đặt pet…"
"settings": "Cài đặt pet…",
"hiddenToast": "Đã ẩn pet. Bật lại trong Cài đặt → Pet.",
"deleteTitle": "Xoá {{name}}?",
"deleteMessage": "Các tệp của pet sẽ bị xoá khỏi bản cài này. Pet nổi bật có thể tải lại; ảnh bạn tải lên sẽ mất hẳn.",
"deleteConfirm": "Xoá pet",
"reset": "Khôi phục mặc định",
"resetDone": "Đã khôi phục cài đặt pet về mặc định.",
"hiddenNote": "Pet đang ẩn — kích thước và bong bóng lời thoại sẽ áp dụng khi hiện lại.",
"resetMessage": "Hiện lại pet, bật bong bóng lời thoại, kích thước 68 px và dùng Sagi. Các pet bạn đã tải lên hoặc tải về vẫn được giữ.",
"previewCapped": "Bản xem trước vẽ ở 120 px; pet thật có kích thước {{size}} px."
},
"bubbles": "Bong bóng lời thoại",
"bubblesDesc": "Cho pet bình luận về các session của bạn trong bong bóng",
"sizeDesc": "{{min}}–{{max}} px; bản xem trước tối đa 120 px",
"chooseDesc": "Pet nổi bật lấy từ codex-pets.net và được tải về máy ở lần đầu bạn chọn. Lựa chọn và kích thước được lưu trong trình duyệt này.",
"uploadHint": "Ảnh (PNG, JPEG, GIF, WebP — không nhận SVG) hiển thị tĩnh; file .codex-pet.zip (pet.json + spritesheet.webp 1536×1872) chuyển động theo tâm trạng."
"uploadHint": "Ảnh (PNG, JPEG, GIF, WebP — không nhận SVG) hiển thị tĩnh; file .codex-pet.zip (pet.json + spritesheet.webp 1536×1872) chuyển động theo tâm trạng.",
"panel": {
"label": "Trợ lý Sagi",
"live": "Trực tuyến",
"offline": "Ngoại tuyến",
"close": "Đóng Sagi",
"statLive": "đang chạy",
"statWaiting": "đang chờ",
"statErrored": "lỗi",
"runClaude": "Chạy Claude",
"activity": "Hoạt động",
"sessions": "Phiên",
"errored": "Phiên lỗi",
"mute": "Tắt tiếng",
"unmute": "Bật tiếng",
"clearAlerts": "Xoá cảnh báo",
"askPlaceholder": "Hỏi Sagi… (vd: có lỗi nào không?)",
"ask": "Hỏi Sagi",
"send": "Gửi"
},
"bubbleDismiss": "Đóng",
"open": "Mở trợ lý Sagi",
"closeAvatar": "Đóng Sagi",
"avatarTitle": "Sagi — Ctrl/⌘+B · kéo hoặc dùng phím mũi tên để di chuyển · chuột phải để chọn pet",
"startFailed": "Không khởi động được phiên: {{error}}"
},
"pricing": {
"vendor": {
@@ -62,8 +95,8 @@
},
"cacheWrite5m": "Ghi bộ nhớ đệm (5m)",
"cacheWrite1h": "Ghi bộ nhớ đệm (1h)",
"fastInput": "Fast Input",
"fastOutput": "Fast Output",
"fastInput": "Đầu vào nhanh",
"fastOutput": "Đầu ra nhanh",
"showAll": "Hiện mọi model (thêm {{count}})",
"empty": {
"claude": "Chưa có giá model Claude. Nút Khôi phục mặc định sẽ đưa chúng trở lại.",
@@ -85,12 +118,17 @@
}
},
"autoBadge": "Tự động",
"autoBadgeTitle": "Tự cập nhật theo giá công bố (bảng giá LiteLLM, kiểm tra mỗi ngày). Sửa dòng này để giữ giá của riêng bạn."
"autoBadgeTitle": "Tự cập nhật theo giá công bố (bảng giá LiteLLM, kiểm tra mỗi ngày). Sửa dòng này để giữ giá của riêng bạn.",
"deleteConfirmTitle": "Xoá quy tắc giá {{pattern}}?",
"deleteConfirmBody": "Các phiên dùng mô hình này sẽ được tính giá theo quy tắc khớp khác, hoặc không được tính. Đồng bộ giá tự động sẽ KHÔNG thêm lại mẫu này; nếu đổi ý, hãy tự thêm lại."
},
"claudeHome": {
"title": "CLAUDE_HOME",
"description": "Thư mục gốc chứa dữ liệu phiên Claude Code. Thay đổi sẽ cập nhật ngay đường dẫn quét cho transcript và import.",
"placeholder": "/đường-dẫn/tới/claude-home"
"placeholder": "/đường-dẫn/tới/claude-home",
"note": "Không có gì bị di chuyển hay sao chép: AgentHub chỉ bắt đầu đọc transcript, cấu hình và hook từ thư mục mới. Phiên Cursor dùng cùng đường dẫn ~/.claude nên cũng theo thư mục này.",
"confirmTitle": "Đổi CLAUDE_HOME?",
"confirmBody": "Từ giờ AgentHub sẽ đọc phiên, cấu hình, skill và hook từ thư mục mới. Các phiên trong thư mục cũ sẽ không được nhập nữa cho tới khi bạn đổi lại. Không có gì bị di chuyển hay xoá."
},
"hooks": {
"title": "Cấu hình hook",
@@ -106,7 +144,16 @@
"description": "Dọn dẹp workspace trên toàn máy.",
"runHousekeeping": "Chạy dọn dẹp",
"dryRun": "Chạy thử (dry run)",
"gcResult": "Đã dọn {{reaped}} tiến trình, cắt {{capped}} log"
"gcResult": "Đã dọn {{reaped}} tiến trình, cắt {{capped}} log",
"explain": "Dọn dẹp sau các phiên của không gian làm việc: dừng các tiến trình máy chủ MCP mà phiên đã kết thúc còn để lại, và cắt bớt tệp nhật ký quá lớn. Không đụng tới kho mã của bạn.",
"preview": "Xem trước dọn dẹp",
"gcReapLine": "dừng tiến trình còn sót {{pid}}",
"gcCapLine": "cắt bớt {{path}} ({{size}})",
"gcNothing": "Không có gì để dọn — không còn tiến trình sót và không có nhật ký quá lớn.",
"gcConfirmTitle": "Chạy dọn dẹp?",
"gcConfirmBody": "Thao tác này sẽ:",
"gcMore": "và {{count}} mục khác",
"gcPreviewResult": "Chạy thử: sẽ dừng {{reaped}} tiến trình và cắt bớt {{capped}} tệp nhật ký"
},
"notifications": {
"awaitingInput": "Claude cần xác nhận",
@@ -127,7 +174,7 @@
"testFailed": "Không hiển thị được thông báo nào.",
"testLocal": "Trình duyệt chưa đăng ký push nên thông báo được hiện tại chỗ. Web Push cần origin bảo mật (localhost hoặc HTTPS) và service worker đã đăng ký.",
"testSent": "Đã gửi qua push — kiểm tra trung tâm thông báo.",
"testTitle": "Agent Monitor",
"testTitle": "AgentHub",
"title": "Thông báo",
"turnComplete": "Claude trả lời xong",
"sound": {
@@ -140,7 +187,16 @@
"pop": "Pop",
"alert": "Cảnh báo",
"forType": "Âm thanh cho loại này"
}
},
"scopeNote": "Các cài đặt thông báo này chỉ lưu trong trình duyệt này. Quy tắc cảnh báo và webhook bên dưới áp dụng cho toàn máy chủ: mọi người dùng bảng điều khiển này đều chịu ảnh hưởng.",
"insecureOrigin": "Trang này được phục vụ từ {{origin}}, không phải nguồn bảo mật. Trình duyệt chỉ hiện thông báo trên HTTPS hoặc localhost — hãy mở bảng điều khiển qua một trong hai cách đó.",
"unsupported": "Trình duyệt này không hỗ trợ thông báo.",
"deniedHelp": "Thông báo đang bị chặn cho trang này. Để cho phép lại, mở cài đặt trang (biểu tượng bên trái thanh địa chỉ), đặt Thông báo thành Cho phép, rồi tải lại trang.",
"pushFailed": "Thông báo đã bật nhưng không thiết lập được push ({{message}}). Thông báo vẫn hiện khi thẻ này đang mở.",
"reset": "Khôi phục mặc định",
"resetTitle": "Khôi phục cài đặt thông báo?",
"resetBody": "Mọi loại thông báo và âm thanh trở về mặc định. Trạng thái bật/tắt thông báo được giữ nguyên.",
"resetDone": "Đã khôi phục cài đặt thông báo."
},
"import": {
"title": "Nhập lịch sử",
@@ -230,7 +286,10 @@
"runCleanup": "Chạy dọn dẹp",
"nothingToClean": "Không có gì để dọn dẹp",
"abandonedResult": " phiên đã bị bỏ dở",
"purgedResult": " phiên đã xóa ({{events}} sự kiện, {{agents}} Agent)"
"purgedResult": " phiên đã xóa ({{events}} sự kiện, {{agents}} Agent)",
"wholeNumber": "Nhập số nguyên từ 1 trở lên (hoặc để trống để bỏ qua).",
"cleanupConfirmTitle": "Chạy dọn dẹp phiên?",
"cleanupConfirmBody": "Thao tác này đánh dấu {{abandoned}} phiên cũ là bị bỏ dở và xóa vĩnh viễn {{sessions}} phiên cũ cùng {{events}} sự kiện và {{agents}} tác tử. Phiên đã xóa không thể khôi phục — hãy xuất dữ liệu trước nếu có thể cần."
},
"danger": {
"title": "Vùng nguy hiểm",
@@ -238,7 +297,11 @@
"warning": "Thao tác này sẽ xóa toàn bộ phiên, Agent, sự kiện và dữ liệu token. Bạn chắc chứ?",
"yesClearAll": "Có, xóa tất cả",
"clearAllData": "Xóa toàn bộ dữ liệu",
"clearedResult": "Đã xóa {{count}} dòng"
"clearedResult": "Đã xóa {{count}} dòng",
"dialogTitle": "Xóa toàn bộ dữ liệu?",
"exportFirst": "Hãy xuất dữ liệu trước.",
"typeToConfirm": "Gõ “{{phrase}}” để xác nhận:",
"phrase": "xóa toàn bộ dữ liệu"
},
"about": {
"title": "Giới thiệu",
@@ -322,7 +385,11 @@
"deleteMessage": "Xóa \"{{name}}\"? Cảnh báo sẽ ngừng được gửi tới đích này. Không thể hoàn tác.",
"guideToggle": "Cách thiết lập {{provider}}",
"guideDocs": "Tài liệu chính thức của {{provider}}",
"guideStaleNote": "Giao diện thiết lập của nhà cung cấp thường thay đổi nên các bước này có thể đã lỗi thời - hãy luôn kiểm tra tài liệu chính thức."
"guideStaleNote": "Giao diện thiết lập của nhà cung cấp thường thay đổi nên các bước này có thể đã lỗi thời - hãy luôn kiểm tra tài liệu chính thức.",
"loadFailed": "Không tải được danh sách webhook: {{error}}",
"toggleFailed": "Không thay đổi được webhook: {{error}}",
"deleteFailed": "Không xoá được webhook: {{error}}",
"pickRule": "Hãy chọn ít nhất một quy tắc, hoặc chọn tất cả quy tắc."
},
"alertsHub": {
"title": "Cảnh báo",
@@ -493,7 +560,8 @@
"purgeData": "Đồng thời xóa các phiên đã nhập từ nguồn này (không thể hoàn tác)",
"deleteAndPurge": "Xóa + xóa dữ liệu",
"removeKeep": "Xóa (giữ dữ liệu)",
"remoteSourceBadgeTitle": "Được thu thập từ một máy từ xa"
"remoteSourceBadgeTitle": "Được thu thập từ một máy từ xa",
"fieldLabelPlaceholder": "Máy dev"
},
"cursorPathsNote": "Chỉ mang tính thông tin: phiên Cursor cũng được tính — Cursor tình cờ dùng cùng đường dẫn ~/.claude với Claude Code (cục bộ và trên máy từ xa đã đồng bộ).",
"aiRouter": {
@@ -579,7 +647,11 @@
"darkThemes": "Theme tối",
"lightThemes": "Theme sáng",
"active": "· đang dùng",
"default": "Mặc định"
"default": "Mặc định",
"reset": "Khôi phục mặc định",
"resetTitle": "Khôi phục giao diện?",
"resetBody": "Chế độ trở về Tối và cả hai chế độ trở về giao diện mặc định.",
"resetDone": "Đã khôi phục giao diện."
},
"gitCreds": {
"title": "Git credentials",
@@ -597,7 +669,9 @@
"colUser": "Tên đăng nhập",
"colSaved": "Đã lưu",
"empty": "Chưa lưu tài khoản git nào. Thanh git sẽ hỏi khi một host cần đăng nhập.",
"badUrl": "Nhập máy chủ dạng URL, ví dụ https://git.example.com"
"badUrl": "Nhập máy chủ dạng URL, ví dụ https://git.example.com",
"saved": "Đã lưu thông tin đăng nhập cho {{host}}.",
"loading": "Đang tải…"
},
"homeDir": {
"save": "Lưu",
@@ -660,7 +734,11 @@
"missing": "File này chưa có",
"save": "Lưu",
"close": "Đóng",
"saved": "Đã lưu. Bản sao lưu: {{backup}}"
"saved": "Đã lưu. Bản sao lưu: {{backup}}",
"defaultModelSaved": "Đã lưu mô hình mặc định.",
"staleBody": "Tệp đã thay đổi trên đĩa kể từ lúc bạn mở nên chưa lưu gì. Hãy sao chép bản của bạn nếu cần, rồi tải lại.",
"copyMine": "Sao chép bản của tôi",
"reloadDiscard": "Tải lại (bỏ bản của tôi)"
},
"settingsNav": {
"label": "Các mục cài đặt",
@@ -669,12 +747,13 @@
"general": "Chung",
"agents": "Tác tử",
"routing": "Định tuyến",
"git": "Git"
"git": "Git",
"cost": "Chi phí"
},
"section": {
"general": {
"title": "Chung",
"description": "Dọn dẹp workspace, dữ liệu và thông tin."
"description": "Ngôn ngữ hiển thị của bảng điều khiển."
},
"appearance": {
"title": "Giao diện",
@@ -718,12 +797,23 @@
"worktrees": {
"title": "Worktree",
"description": "Những git worktree chưa theo dõi được gợi ý khi thêm thư mục cho workspace."
},
"data": {
"title": "Dữ liệu",
"description": "Tổng quan chi phí và cơ sở dữ liệu, nhập, nguồn từ xa, xuất, dọn dẹp và vùng nguy hiểm."
},
"about": {
"title": "Giới thiệu",
"description": "Phiên bản máy chủ, thời gian hoạt động và môi trường chạy."
}
}
},
"backPrevious": "Quay lại",
"unknownSection": "Không có trang cài đặt “{{section}}” — đang hiển thị trang Chung."
},
"search": {
"placeholder": "Tìm cài đặt",
"noMatch": "Không có cài đặt nào khớp “{{query}}”."
"noMatch": "Không có cài đặt nào khớp “{{query}}”.",
"results": "Kết quả tìm kiếm"
},
"worktrees": {
"title": "Gợi ý worktree",
@@ -751,5 +841,15 @@
"copyPath": "Sao chép đường dẫn",
"enable": "Bật",
"disable": "Tắt"
},
"prefsBackup": {
"title": "Tùy chọn của trình duyệt này",
"description": "Giao diện, ngôn ngữ, cài đặt thông báo, thú cưng, thanh bên và bố cục không gian làm việc được lưu trong trình duyệt này. Lưu chúng ra tệp để chuyển sang trình duyệt khác, hoặc khôi phục từ tệp.",
"export": "Xuất tùy chọn",
"import": "Nhập tùy chọn…",
"importTitle": "Nhập tùy chọn?",
"importBody": "Tùy chọn trong tệp sẽ thay thế tùy chọn của trình duyệt này, sau đó trang tải lại.",
"imported": "Đã nhập {{count}} tùy chọn. Đang tải lại…",
"badFile": "Tệp này không phải tệp tùy chọn của AgentHub."
}
}
+4 -1
View File
@@ -27,5 +27,8 @@
"pluginUpdating": "Đang tải bản plugin mới và cài lại runtime — mất vài phút, trang sẽ tự reload khi server mới lên...",
"lastPluginUpdateFailed": "Lần cập nhật plugin trước thất bại ở bước \"{{step}}\": {{error}} — chạy /agenthub-update trong Claude Code để khắc phục.",
"restartingShort": "Đang khởi động lại…",
"installedTitle": "Đã cài bản {{version}} nhưng chưa chạy"
"installedTitle": "Đã cài bản {{version}} nhưng chưa chạy",
"later": "Để sau",
"skipVersion": "Bỏ qua phiên bản này",
"skipVersionHint": "Không hỏi lại về phiên bản này; phiên bản mới hơn vẫn sẽ được thông báo."
}
@@ -0,0 +1,43 @@
/**
* @file Tests for the browser-preferences backup: only allowlisted keys leave
* (never the access token), an import writes only allowlisted keys, and a file
* that is not one of ours is refused.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { beforeEach, describe, expect, it } from "vitest";
import { exportPrefs, importPrefs, PREFS_FORMAT } from "../prefsBackup";
beforeEach(() => localStorage.clear());
describe("prefsBackup", () => {
it("exports only preference keys, never the access token", () => {
localStorage.setItem("theme", "dark");
localStorage.setItem("agenthub.pet.size", "2");
localStorage.setItem("agent-monitor-notifications", "{}");
localStorage.setItem("dashboard_token", "secret");
localStorage.setItem("sidebar-connection-stats", "{}");
const out = exportPrefs();
expect(out.format).toBe(PREFS_FORMAT);
expect(Object.keys(out.prefs).sort()).toEqual([
"agent-monitor-notifications",
"agenthub.pet.size",
"theme",
]);
});
it("imports allowlisted keys only and refuses a foreign file", () => {
const n = importPrefs(
JSON.stringify({
format: PREFS_FORMAT,
version: 1,
prefs: { theme: "light", dashboard_token: "evil", "agenthub.x": 5 },
})
);
expect(n).toBe(1);
expect(localStorage.getItem("theme")).toBe("light");
expect(localStorage.getItem("dashboard_token")).toBeNull();
expect(() => importPrefs(JSON.stringify({ format: "other", prefs: {} }))).toThrow();
expect(() => importPrefs("not json")).toThrow();
});
});
@@ -2,12 +2,19 @@
* @file settingsSearch.test.ts
* @description `rankSettings`: an empty query matches nothing, a section title
* outranks an entry title, which outranks a description and a keyword, exact
* beats prefix beats substring, and a query matching nothing returns nothing.
* beats prefix beats substring, and a query matching nothing returns nothing;
* `rankSettingsEntries` ranks rows with their deep-link target, and the index
* covers the sub-tabs and Data rows the search must reach.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, expect, it } from "vitest";
import { rankSettings, type SettingsEntry } from "../settingsSearch";
import {
rankSettings,
rankSettingsEntries,
SETTINGS_INDEX,
type SettingsEntry,
} from "../settingsSearch";
const entries: SettingsEntry[] = [
{ section: "pricing", title: "Pricing", sectionTitle: true },
@@ -38,3 +45,42 @@ describe("rankSettings", () => {
expect(rankSettings("zzz", entries)).toEqual([]);
});
});
describe("rankSettingsEntries", () => {
it("ranks rows, keeps their deep-link target and caps the list", () => {
const rows: SettingsEntry[] = [
{ section: "agents/claude-code", title: "Skills", to: "?tab=skills" },
{ section: "data", title: "Session cleanup", keywords: ["purge"], to: "#cleanup" },
{ section: "pet", title: "Pet", sectionTitle: true },
];
expect(rankSettingsEntries("skill", rows).map((r) => r.to)).toEqual(["?tab=skills"]);
expect(rankSettingsEntries("purge", rows)[0]).toMatchObject({
section: "data",
to: "#cleanup",
});
expect(rankSettingsEntries("e", rows, 2)).toHaveLength(2);
});
it("indexes every Claude Code sub-tab and Data row the audit named", () => {
const tos = SETTINGS_INDEX.map((e) => `${e.section}${e.to ?? ""}`);
for (const t of [
"skills",
"agents",
"commands",
"memory",
"plugins",
"marketplaces",
"mcp",
"hooks",
"keybindings",
"outputStyles",
"settings",
])
expect(tos).toContain(`agents/claude-code?tab=${t}`);
expect(tos).toContain("data#cleanup");
expect(tos).toContain("data#prefs");
expect(SETTINGS_INDEX.find((e) => e.section === "worktrees")?.keywords).not.toContain(
"sidebar"
);
});
});
+59
View File
@@ -0,0 +1,59 @@
/**
* @file prefsBackup.ts
* @description Export / import of THIS browser's preferences (theme, language,
* notification prefs, pet, sidebar, workspace layout …) as one JSON file, for
* Settings → Data. Only allowlisted `localStorage` keys travel — never the
* dashboard access token or a cache — and an import writes only those keys,
* as strings, so a crafted file cannot plant anything else.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
export const PREFS_FORMAT = "agenthub-browser-prefs";
const EXACT = new Set([
"theme",
"i18nextLng",
"agent-monitor-notifications",
"dashboard_tab",
"sidebar-collapsed",
"agent-dashboard-sagi-enabled",
"agent-dashboard-sagi-muted",
"agent-dashboard-sagi-pos",
"agenthub-data-scope",
]);
/** Whether a `localStorage` key is a preference that may be exported/imported. */
export function isPrefKey(key: string): boolean {
return EXACT.has(key) || key.startsWith("agenthub.");
}
export interface PrefsFile {
format: typeof PREFS_FORMAT;
version: 1;
exported_at: string;
prefs: Record<string, string>;
}
/** Every preference key this browser holds. */
export function exportPrefs(): PrefsFile {
const prefs: Record<string, string> = {};
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i);
if (k && isPrefKey(k)) prefs[k] = localStorage.getItem(k) ?? "";
}
return { format: PREFS_FORMAT, version: 1, exported_at: new Date().toISOString(), prefs };
}
/** Write a prefs file's allowlisted string values; returns how many. Throws on a foreign file. */
export function importPrefs(text: string): number {
const data = JSON.parse(text) as Partial<PrefsFile>;
if (!data || data.format !== PREFS_FORMAT || typeof data.prefs !== "object" || !data.prefs)
throw new Error("not an AgentHub preferences file");
let n = 0;
for (const [k, v] of Object.entries(data.prefs)) {
if (!isPrefKey(k) || typeof v !== "string") continue;
localStorage.setItem(k, v);
n++;
}
return n;
}
+193 -20
View File
@@ -4,6 +4,8 @@
* `rankSettings` scores sections against a query. Per field, exact beats
* prefix beats substring: section title 900/850/800, entry title 700/650/600,
* description 500/450/400, keywords 300/250/200; a section scores its best hit.
* `rankSettingsEntries` ranks the rows themselves, for the result list that
* deep-links a row's sub-tab and anchor.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -14,9 +16,14 @@ export interface SettingsEntry {
keywords?: string[];
/** The section's own title (scored in the top tier). */
sectionTitle?: boolean;
/** Appended to `/settings/<section>`: a sub-tab (`?tab=skills`) and/or an
* anchor (`#cleanup`) the hub scrolls to and highlights. */
to?: string;
}
/** One searchable row per section, by i18n key in the `settings` namespace. A
/** One searchable row per section, by i18n key (`settings` namespace unless
* the key names another, `ccConfig:tabs.skills`). `to` targets a sub-tab and/or
* a row anchor — an element with that `id` or `data-settings-anchor`. A
* section whose id starts with `agents/` and is not listed here is a catalog
* tool's page and takes the `agents/*` entries. */
export const SETTINGS_INDEX: {
@@ -24,16 +31,64 @@ export const SETTINGS_INDEX: {
titleKey: string;
descKey?: string;
keywords?: string[];
to?: string;
}[] = [
{
section: "general",
titleKey: "language.title",
keywords: ["english", "tiếng việt", "vietnamese", "ngôn ngữ"],
},
{ section: "general", titleKey: "lanesSection.title", descKey: "lanesSection.description" },
{ section: "general", titleKey: "lanesSection.runHousekeeping" },
{ section: "general", titleKey: "import.title", keywords: ["history", "transcripts"] },
{ section: "general", titleKey: "remoteSources.title", keywords: ["ssh", "machines"] },
{
section: "data",
titleKey: "lanesSection.title",
descKey: "lanesSection.description",
to: "#lanes",
},
{
section: "data",
titleKey: "lanesSection.runHousekeeping",
keywords: ["gc", "logs", "processes"],
to: "#lanes",
},
{
section: "data",
titleKey: "import.title",
keywords: ["history", "transcripts"],
to: "#import",
},
{
section: "data",
titleKey: "remoteSources.title",
keywords: ["ssh", "machines"],
to: "#remote-sources",
},
{ section: "data", titleKey: "data.title", descKey: "data.description", to: "#data" },
{ section: "data", titleKey: "exportData", keywords: ["backup", "download"], to: "#data" },
{
section: "data",
titleKey: "data.sessionCleanup",
descKey: "data.cleanupDesc",
keywords: ["purge", "abandon", "retention"],
to: "#cleanup",
},
{
section: "data",
titleKey: "prefsBackup.title",
descKey: "prefsBackup.description",
keywords: ["export", "import", "browser", "preferences"],
to: "#prefs",
},
{
section: "data",
titleKey: "danger.title",
keywords: ["clear", "delete", "wipe"],
to: "#danger",
},
{
section: "about",
titleKey: "about.title",
keywords: ["version", "server", "database", "uptime"],
},
{
section: "pet",
titleKey: "sagi.title",
@@ -43,17 +98,41 @@ export const SETTINGS_INDEX: {
{ section: "pet", titleKey: "sagi.bubbles", descKey: "sagi.bubblesDesc" },
{ section: "pet", titleKey: "sagi.pet.size" },
{ section: "pet", titleKey: "sagi.pet.choose", keywords: ["codex-pets", "upload", "import"] },
{ section: "general", titleKey: "data.title", descKey: "data.description" },
{ section: "general", titleKey: "danger.title", keywords: ["clear", "delete", "export"] },
{ section: "general", titleKey: "about.title", keywords: ["version", "server", "database"] },
{ section: "appearance", titleKey: "appearance.mode", keywords: ["dark", "light", "system"] },
{ section: "appearance", titleKey: "appearance.darkThemes", keywords: ["theme", "colors"] },
{ section: "appearance", titleKey: "appearance.lightThemes" },
{ section: "notifications", titleKey: "notifications.enable", keywords: ["push", "browser"] },
{ section: "notifications", titleKey: "notifications.awaitingInput" },
{ section: "notifications", titleKey: "notifications.turnComplete" },
{ section: "notifications", titleKey: "alertsHub.title", descKey: "alertsHub.description" },
{ section: "notifications", titleKey: "webhooks.title", keywords: ["slack", "discord"] },
{
section: "notifications",
titleKey: "notifications.enable",
keywords: ["push", "browser"],
to: "#notifications",
},
{ section: "notifications", titleKey: "notifications.awaitingInput", to: "#notifications" },
{ section: "notifications", titleKey: "notifications.turnComplete", to: "#notifications" },
{
section: "notifications",
titleKey: "notifications.sound.title",
keywords: ["chime", "audio"],
to: "#notifications",
},
{
section: "notifications",
titleKey: "notifications.sendTest",
keywords: ["test"],
to: "#notifications",
},
{
section: "notifications",
titleKey: "alertsHub.title",
descKey: "alertsHub.description",
to: "#alerts",
},
{
section: "notifications",
titleKey: "webhooks.title",
keywords: ["slack", "discord", "telegram", "channels"],
to: "#alerts",
},
{
section: "agents",
titleKey: "aiTools.title",
@@ -62,15 +141,89 @@ export const SETTINGS_INDEX: {
},
{ section: "agents/claude-code", titleKey: "pricing.title", keywords: ["cost", "price"] },
{ section: "agents/claude-code", titleKey: "hooks.title" },
{ section: "agents/claude-code", titleKey: "claudeHome.title", keywords: ["profiles"] },
{ section: "agents/claude-code", titleKey: "claudeHome.title" },
{ section: "agents/claude-code", titleKey: "ccConfig:tabs.skills", to: "?tab=skills" },
{ section: "agents/claude-code", titleKey: "ccConfig:tabs.agents", to: "?tab=agents" },
{ section: "agents/claude-code", titleKey: "ccConfig:tabs.commands", to: "?tab=commands" },
{
section: "agents/claude-code",
titleKey: "ccConfig:tabs.memory",
keywords: ["claude.md"],
to: "?tab=memory",
},
{ section: "agents/claude-code", titleKey: "ccConfig:tabs.plugins", to: "?tab=plugins" },
{
section: "agents/claude-code",
titleKey: "ccConfig:tabs.marketplaces",
to: "?tab=marketplaces",
},
{
section: "agents/claude-code",
titleKey: "ccConfig:tabs.mcp",
keywords: ["mcp"],
to: "?tab=mcp",
},
{
section: "agents/claude-code",
titleKey: "ccConfig:tabs.hooks",
keywords: ["hooks"],
to: "?tab=hooks",
},
{
section: "agents/claude-code",
titleKey: "ccConfig:tabs.keybindings",
keywords: ["shortcuts"],
to: "?tab=keybindings",
},
{
section: "agents/claude-code",
titleKey: "ccConfig:tabs.outputStyles",
to: "?tab=outputStyles",
},
{
section: "agents/claude-code",
titleKey: "ccConfig:tabs.settings",
keywords: ["settings.json", "statusline", "permissions"],
to: "?tab=settings",
},
{
section: "agents/claude-code",
titleKey: "ccConfig:statusline.title",
keywords: ["statusline"],
to: "?tab=settings",
},
{
section: "agents/claude-code",
titleKey: "ccConfig:profiles.title",
keywords: ["account", "login", "quota", "switch"],
},
{ section: "agents/claude-code", titleKey: "ccConfig:backups.title", keywords: ["restore"] },
{ section: "agents/codex", titleKey: "pricing.title", keywords: ["cost", "price"] },
{ section: "agents/codex", titleKey: "codexHooks.title" },
{ section: "agents/codex", titleKey: "codexHome.title" },
{ section: "agents/codex", titleKey: "codexConfig:tabs.skills", to: "?tab=skills" },
{ section: "agents/codex", titleKey: "codexConfig:tabs.agents", to: "?tab=agents" },
{ section: "agents/codex", titleKey: "codexConfig:tabs.rules", to: "?tab=rules" },
{
section: "agents/codex",
titleKey: "codexConfig:tabs.memory",
keywords: ["agents.md"],
to: "?tab=memory",
},
{
section: "agents/codex",
titleKey: "codexConfig:tabs.config",
keywords: ["config.toml"],
to: "?tab=config",
},
{ section: "agents/codex", titleKey: "codexConfig:tabs.mcp", keywords: ["mcp"], to: "?tab=mcp" },
{ section: "agents/codex", titleKey: "codexConfig:tabs.plugins", to: "?tab=plugins" },
{ section: "agents/antigravity", titleKey: "antigravityHooks.title", keywords: ["agy", "hooks"] },
{ section: "agents/*", titleKey: "toolTab.defaultModel", keywords: ["model", "config"] },
{ section: "ai-router", titleKey: "aiRouter.title", keywords: ["proxy", "endpoint", "key"] },
{ section: "pricing", titleKey: "pricing.title", keywords: ["cost", "price", "tokens"] },
{ section: "git-credentials", titleKey: "gitCreds.title", keywords: ["login", "token"] },
{ section: "worktrees", titleKey: "worktrees.showUntracked", keywords: ["hide", "sidebar"] },
{ section: "worktrees", titleKey: "worktrees.showUntracked", keywords: ["hide", "suggestions"] },
{ section: "worktrees", titleKey: "worktrees.claude" },
{ section: "worktrees", titleKey: "worktrees.agenthub" },
];
@@ -84,6 +237,30 @@ function tier(hay: string | undefined, q: string, exact: number): number {
return 0;
}
/** Score of one entry against an already-lowercased query (0 = no match). */
function scoreEntry(e: SettingsEntry, q: string): number {
return Math.max(
tier(e.title, q, e.sectionTitle ? 900 : 700),
tier(e.description, q, 500),
...(e.keywords ?? []).map((k) => tier(k, q, 300))
);
}
/** Entries matching `query`, best first (ties keep order), at most `limit`. */
export function rankSettingsEntries(
query: string,
entries: SettingsEntry[],
limit = 8
): (SettingsEntry & { score: number })[] {
const q = query.trim().toLowerCase();
if (!q) return [];
return entries
.map((e) => ({ ...e, score: scoreEntry(e, q) }))
.filter((e) => e.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, limit);
}
/** Sections matching `query`, best first; ties keep the entries' order. */
export function rankSettings(
query: string,
@@ -93,11 +270,7 @@ export function rankSettings(
if (!q) return [];
const best = new Map<string, number>();
for (const e of entries) {
const score = Math.max(
tier(e.title, q, e.sectionTitle ? 900 : 700),
tier(e.description, q, 500),
...(e.keywords ?? []).map((k) => tier(k, q, 300))
);
const score = scoreEntry(e, q);
if (score > (best.get(e.section) ?? 0)) best.set(e.section, score);
}
return [...best]
+38 -10
View File
@@ -15,6 +15,9 @@
* An API key is write-only here by design — the server never returns one, so
* the form shows whether a key is saved, never the key.
*
* The form is a real dialog (focus trap, Escape) and shows a failed save
* inside itself, not behind its scrim; a remove asks through `confirmDialog`.
*
* A right-click on a profile row opens its Edit / Remove (plus Copy endpoint)
* as a menu; the buttons stay.
*
@@ -25,11 +28,14 @@
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Plus, Pencil, Trash2, Lock } from "lucide-react";
import { api, type AiRouter, type AiRouterInput, type AiRouterModels } from "../lib/api";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../components/ContextMenu";
import { confirmDialog } from "../lib/confirm";
import { copyText } from "../lib/clipboard";
import { useDialog } from "../hooks/useDialog";
const TIERS: (keyof AiRouterModels)[] = ["opus", "sonnet", "haiku", "smallFast", "subagent"];
@@ -54,6 +60,10 @@ export function AiRouterConfig() {
const [form, setForm] = useState(emptyForm());
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false);
// A failed save, shown INSIDE the dialog (the page-level `error` sits behind it).
const [formError, setFormError] = useState<string | null>(null);
const dialogRef = useRef<HTMLDivElement>(null);
useDialog(open, dialogRef, () => setOpen(false));
// The router's own verdict on the form as it currently stands. Cleared on
// every field change: a green "works" left sitting above an edited base URL
// is a claim about a configuration that no longer exists.
@@ -98,6 +108,7 @@ export function AiRouterConfig() {
const startAdd = () => {
setEditing(null);
setForm(emptyForm());
setFormError(null);
setOpen(true);
};
@@ -111,6 +122,7 @@ export function AiRouterConfig() {
model: item.model,
models: { ...item.models },
});
setFormError(null);
setOpen(true);
};
@@ -148,7 +160,7 @@ export function AiRouterConfig() {
const submit = async () => {
setBusy(true);
setError(null);
setFormError(null);
try {
// Send only the tiers that differ from the headline model, so the server
// keeps filling the rest from it — that is what makes one symbolic name
@@ -172,14 +184,20 @@ export function AiRouterConfig() {
setOpen(false);
await load();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
setFormError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
};
const remove = async (item: AiRouter) => {
if (!window.confirm(t("aiRouter.removeConfirm", { label: item.label }))) return;
if (
!(await confirmDialog({
title: t("aiRouter.removeConfirm", { label: item.label }),
confirmLabel: t("aiRouter.remove"),
}))
)
return;
setError(null);
try {
await api.aiRouters.remove(item.name);
@@ -288,7 +306,7 @@ export function AiRouterConfig() {
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="ai-router-menu">
<MenuItem onSelect={pick(startEdit)}>{t("aiRouter.edit")}</MenuItem>
{ctx.menu.item.baseUrl && (
<MenuItem onSelect={pick((item) => void navigator.clipboard.writeText(item.baseUrl))}>
<MenuItem onSelect={pick((item) => void copyText(item.baseUrl))}>
{t("rowMenu.copyUrl")}
</MenuItem>
)}
@@ -301,16 +319,17 @@ export function AiRouterConfig() {
{open && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
className="fixed inset-0 z-50 flex items-center justify-center bg-surface-0/70 p-4"
role="presentation"
>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label={editing ? t("aiRouter.edit") : t("aiRouter.add")}
aria-labelledby="router-form-title"
className="max-h-[90vh] w-full max-w-md overflow-y-auto rounded-xl border border-border bg-surface-1 p-5 shadow-xl"
>
<h3 className="mb-3 text-sm font-semibold text-fg-primary">
<h3 id="router-form-title" className="mb-3 text-sm font-semibold text-fg-primary">
{editing ? t("aiRouter.edit") : t("aiRouter.add")}
</h3>
@@ -375,6 +394,15 @@ export function AiRouterConfig() {
</details>
</div>
{formError && (
<p
role="alert"
className="mt-3 rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-xs text-status-danger"
>
{formError}
</p>
)}
{testResult && (
<p
data-testid="router-test-result"
@@ -409,7 +437,7 @@ export function AiRouterConfig() {
type="button"
onClick={submit}
disabled={busy || !form.label.trim() || !form.baseUrl.trim() || !form.model.trim()}
className="rounded-md bg-accent px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-accent/90 disabled:opacity-50"
className="btn-primary px-3 py-1.5 text-xs"
>
{t("aiRouter.save")}
</button>
@@ -451,7 +479,7 @@ function Field({
value={value}
placeholder={placeholder}
onChange={(e) => onChange(e.target.value)}
className={`w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary placeholder:text-fg-muted focus:border-blue-500 focus:outline-none ${
className={`w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none ${
mono ? "font-mono" : ""
}`}
/>
+58 -6
View File
@@ -7,7 +7,10 @@
* default model (`PUT /api/ai-tools/:id {defaultModel}`) for a tool with a
* model flag; its vendor's pricing table; and the config files its catalog
* file lists, each opened over a sha and saved by the server only if it is
* still that sha, valid, and backed up. Antigravity adds its hooks card.
* still that sha, valid, and backed up — a refusal because the file changed
* (ESTALE) offers a reload and keeps the user's text to copy, and an edited
* file asks before another file or Close drops it. Antigravity adds its hooks
* card.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -16,6 +19,10 @@ import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Cpu, ExternalLink, FileCog, RefreshCw } from "lucide-react";
import { api, type AiToolConfigFile } from "../lib/api";
import { toast } from "../lib/toast";
import { confirmDiscard } from "../lib/confirm";
import { copyText } from "../lib/clipboard";
import { useUnsavedGuard } from "../hooks/useUnsavedGuard";
import type { Lane } from "../lib/types";
import { useAiTools } from "../hooks/useAiTools";
import { AiToolIcon } from "../components/AiToolIcon";
@@ -39,6 +46,7 @@ function DefaultModel({ toolId, saved }: { toolId: string; saved: string }) {
setError(null);
try {
await aiTools.update(toolId, { defaultModel: value || null });
toast.success(t("toolTab.defaultModelSaved"));
} catch (err) {
setError(message(err));
}
@@ -70,8 +78,14 @@ function ConfigFiles({ toolId }: { toolId: string }) {
const [files, setFiles] = useState<AiToolConfigFile[] | null>(null);
const [open, setOpen] = useState<{ index: number; path: string; sha: string } | null>(null);
const [text, setText] = useState("");
// The text as opened/saved: an edit differs from it.
const [base, setBase] = useState("");
const [busy, setBusy] = useState(false);
const [note, setNote] = useState<{ ok: boolean; text: string } | null>(null);
// Set when a save was refused because the file changed on disk.
const [stale, setStale] = useState(false);
const dirty = open !== null && text !== base;
useUnsavedGuard(dirty);
useEffect(() => {
api.aiTools
@@ -80,26 +94,42 @@ function ConfigFiles({ toolId }: { toolId: string }) {
.catch((err) => setNote({ ok: false, text: message(err) }));
}, [toolId]);
const openFile = async (f: AiToolConfigFile) => {
const load = async (index: number) => {
setNote(null);
setStale(false);
try {
const r = await api.aiTools.readConfig(toolId, f.index);
setOpen({ index: f.index, path: r.path, sha: r.sha });
const r = await api.aiTools.readConfig(toolId, index);
setOpen({ index, path: r.path, sha: r.sha });
setText(r.text);
setBase(r.text);
} catch (err) {
setNote({ ok: false, text: message(err) });
}
};
const openFile = async (f: AiToolConfigFile) => {
if (dirty && !(await confirmDiscard())) return;
await load(f.index);
};
const close = async () => {
if (dirty && !(await confirmDiscard())) return;
setOpen(null);
setStale(false);
};
const save = async () => {
if (!open) return;
setBusy(true);
setNote(null);
setStale(false);
try {
const r = await api.aiTools.saveConfig(toolId, open.index, text, open.sha);
setOpen({ ...open, sha: r.sha });
setBase(text);
setNote({ ok: true, text: t("toolTab.saved", { backup: r.backup }) });
} catch (err) {
setStale((err as { code?: string }).code === "ESTALE");
setNote({ ok: false, text: message(err) });
} finally {
setBusy(false);
@@ -141,16 +171,38 @@ function ConfigFiles({ toolId }: { toolId: string }) {
<button
type="button"
onClick={save}
disabled={busy}
disabled={busy || !dirty}
className="btn-ghost text-xs disabled:opacity-50"
>
{busy && <RefreshCw className="h-3.5 w-3.5 animate-spin" />}
{t("toolTab.save")}
</button>
<button type="button" onClick={() => setOpen(null)} className="btn-ghost text-xs">
<button type="button" onClick={() => void close()} className="btn-ghost text-xs">
{t("toolTab.close")}
</button>
</div>
{stale && (
<div
role="alert"
className="flex flex-wrap items-center gap-2 rounded-md border border-status-warning/40 bg-status-warning/10 px-3 py-2 text-xs text-fg-primary"
>
<span className="flex-1">{t("toolTab.staleBody")}</span>
<button
type="button"
onClick={() => void copyText(text)}
className="btn-ghost text-xs"
>
{t("toolTab.copyMine")}
</button>
<button
type="button"
onClick={() => void load(open.index)}
className="btn-ghost text-xs"
>
{t("toolTab.reloadDiscard")}
</button>
</div>
)}
</div>
)}
{note && (
File diff suppressed because it is too large Load Diff
+164 -110
View File
@@ -20,6 +20,7 @@
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import { useSearchParams } from "react-router-dom";
import { eventBus } from "../lib/eventBus";
import {
Boxes,
@@ -53,7 +54,10 @@ import {
ConfirmDeleteModal,
type ConfirmDeleteState,
} from "../components/config/ConfirmDeleteModal";
import { ToastNotice, type Toast } from "../components/config/ToastNotice";
import { showToast, type Toast } from "../components/config/ToastNotice";
import { copyText } from "../lib/clipboard";
import { useDialog } from "../hooks/useDialog";
import { isPluginCachePath, restoreCommand } from "./CcConfig";
import { ScopeBadge } from "../components/config/ScopeBadge";
import { FileItemMenu } from "../components/config/MdItemList";
import {
@@ -98,17 +102,19 @@ interface TabDef {
key: TabKey;
icon: typeof Sparkles;
i18nKey: string;
/** i18n key (in ccConfig) of the tab's group label. */
group?: string;
}
const TABS: TabDef[] = [
{ key: "overview", icon: Boxes, i18nKey: "tabs.overview" },
{ key: "skills", icon: Sparkles, i18nKey: "tabs.skills" },
{ key: "agents", icon: UserRound, i18nKey: "tabs.agents" },
{ key: "rules", icon: FileText, i18nKey: "tabs.rules" },
{ key: "memory", icon: BookOpen, i18nKey: "tabs.memory" },
{ key: "config", icon: SettingsIcon, i18nKey: "tabs.config" },
{ key: "mcp", icon: Server, i18nKey: "tabs.mcp" },
{ key: "plugins", icon: PlugZap, i18nKey: "tabs.plugins" },
{ key: "skills", icon: Sparkles, i18nKey: "tabs.skills", group: "tabGroups.content" },
{ key: "agents", icon: UserRound, i18nKey: "tabs.agents", group: "tabGroups.content" },
{ key: "rules", icon: FileText, i18nKey: "tabs.rules", group: "tabGroups.content" },
{ key: "memory", icon: BookOpen, i18nKey: "tabs.memory", group: "tabGroups.content" },
{ key: "mcp", icon: Server, i18nKey: "tabs.mcp", group: "tabGroups.extensions" },
{ key: "plugins", icon: PlugZap, i18nKey: "tabs.plugins", group: "tabGroups.extensions" },
{ key: "config", icon: SettingsIcon, i18nKey: "tabs.config", group: "tabGroups.config" },
];
function isNamedArtifact(tab: TabKey): tab is "skills" | "agents" | "rules" {
@@ -159,7 +165,24 @@ interface NamedArtifactItem {
export function CodexConfig() {
const { t } = useTranslation("codexConfig");
const { t: tSettings } = useTranslation("settings");
const [tab, setTab] = useState<TabKey>("overview");
// The tab lives in the URL (`?tab=`, replaced) so search results, deep links
// and Back land on it.
const [params, setParams] = useSearchParams();
const rawTab = params.get("tab");
const tab: TabKey = TABS.some((d) => d.key === rawTab) ? (rawTab as TabKey) : "overview";
const setTab = useCallback(
(k: TabKey) =>
setParams(
(prev) => {
const next = new URLSearchParams(prev);
if (k === "overview") next.delete("tab");
else next.set("tab", k);
return next;
},
{ replace: true }
),
[setParams]
);
// Bumped by Refresh: the AgentHub cards load on mount, so a new key reloads them.
const [agenthubKey, setAgenthubKey] = useState(0);
// Bumped after a CODEX_HOME save: the hooks card reads the new home's hooks.json.
@@ -175,7 +198,10 @@ export function CodexConfig() {
const [backupsOpen, setBackupsOpen] = useState(false);
const [projectMemoryOpen, setProjectMemoryOpen] = useState(false);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const [search, setSearch] = useState("");
// One search box per tab: a filter typed on Skills must not hide Plugins.
const [searches, setSearches] = useState<Partial<Record<TabKey, string>>>({});
const search = searches[tab] ?? "";
const setSearch = (v: string) => setSearches((s) => ({ ...s, [tab]: v }));
const [viewer, setViewer] = useState<{
path: string;
data: CodexFileResponse | null;
@@ -184,13 +210,7 @@ export function CodexConfig() {
const [editor, setEditor] = useState<EditorState<CodexArtifactType, "user" | "project">>(null);
const [confirmDelete, setConfirmDelete] =
useState<ConfirmDeleteState<CodexArtifactType, "user" | "project">>(null);
const [toast, setToast] = useState<Toast>(null);
useEffect(() => {
if (!toast) return;
const id = setTimeout(() => setToast(null), 5000);
return () => clearTimeout(id);
}, [toast]);
const setToast = showToast;
const fetchAll = useCallback(async () => {
setLoading(true);
@@ -269,6 +289,12 @@ export function CodexConfig() {
}, []);
const wsConnected = useSyncExternalStore(eventBus.onConnection, () => eventBus.connected);
const projectRoot = data.overview?.roots.projectRoot ?? null;
// Project files under the plugin cache are wiped by the next plugin update.
const projectDisabled = isPluginCachePath(projectRoot);
useEffect(() => {
if (projectDisabled && scope === "project") setScope("all");
}, [projectDisabled, scope]);
const openViewer = useCallback(async (path: string) => {
setViewer({ path, data: null, error: null });
@@ -285,10 +311,10 @@ export function CodexConfig() {
(type: CodexArtifactType, overrideScope?: "user" | "project") => {
const template = t(`edit.templates.${type}`);
const defaultScope: "user" | "project" =
overrideScope ?? (scope === "project" ? "project" : "user");
overrideScope ?? (scope === "project" && !projectDisabled ? "project" : "user");
setEditor({ mode: "create", type, defaultScope, template });
},
[scope, t]
[scope, t, projectDisabled]
);
const openEdit = useCallback(
@@ -381,8 +407,6 @@ export function CodexConfig() {
<Header
loading={loading}
lastUpdated={lastUpdated}
scope={scope}
onScopeChange={setScope}
onRefresh={() => {
void fetchAll();
setAgenthubKey((k) => k + 1);
@@ -400,29 +424,46 @@ export function CodexConfig() {
<Tabs current={tab} onSelect={setTab} counts={data.overview?.counts} />
<div className="rounded-xl border border-border bg-surface-1">
<div
id="codex-tabpanel"
role="tabpanel"
aria-labelledby={`codex-tab-${tab}`}
className="rounded-xl border border-border bg-surface-1"
>
{tab !== "overview" && tab !== "memory" && tab !== "config" && (
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
<Search className="w-4 h-4 text-fg-muted" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("common.search")}
className="h-7 bg-transparent text-sm text-fg-primary placeholder:text-fg-muted focus:outline-none flex-1"
/>
{search && (
<button
type="button"
onClick={() => setSearch("")}
title={t("common.clearSearch")}
aria-label={t("common.clearSearch")}
className="h-7 w-7 flex-shrink-0 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-fg-secondary hover:bg-surface-3 focus:outline-none focus:ring-1 focus:ring-accent/40"
>
<X className="w-3.5 h-3.5" />
</button>
<div className="flex flex-wrap items-center gap-2 border-b border-border px-4 py-2.5">
<div className="flex flex-1 min-w-[12rem] items-center gap-2 rounded-md px-1 focus-within:ring-2 focus-within:ring-accent/40">
<Search className="w-4 h-4 text-fg-muted" aria-hidden />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("common.search")}
aria-label={t("common.search")}
className="h-7 bg-transparent text-sm text-fg-primary placeholder:text-fg-muted focus:outline-none flex-1"
/>
{search && (
<button
type="button"
onClick={() => setSearch("")}
title={t("common.clearSearch")}
aria-label={t("common.clearSearch")}
className="h-7 w-7 flex-shrink-0 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-fg-secondary hover:bg-surface-3 focus:outline-none focus:ring-1 focus:ring-accent/40"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
{isNamedArtifact(tab) && (
<ScopeToggle
value={scope}
onChange={setScope}
projectRoot={projectRoot}
projectDisabled={projectDisabled}
/>
)}
{isNamedArtifact(tab) && (
<button
type="button"
onClick={() => openCreate(tab)}
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5"
>
@@ -500,10 +541,11 @@ export function CodexConfig() {
{editor && (
<EditorModal<CodexArtifactType, "user" | "project">
state={editor}
fetchContent={(p) => api.codexConfig.file(p).then((r) => r.text)}
fetchContent={(p) => api.codexConfig.file(p)}
onClose={() => setEditor(null)}
onSave={handleSave}
namelessTypes={NAMELESS_TYPES}
projectDisabled={projectDisabled}
/>
)}
{confirmDelete && (
@@ -522,7 +564,6 @@ export function CodexConfig() {
onCreate={handleCreateProjectMemory}
/>
)}
{toast && <ToastNotice toast={toast} onDismiss={() => setToast(null)} />}
</div>
);
}
@@ -532,22 +573,12 @@ export function CodexConfig() {
interface HeaderProps {
loading: boolean;
lastUpdated: Date | null;
scope: CodexScope;
onScopeChange: (s: CodexScope) => void;
onRefresh: () => void;
onOpenBackups: () => void;
wsConnected: boolean;
}
function Header({
loading,
lastUpdated,
scope,
onScopeChange,
onRefresh,
onOpenBackups,
wsConnected,
}: HeaderProps) {
function Header({ loading, lastUpdated, onRefresh, onOpenBackups, wsConnected }: HeaderProps) {
const { t } = useTranslation("codexConfig");
const { t: tCommon } = useTranslation("common");
const formatted = lastUpdated
@@ -559,32 +590,25 @@ function Header({
: "-";
return (
<header className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div className="flex items-start gap-3 min-w-0 flex-1">
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center flex-shrink-0">
<Boxes className="w-4.5 h-4.5 text-accent" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
{wsConnected ? (
<span className="flex items-center gap-1.5 text-[11px] text-status-success bg-status-success/10 border border-status-success/20 px-2 py-0.5 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse-dot" />
{tCommon("live")}
</span>
) : (
<span className="flex items-center gap-1.5 text-[11px] text-fg-secondary bg-surface-4/10 border border-border-light/20 px-2 py-0.5 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-surface-4" />
{tCommon("offline")}
</span>
)}
</div>
<p className="text-xs text-fg-muted max-w-2xl">{t("subtitle")}</p>
</div>
{/* No title of its own: the Settings hub already draws one. */}
<div className="flex items-start gap-2 min-w-0 flex-1">
{wsConnected ? (
<span className="flex items-center gap-1.5 text-[11px] text-status-success bg-status-success/10 border border-status-success/20 px-2 py-0.5 rounded-full flex-shrink-0">
<span className="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse-dot" />
{tCommon("live")}
</span>
) : (
<span className="flex items-center gap-1.5 text-[11px] text-fg-secondary bg-surface-4/10 border border-border-light/20 px-2 py-0.5 rounded-full flex-shrink-0">
<span className="w-1.5 h-1.5 rounded-full bg-surface-4" />
{tCommon("offline")}
</span>
)}
<p className="text-xs text-fg-muted max-w-2xl">{t("subtitle")}</p>
</div>
<div className="flex flex-col items-stretch lg:items-end gap-2 flex-shrink-0">
<div className="flex items-center gap-2 justify-end flex-wrap">
<ScopeToggle value={scope} onChange={onScopeChange} />
<button
type="button"
onClick={onOpenBackups}
className="inline-flex items-center gap-2 rounded-lg border border-border bg-surface-2 px-3 py-1.5 text-xs font-medium text-fg-secondary hover:bg-surface-3 transition-colors"
>
@@ -592,6 +616,7 @@ function Header({
{t("backups.openButton")}
</button>
<button
type="button"
onClick={onRefresh}
disabled={loading}
className="inline-flex items-center gap-2 rounded-lg border border-border bg-surface-2 px-3 py-1.5 text-xs font-medium text-fg-secondary hover:bg-surface-3 disabled:opacity-60 transition-colors"
@@ -617,34 +642,62 @@ function Header({
);
}
/** All / User / Project for the three scoped lists, naming the folder
* "Project" resolves to — and refusing it when that is the plugin cache. */
function ScopeToggle({
value,
onChange,
projectRoot,
projectDisabled,
}: {
value: CodexScope;
onChange: (s: CodexScope) => void;
projectRoot: string | null;
projectDisabled: boolean;
}) {
const { t } = useTranslation("codexConfig");
const { t: tCc } = useTranslation("ccConfig");
const opts: { v: CodexScope; label: string }[] = [
{ v: "all", label: t("scope.all") },
{ v: "user", label: t("scope.user") },
{ v: "project", label: t("scope.project") },
];
return (
<div className="inline-flex rounded-lg border border-border bg-surface-2 p-0.5">
{opts.map((o) => (
<button
key={o.v}
onClick={() => onChange(o.v)}
className={`px-2.5 py-1 text-[11px] font-medium rounded-md transition-colors ${
value === o.v
? "bg-accent/20 text-accent border border-accent/30"
: "text-fg-secondary hover:text-fg-primary"
}`}
<div className="flex items-center gap-2 min-w-0">
<div
role="group"
aria-label={tCc("common.scopeLabel")}
className="inline-flex rounded-lg border border-border bg-surface-2 p-0.5 flex-shrink-0"
>
{opts.map((o) => {
const disabled = o.v === "project" && projectDisabled;
return (
<button
key={o.v}
type="button"
aria-pressed={value === o.v}
disabled={disabled}
title={disabled ? tCc("scope.projectInCache") : undefined}
onClick={() => onChange(o.v)}
className={`px-2.5 py-1 text-[11px] font-medium rounded-md transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${
value === o.v
? "bg-accent/20 text-accent border border-accent/30"
: "text-fg-secondary hover:text-fg-primary"
}`}
>
{o.label}
</button>
);
})}
</div>
{projectRoot && (
<span
className={`truncate font-mono text-[10px] ${projectDisabled ? "text-status-warning" : "text-fg-muted"}`}
title={projectDisabled ? tCc("scope.projectInCache") : projectRoot}
>
{o.label}
</button>
))}
{tCc("scope.projectIs", { path: projectRoot })}
</span>
)}
</div>
);
}
@@ -661,6 +714,7 @@ function Tabs({
counts?: CodexOverview["counts"];
}) {
const { t } = useTranslation("codexConfig");
const { t: tCc } = useTranslation("ccConfig");
const countFor = (key: TabKey): number | null => {
if (!counts) return null;
switch (key) {
@@ -680,15 +734,17 @@ function Tabs({
};
return (
<ConfigTabBar
tabs={TABS.map(({ key, icon, i18nKey }) => ({
tabs={TABS.map(({ key, icon, i18nKey, group }) => ({
key,
icon,
label: t(i18nKey),
count: countFor(key),
group: group ? tCc(group) : undefined,
}))}
current={current}
onSelect={onSelect}
testIdPrefix="codex-tab"
panelId="codex-tabpanel"
ariaLabel={t("title")}
/>
);
@@ -1300,7 +1356,7 @@ function CliRowMenu({
<MenuItem
onSelect={() => {
onClose();
void navigator.clipboard.writeText(item.label);
void copyText(item.label);
}}
>
{t("list.copyName")}
@@ -1756,27 +1812,29 @@ function BackupsModal({
.catch((err: unknown) => setError(err instanceof Error ? err.message : "unknown"));
}, []);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const panelRef = useRef<HTMLDivElement>(null);
useDialog(true, panelRef, onClose);
return (
<div
className="fixed inset-0 bg-black/60 z-40 flex items-center justify-center p-4"
className="fixed inset-0 bg-surface-0/70 z-50 flex items-center justify-center p-4"
onClick={onClose}
>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-labelledby="codex-backups-title"
className="relative w-full max-w-3xl max-h-[85vh] rounded-xl border border-border bg-surface-1 flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
<History className="w-4 h-4 text-fg-muted" />
<span className="text-sm font-medium text-fg-primary flex-1">{t("backups.title")}</span>
<span id="codex-backups-title" className="text-sm font-medium text-fg-primary flex-1">
{t("backups.title")}
</span>
<button
type="button"
onClick={onClose}
className="text-fg-secondary hover:text-fg-primary p-1 rounded-md hover:bg-surface-3"
aria-label={t("common.close")}
@@ -1818,7 +1876,11 @@ function BackupRow({
}) {
const { t } = useTranslation("codexConfig");
const scope = deriveScope(backup.file, roots);
const restoreCmd = `mv ${shellEscape(backup.file)} ${shellEscape(deriveActivePath(backup, roots))}`;
const restoreCmd = restoreCommand(
backup.file,
deriveActivePath(backup, roots),
backup.type === "skills"
);
return (
<div className="rounded-lg border border-border bg-surface-2 px-3 py-2.5">
<div className="flex items-center gap-2 flex-wrap">
@@ -1851,13 +1913,11 @@ function CommandSnippet({ command }: { command: string }) {
<Terminal className="w-3 h-3 text-fg-muted flex-shrink-0" />
<code className="font-mono text-[11px] text-fg-secondary truncate flex-1">{command}</code>
<button
type="button"
onClick={async () => {
try {
await navigator.clipboard.writeText(command);
if (await copyText(command, true)) {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
/* clipboard unavailable */
}
}}
className="text-[10px] font-medium px-1.5 py-1 rounded border border-border bg-surface-1 hover:bg-surface-3 text-fg-secondary hover:text-fg-primary inline-flex items-center gap-1 flex-shrink-0"
@@ -1905,9 +1965,3 @@ function deriveActivePath(b: CodexBackup, roots: CodexOverview["roots"] | undefi
const root = scope === "user" ? roots.codexHome : roots.projectCodexDir;
return `${root}/${b.type}/${baseName}`;
}
// Quote for POSIX shells: wrap in single quotes, escape any embedded single quotes.
function shellEscape(s: string): string {
if (/^[A-Za-z0-9_/.@:=+,-]+$/.test(s)) return s;
return `'${s.replace(/'/g, `'\\''`)}'`;
}
+37 -11
View File
@@ -6,7 +6,9 @@
* adds one, deletes one. A token is write-only here: the server never returns
* one, so the form starts blank and the list shows only that one is saved.
* The git bar's login form saves into the same store. A right-click on a row
* opens the same Replace / Remove (plus Copy host) as a menu.
* opens the same Replace / Remove (plus Copy host) as a menu. A bare host
* (`git.example.com`) means https; Replace keeps the host fixed (it replaces
* that host's login, never moves it), and a remove asks through `confirmDialog`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState } from "react";
@@ -14,6 +16,9 @@ import { useTranslation } from "react-i18next";
import { Pencil, Plus, Trash2 } from "lucide-react";
import { api, type GitCredential } from "../lib/api";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../components/ContextMenu";
import { confirmDialog } from "../lib/confirm";
import { copyText } from "../lib/clipboard";
import { toast } from "../lib/toast";
const INPUT =
"w-full rounded border border-border-light bg-surface-0 px-2 py-1 text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none";
@@ -22,7 +27,15 @@ export function GitCredentialsConfig() {
const { t } = useTranslation("settings");
const [items, setItems] = useState<GitCredential[]>([]);
const [error, setError] = useState<string | null>(null);
const [form, setForm] = useState<{ url: string; username: string; token: string } | null>(null);
// `replacing`: the host is the saved one's and stays fixed.
const [form, setForm] = useState<{
url: string;
username: string;
token: string;
replacing?: boolean;
} | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const ctx = useContextMenu<GitCredential>();
// A menu item: close the menu, then act on the row it was opened on.
const pick = (fn: (c: GitCredential) => unknown) => () => {
@@ -36,6 +49,8 @@ export function GitCredentialsConfig() {
setItems((await api.gitCredentials.list()).items);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
@@ -46,12 +61,15 @@ export function GitCredentialsConfig() {
if (!form) return;
setError(null);
let url: URL;
const typed = form.url.trim();
try {
url = new URL(form.url.trim());
// A bare host (no scheme) is the common case: take it as https.
url = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(typed) ? typed : `https://${typed}`);
} catch {
setError(t("gitCreds.badUrl"));
return;
}
setSaving(true);
try {
const r = await api.gitCredentials.save({
protocol: url.protocol.replace(/:$/, ""),
@@ -61,16 +79,25 @@ export function GitCredentialsConfig() {
});
setItems(r.items);
setForm(null);
toast.success(t("gitCreds.saved", { host: url.host }));
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setSaving(false);
}
};
const replace = (c: GitCredential) =>
setForm({ url: `${c.protocol}://${c.host}`, username: c.username, token: "" });
setForm({ url: `${c.protocol}://${c.host}`, username: c.username, token: "", replacing: true });
const remove = async (c: GitCredential) => {
if (!window.confirm(t("gitCreds.removeConfirm"))) return;
if (
!(await confirmDialog({
title: t("gitCreds.removeConfirm"),
confirmLabel: t("gitCreds.remove"),
}))
)
return;
try {
setItems((await api.gitCredentials.remove(c.protocol, c.host)).items);
} catch (e) {
@@ -111,8 +138,9 @@ export function GitCredentialsConfig() {
aria-label={t("gitCreds.fieldHost")}
placeholder="https://git.example.com"
value={form.url}
readOnly={form.replacing}
onChange={(e) => setForm({ ...form, url: e.target.value })}
className={`${INPUT} font-mono`}
className={`${INPUT} font-mono read-only:opacity-70`}
/>
<input
aria-label={t("gitCreds.fieldUser")}
@@ -134,7 +162,7 @@ export function GitCredentialsConfig() {
<div className="flex gap-2 sm:col-span-3">
<button
type="submit"
disabled={!form.url.trim() || !form.username.trim() || !form.token}
disabled={saving || !form.url.trim() || !form.username.trim() || !form.token}
className="rounded border border-accent/40 bg-accent/15 px-3 py-1 text-xs text-accent hover:bg-accent/25 disabled:cursor-not-allowed disabled:opacity-40"
>
{t("gitCreds.save")}
@@ -199,7 +227,7 @@ export function GitCredentialsConfig() {
{items.length === 0 && (
<tr className="border-t border-border">
<td colSpan={4} className="px-3 py-4 text-center text-fg-muted">
{t("gitCreds.empty")}
{loading ? t("gitCreds.loading") : t("gitCreds.empty")}
</td>
</tr>
)}
@@ -209,9 +237,7 @@ export function GitCredentialsConfig() {
{ctx.menu && (
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="git-cred-menu">
<MenuItem onSelect={pick(replace)}>{t("gitCreds.replace")}</MenuItem>
<MenuItem onSelect={pick((c) => void navigator.clipboard.writeText(c.host))}>
{t("rowMenu.copyHost")}
</MenuItem>
<MenuItem onSelect={pick((c) => void copyText(c.host))}>{t("rowMenu.copyHost")}</MenuItem>
<MenuSeparator />
<MenuItem danger onSelect={pick((c) => void remove(c))}>
{t("gitCreds.remove")}
File diff suppressed because it is too large Load Diff
+160 -13
View File
@@ -1,22 +1,36 @@
/**
* @file SettingsHub.tsx
* @description The Settings page: `/settings/<section>`, a left nav grouped
* General / Agents / Routing / Git (a `<select>` below `md`), a
* General / Agents / Routing / Cost / Git (a `<select>` below `md`), a
* search box over `SETTINGS_INDEX` (`lib/settingsSearch.ts`; Ctrl/Cmd+F
* focuses it, Enter opens the first match, Escape clears), and one page per
* section. A tool's own settings live under `agents/<id>` — Claude Code and
* focuses it — and selects its text when it already has focus —, a result list
* deep-links a row's sub-tab and anchor, Enter opens the first result, Escape
* clears), and one page per section. A `#anchor` in the URL scrolls to the
* element with that `id` or `data-settings-anchor` and briefly highlights it.
* Back returns to the page before Settings when there is one, else /workspace;
* an unknown section shows a skeleton until the tool list loads, then toasts
* and falls back to General. 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.
* The old `?tool=<id>` query and `#ai-tools` hash redirect to the path form.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useRef, useState, type ComponentType, type ReactNode } from "react";
import {
useEffect,
useRef,
useState,
type ComponentType,
type MouseEvent,
type ReactNode,
} from "react";
import { Link, Navigate, useLocation, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
ArrowLeft,
Bell,
Database,
Info,
Bot,
Boxes,
Coins,
@@ -43,9 +57,16 @@ import { AiToolsSettings } from "../components/settings/AiToolsSettings";
import { PricingSettings } from "../components/settings/PricingSettings";
import { WorktreeVisibilitySettings } from "../components/settings/WorktreeVisibilitySettings";
import { useAiTools } from "../hooks/useAiTools";
import { rankSettings, SETTINGS_INDEX, type SettingsEntry } from "../lib/settingsSearch";
import {
rankSettings,
rankSettingsEntries,
SETTINGS_INDEX,
type SettingsEntry,
} from "../lib/settingsSearch";
import { Skeleton } from "../components/Skeleton";
import { toast } from "../lib/toast";
const GROUPS = ["general", "agents", "routing", "git"] as const;
const GROUPS = ["general", "agents", "routing", "cost", "git"] as const;
type Group = (typeof GROUPS)[number];
interface Section {
@@ -66,6 +87,24 @@ const LEGACY_TOOL: Record<string, string> = {
"git-credentials": "git-credentials",
};
/** Classes a deep-linked row wears for a moment after the hub scrolls to it. */
const HIGHLIGHT = ["ring-2", "ring-accent/60", "rounded-lg"];
/** A history entry's index as react-router's BrowserRouter records it. */
const historyIdx = (): number => {
const idx = (window.history.state as { idx?: unknown } | null)?.idx;
return typeof idx === "number" ? idx : 0;
};
/** An unknown section: say so, then show General. */
function UnknownSection({ name }: { name: string }) {
const { t } = useTranslation("settings");
useEffect(() => {
toast.info(t("settingsNav.unknownSection", { section: name }));
}, [name, t]);
return <Navigate to="/settings/general" replace />;
}
export function SettingsHub() {
const { t } = useTranslation("settings");
const location = useLocation();
@@ -73,6 +112,11 @@ export function SettingsHub() {
const aiTools = useAiTools();
const [query, setQuery] = useState("");
const searchRef = useRef<HTMLInputElement>(null);
// The history index Settings was entered at: Back unwinds to the entry
// before it rather than always landing on /workspace.
const entryIdx = useRef<number | null>(null);
if (entryIdx.current === null) entryIdx.current = historyIdx();
const canGoBack = entryIdx.current > 0;
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
@@ -81,13 +125,53 @@ export function SettingsHub() {
const el = e.target;
if (el instanceof Element && el.closest("textarea, [contenteditable='true'], .cm-editor"))
return;
// An open modal owns the keyboard: never pull focus to a box behind it.
if (document.querySelector('[aria-modal="true"]')) return;
e.preventDefault();
searchRef.current?.focus();
const box = searchRef.current;
if (!box) return;
// A second press while the box has focus selects its text, so the
// shortcut always does something visible (and never opens the browser's).
if (document.activeElement === box) box.select();
else box.focus();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
// Deep link to a row: scroll to the anchor once the page has rendered it
// (content loads async, so poll briefly) and highlight it for a moment.
useEffect(() => {
const id = decodeURIComponent(location.hash.slice(1));
if (!id) return;
let timer: number | undefined;
let lit: Element | null = null;
let tries = 0;
const find = () =>
document.getElementById(id) ??
document.querySelector(`[data-settings-anchor="${id.replace(/["\\]/g, "")}"]`);
const tick = () => {
const el = find();
if (!el) {
if (++tries < 30) timer = window.setTimeout(tick, 100);
return;
}
const reduce = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
el.scrollIntoView?.({ block: "start", behavior: reduce ? "auto" : "smooth" });
el.classList.add(...HIGHLIGHT);
lit = el;
timer = window.setTimeout(() => {
el.classList.remove(...HIGHLIGHT);
lit = null;
}, 1600);
};
tick();
return () => {
window.clearTimeout(timer);
lit?.classList.remove(...HIGHLIGHT);
};
}, [location.key, location.hash]);
const enabled = (id: string) => aiTools.byId(id)?.enabled !== false;
const s = (key: string) => ({
title: t(`settingsNav.section.${key}.title`),
@@ -101,6 +185,20 @@ export function SettingsHub() {
...s("general"),
render: () => <Settings section="general" />,
},
{
id: "data",
group: "general",
Icon: Database,
...s("data"),
render: () => <Settings section="data" />,
},
{
id: "about",
group: "general",
Icon: Info,
...s("about"),
render: () => <Settings section="about" />,
},
{
id: "appearance",
group: "general",
@@ -172,7 +270,7 @@ export function SettingsHub() {
},
{
id: "pricing",
group: "routing",
group: "cost",
Icon: Coins,
...s("pricing"),
render: () => (
@@ -225,6 +323,7 @@ export function SettingsHub() {
title: t(e.titleKey),
description: e.descKey ? t(e.descKey) : undefined,
keywords: e.keywords,
to: e.to,
})),
]);
@@ -243,23 +342,39 @@ export function SettingsHub() {
const active = sections.find((x) => x.id === rest);
if (!active) {
// A catalog tool page exists only once the tool list has loaded.
if (!aiTools.loaded) return null;
return <Navigate to="/settings/general" replace />;
if (!aiTools.loaded)
return (
<div className="space-y-3" aria-busy="true">
<Skeleton className="h-6 w-48" />
<Skeleton className="h-4 w-80" />
<Skeleton className="h-40 w-full" />
</div>
);
return <UnknownSection name={rest} />;
}
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 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 ?? ""}`;
const go = (id: string) => navigate(`/settings/${id}`);
const back = (e: MouseEvent) => {
if (!canGoBack) return;
e.preventDefault();
navigate(-(historyIdx() - (entryIdx.current ?? 0) + 1));
};
return (
<div className="space-y-4">
<Link
to="/workspace"
onClick={back}
className="inline-flex items-center gap-1.5 text-xs text-fg-secondary hover:text-fg-primary"
>
<ArrowLeft className="h-3.5 w-3.5" />
{t("settingsNav.back")}
{canGoBack ? t("settingsNav.backPrevious") : t("settingsNav.back")}
</Link>
<label className="relative block md:w-56">
@@ -270,7 +385,7 @@ export function SettingsHub() {
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && first) go(first);
if (e.key === "Enter" && results[0]) navigate(hrefOf(results[0]));
if (e.key === "Escape") setQuery("");
}}
placeholder={t("search.placeholder")}
@@ -279,6 +394,38 @@ export function SettingsHub() {
/>
</label>
{ranked && (
<div className="md:w-96">
{results.length === 0 ? (
<p role="status" className="px-2 text-xs text-fg-muted md:hidden">
{t("search.noMatch", { query })}
</p>
) : (
<ul
aria-label={t("search.results")}
className="card divide-y divide-border/60 overflow-hidden"
>
{results.map((r, i) => (
<li key={`${r.section}${r.to ?? ""}${r.title}`}>
<Link
to={hrefOf(r)}
onClick={() => setQuery("")}
className={`block px-3 py-2 text-xs hover:bg-surface-3 ${
i === 0 ? "bg-surface-2" : ""
}`}
>
<span className="block text-fg-primary">{r.title}</span>
{!r.sectionTitle && (
<span className="block text-[11px] text-fg-muted">{titleOf(r.section)}</span>
)}
</Link>
</li>
))}
</ul>
)}
</div>
)}
<div className="flex gap-6">
<nav
aria-label={t("settingsNav.label")}
@@ -323,7 +470,7 @@ export function SettingsHub() {
aria-label={t("settingsNav.label")}
value={active.id}
onChange={(e) => go(e.target.value)}
className="mb-4 w-full rounded-lg border border-border bg-surface-1 px-2 py-1.5 text-sm text-fg-primary md:hidden"
className="select mb-4 w-full py-1.5 md:hidden"
>
{GROUPS.map((g) => {
const own = sections.filter((x) => x.group === g);
@@ -7,10 +7,11 @@
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { AiRouterConfig } from "../AiRouterConfig";
import { api } from "../../lib/api";
import { settleConfirm, subscribeConfirm, type PendingConfirm } from "../../lib/confirm";
vi.mock("../../lib/api", () => ({
api: {
@@ -158,8 +159,18 @@ describe("AiRouterConfig", () => {
await user.type(screen.getByLabelText("aiRouter.fieldModel"), "x");
await user.click(screen.getByRole("button", { name: "aiRouter.save" }));
expect(await screen.findByText(/must be http/)).toBeInTheDocument();
// Inside the dialog, not behind its scrim.
const dialog = screen.getByRole("dialog");
expect(await within(dialog).findByText(/must be http/)).toBeInTheDocument();
});
it("closes the form on Escape", async () => {
render(<AiRouterConfig />);
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: /aiRouter\.add/ }));
expect(screen.getByRole("dialog")).toBeInTheDocument();
await user.keyboard("{Escape}");
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
});
});
@@ -236,7 +247,8 @@ describe("AiRouterConfig — testing an endpoint before saving it", () => {
it("opens a profile's Edit and Remove from a right-click; the built-in row has no menu", async () => {
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
const confirm = vi.spyOn(window, "confirm").mockReturnValueOnce(false);
let pending: PendingConfirm | null = null;
const off = subscribeConfirm((p) => (pending = p));
const user = userEvent.setup();
render(<AiRouterConfig />);
const row = (await screen.findByText("9Router")).closest("tr")!;
@@ -246,12 +258,14 @@ describe("AiRouterConfig — testing an endpoint before saving it", () => {
await user.pointer({ keys: "[MouseRight]", target: row });
await user.click(screen.getByRole("menuitem", { name: "aiRouter.remove" }));
expect(confirm).toHaveBeenCalledWith("aiRouter.removeConfirm:9Router");
await waitFor(() => expect(pending?.title).toBe("aiRouter.removeConfirm:9Router"));
settleConfirm(false);
await waitFor(() => expect(pending).toBeNull());
expect(api.aiRouters.remove).not.toHaveBeenCalled();
await user.pointer({ keys: "[MouseRight]", target: row });
await user.click(screen.getByRole("menuitem", { name: "aiRouter.edit" }));
expect(screen.getByRole("dialog")).toBeInTheDocument();
confirm.mockRestore();
off();
});
});
@@ -14,6 +14,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { api, type AiTool } from "../../lib/api";
import { __resetAiTools } from "../../hooks/useAiTools";
import { AiToolConfig } from "../AiToolConfig";
import { settleConfirm, subscribeConfirm, type PendingConfirm } from "../../lib/confirm";
import { subscribeToasts, type ToastItem } from "../../lib/toast";
vi.mock("../../lib/api", () => ({
api: {
@@ -138,4 +140,54 @@ describe("AiToolConfig", () => {
expect(await screen.findByText(/not valid JSON/)).toBeInTheDocument();
expect(api.aiTools.saveConfig).toHaveBeenCalledWith("gemini", 0, "{nope", "s1");
});
it("toasts a saved default model", async () => {
let seen: ToastItem[] = [];
const off = subscribeToasts((i) => (seen = i));
renderTab();
fireEvent.click(await screen.findByText("Inherit from settings"));
fireEvent.click(await screen.findByText("gemini-3.1-pro"));
fireEvent.click(screen.getByRole("button", { name: /save default model/i }));
await waitFor(() => expect(seen.some((x) => x.kind === "success")).toBe(true));
off();
});
it("offers a reload on ESTALE and keeps the user's text until they take it", async () => {
vi.mocked(api.aiTools.saveConfig).mockRejectedValueOnce(
Object.assign(new Error("changed on disk"), { code: "ESTALE", status: 409 })
);
renderTab();
fireEvent.click(await screen.findByRole("button", { name: "/h/.gemini/settings.json" }));
const box = await screen.findByRole("textbox", { name: "/h/.gemini/settings.json" });
fireEvent.change(box, { target: { value: "{mine}" } });
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
const reload = await screen.findByRole("button", { name: /reload/i });
expect(box).toHaveValue("{mine}");
vi.mocked(api.aiTools.readConfig).mockResolvedValueOnce({
path: "/h/.gemini/settings.json",
format: "json",
text: "{disk}",
sha: "s2",
});
fireEvent.click(reload);
await waitFor(() => expect(box).toHaveValue("{disk}"));
});
it("asks before Close discards an edited file", async () => {
let pending: PendingConfirm | null = null;
const off = subscribeConfirm((p) => (pending = p));
renderTab();
fireEvent.click(await screen.findByRole("button", { name: "/h/.gemini/settings.json" }));
const box = await screen.findByRole("textbox", { name: "/h/.gemini/settings.json" });
fireEvent.change(box, { target: { value: "{edited}" } });
fireEvent.click(screen.getByRole("button", { name: /^close$/i }));
await waitFor(() => expect(pending).not.toBeNull());
expect(box).toBeInTheDocument();
settleConfirm(false);
await new Promise((r) => setTimeout(r, 0));
expect(screen.getByRole("textbox", { name: "/h/.gemini/settings.json" })).toHaveValue(
"{edited}"
);
off();
});
});
@@ -9,6 +9,8 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import { settleConfirm } from "../../lib/confirm";
const ccConfigMock = vi.hoisted(() => {
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
@@ -99,7 +101,11 @@ afterEach(() => vi.clearAllMocks());
describe("CcConfig — right-click menus", () => {
it("uninstalls a plugin from its menu only on the second click, like the button", async () => {
const user = userEvent.setup();
render(<CcConfig />);
render(
<MemoryRouter>
<CcConfig />
</MemoryRouter>
);
await settle();
await user.click(screen.getByRole("tab", { name: /plugins/i }));
await settle();
@@ -121,7 +127,11 @@ describe("CcConfig — right-click menus", () => {
it("deletes a saved profile from its menu through the same API as the trash button", async () => {
const user = userEvent.setup();
render(<CcConfig />);
render(
<MemoryRouter>
<CcConfig />
</MemoryRouter>
);
await settle();
await user.click(screen.getByRole("button", { name: /profile/i }));
await settle();
@@ -129,12 +139,20 @@ describe("CcConfig — right-click menus", () => {
await user.pointer({ keys: "[MouseRight]", target: screen.getByTestId("profile-row-spare") });
await user.click(screen.getByTestId("profile-menu-delete"));
await settle();
// A saved login is never deleted on one click: it asks first.
expect(ccConfigMock.deleteProfile).not.toHaveBeenCalled();
await act(async () => settleConfirm(true));
await settle();
expect(ccConfigMock.deleteProfile).toHaveBeenCalledWith("spare");
});
it("offers no delete for the only saved copy of the live account", async () => {
const user = userEvent.setup();
render(<CcConfig />);
render(
<MemoryRouter>
<CcConfig />
</MemoryRouter>
);
await settle();
await user.click(screen.getByRole("button", { name: /profile/i }));
await settle();
@@ -0,0 +1,285 @@
/**
* @file Data-safety tests for the Claude Code config page: the settings editor
* unsets only keys from the snapshot it opened (never one written meanwhile),
* a renamed key keeps its value, one failing endpoint blanks only its own tab,
* the tab lives in `?tab=`, the Project scope is refused under the plugin
* cache, and the pure helpers (change → keys, restore command) are right.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, act, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import { settleConfirm, subscribeConfirm, type PendingConfirm } from "../../lib/confirm";
import { eventBus } from "../../lib/eventBus";
const ccConfigMock = vi.hoisted(() => {
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
return {
overview: r({
roots: {
claudeHome: "/home/.claude",
projectClaudeDir: "/repo/.claude",
projectRoot: "/repo",
},
counts: {
skills: { user: 0, project: 0 },
agents: { user: 0, project: 0 },
commands: { user: 0, project: 0 },
outputStyles: { user: 0, project: 0 },
mcpServers: { user: 0, project: 0 },
hooks: {},
plugins: 0,
marketplaces: 0,
keybindings: 0,
settingsFiles: 1,
memory: 0,
},
}),
skills: r({ items: [] }),
agents: r({ items: [] }),
commands: r({ items: [] }),
outputStyles: r({ items: [] }),
memory: r({ items: [] }),
plugins: r({ manifestPath: "", manifestExists: false, plugins: [] }),
marketplaces: r({ items: [] }),
mcp: r({ user: [], projectScoped: [] }),
hooks: r({ items: [] }),
settings: r({
items: [{ scope: "user", file: "/home/.claude/settings.json", exists: true, data: { a: 1 } }],
}),
keybindings: r({ groups: [] }),
statusline: r({ config: null, scripts: [] }),
hookScripts: r({ items: [] }),
patchSettings: r({ ok: true }),
pluginsAvailable: r({ installed: [], available: [] }),
updateAllStatus: r({ batch: null }),
};
});
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: { ...(actual.api as Record<string, unknown>), ccConfig: ccConfigMock },
};
});
import { CcConfig, isPluginCachePath, keysForChange, restoreCommand } from "../CcConfig";
async function settle() {
await act(async () => {
await new Promise((res) => setTimeout(res, 0));
await new Promise((res) => setTimeout(res, 0));
});
}
afterEach(() => vi.clearAllMocks());
describe("CcConfig — settings editor", () => {
it("unsets only keys from its own snapshot, never one written after Edit", async () => {
const user = userEvent.setup();
render(
<MemoryRouter initialEntries={["/settings/claude?tab=settings"]}>
<CcConfig />
</MemoryRouter>
);
await settle();
await user.click(screen.getByRole("button", { name: /^edit$/i }));
// The live CLI writes key `b` while the editor is open.
ccConfigMock.settings.mockResolvedValue({
items: [
{ scope: "user", file: "/home/.claude/settings.json", exists: true, data: { a: 1, b: 2 } },
],
});
vi.useFakeTimers({ shouldAdvanceTime: true });
act(() =>
eventBus.publish({
type: "cc_config_changed",
data: { source: "fs", paths: ["/home/.claude/settings.json"] },
} as never)
);
await act(async () => {
vi.advanceTimersByTime(300);
});
vi.useRealTimers();
await settle();
// Remove `a` and save.
await user.click(screen.getAllByRole("button", { name: /remove key/i })[0]!);
let pending: PendingConfirm | null = null;
const off = subscribeConfirm((p) => (pending = p));
await user.click(screen.getByRole("button", { name: /^save$/i }));
await settle();
// The file changed underneath: it asks before writing.
expect(pending).not.toBeNull();
await act(async () => settleConfirm(true));
await settle();
off();
expect(ccConfigMock.patchSettings).toHaveBeenCalledWith({ scope: "user", unset: ["a"] });
});
it("a renamed key is sent under its new name, not dropped", async () => {
const user = userEvent.setup();
render(
<MemoryRouter initialEntries={["/?tab=settings"]}>
<CcConfig />
</MemoryRouter>
);
await settle();
await user.click(screen.getByRole("button", { name: /^edit$/i }));
const keyBox = screen.getByDisplayValue("a");
await user.clear(keyBox);
await user.type(keyBox, "z");
await user.click(screen.getByRole("button", { name: /^save$/i }));
await settle();
expect(ccConfigMock.patchSettings).toHaveBeenCalledWith({
scope: "user",
set: { z: 1 },
unset: ["a"],
});
});
});
describe("CcConfig — redacted and read-only entries", () => {
it("refuses a new settings key that names a redacted one (it would replace the secret)", async () => {
ccConfigMock.settings.mockResolvedValue({
items: [
{
scope: "user",
file: "/home/.claude/settings.json",
exists: true,
data: { a: 1, env: { API_KEY: "<redacted>" } },
},
],
});
const user = userEvent.setup();
render(
<MemoryRouter initialEntries={["/?tab=settings"]}>
<CcConfig />
</MemoryRouter>
);
await settle();
await user.click(screen.getByRole("button", { name: /^edit$/i }));
await user.click(screen.getByRole("button", { name: /add key/i }));
const keys = screen.getAllByRole("textbox", { name: /^key$/i });
await user.type(keys[keys.length - 1]!, "env");
await user.click(screen.getByRole("button", { name: /^save$/i }));
await settle();
expect(ccConfigMock.patchSettings).not.toHaveBeenCalled();
expect(screen.getByText(/redacted/i, { selector: "span" })).toBeInTheDocument();
});
it("refuses a new hook event that names a read-only one (it would replace its entries)", async () => {
ccConfigMock.hooks.mockResolvedValue({
items: [
{
scope: "user",
file: "/home/.claude/settings.json",
exists: true,
hooks: { Stop: [{ type: "prompt", prompt: "x" }] },
},
],
});
const user = userEvent.setup();
render(
<MemoryRouter initialEntries={["/?tab=hooks"]}>
<CcConfig />
</MemoryRouter>
);
await settle();
await user.click(screen.getByRole("button", { name: /^edit$/i }));
await user.click(screen.getByRole("button", { name: /add event/i }));
await user.type(screen.getByRole("textbox", { name: /event/i }), "Stop");
await user.type(screen.getByRole("textbox", { name: /command/i }), "echo hi");
await user.click(screen.getByRole("button", { name: /^save$/i }));
await settle();
expect(ccConfigMock.patchSettings).not.toHaveBeenCalled();
expect(
screen.getByText(/can't be edited here|read-only/i, { selector: "span" })
).toBeInTheDocument();
});
});
describe("CcConfig — page", () => {
it("one failing endpoint shows its error on its own tab and leaves the rest working", async () => {
ccConfigMock.mcp.mockRejectedValueOnce(new Error("mcp down"));
const user = userEvent.setup();
render(
<MemoryRouter initialEntries={["/?tab=skills"]}>
<CcConfig />
</MemoryRouter>
);
await settle();
expect(screen.queryByText(/mcp down/)).toBeNull();
expect(screen.getByRole("tab", { name: /skills/i })).toHaveAttribute("aria-selected", "true");
await user.click(screen.getByRole("tab", { name: /mcp/i }));
expect(await screen.findByText(/mcp down/)).toBeInTheDocument();
});
it("arrow keys move between tabs (roving tabindex)", async () => {
const user = userEvent.setup();
render(
<MemoryRouter>
<CcConfig />
</MemoryRouter>
);
await settle();
const list = screen.getByRole("tablist");
const overview = within(list).getByRole("tab", { name: /overview/i });
expect(overview).toHaveAttribute("tabindex", "0");
overview.focus();
await user.keyboard("{ArrowRight}");
expect(within(list).getByRole("tab", { name: /skills/i })).toHaveAttribute(
"aria-selected",
"true"
);
expect(document.activeElement).toBe(within(list).getByRole("tab", { name: /skills/i }));
});
it("refuses the Project scope when the project root is the plugin cache", async () => {
ccConfigMock.overview.mockResolvedValueOnce({
roots: {
claudeHome: "/home/.claude",
projectClaudeDir: "/home/u/.claude/plugins/cache/agenthub/agenthub/1.8.13/.claude",
projectRoot: "/home/u/.claude/plugins/cache/agenthub/agenthub/1.8.13",
},
counts: (await ccConfigMock.overview()).counts,
});
render(
<MemoryRouter initialEntries={["/?tab=skills"]}>
<CcConfig />
</MemoryRouter>
);
await settle();
expect(screen.getByRole("button", { name: /^project$/i })).toBeDisabled();
});
});
describe("CcConfig helpers", () => {
it("maps a change to the keys it touched", () => {
expect(keysForChange({ source: "fs", paths: ["/h/.claude/skills/x/SKILL.md"] }).sort()).toEqual(
["overview", "skills"].sort()
);
expect(keysForChange({ source: "dashboard", type: "keybindings" }).sort()).toEqual(
["keybindings", "overview"].sort()
);
// Something unplaceable reloads everything.
expect(keysForChange({ source: "fs", paths: ["/h/.claude/weird.txt"] })).toContain("mcp");
});
it("knows the plugin cache", () => {
expect(isPluginCachePath("/home/u/.claude/plugins/cache/a/b/1.0")).toBe(true);
expect(isPluginCachePath("/home/u/projects/app")).toBe(false);
expect(isPluginCachePath(null)).toBe(false);
});
it("restores a skill directory by copying its contents, never mv into it", () => {
expect(restoreCommand("/b/s.2026.bak", "/h/skills/s", true)).toBe(
"mkdir -p /h/skills/s && cp -a /b/s.2026.bak/. /h/skills/s/"
);
expect(restoreCommand("/b/a.md.2026.bak", "/h/agents/a.md", false)).toBe(
"cp -a /b/a.md.2026.bak /h/agents/a.md"
);
});
});
@@ -12,6 +12,7 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
const ccConfigMock = vi.hoisted(() => {
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
@@ -109,7 +110,11 @@ async function settle() {
}
async function openProfiles() {
render(<CcConfig />);
render(
<MemoryRouter>
<CcConfig />
</MemoryRouter>
);
await settle();
await userEvent.click(screen.getByRole("button", { name: /profile/i }));
await settle();
@@ -13,6 +13,8 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, act, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import { Toaster } from "../../components/Toaster";
const ccConfigMock = vi.hoisted(() => {
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
@@ -100,7 +102,13 @@ async function settle() {
/** Open the plugins tab, where the Update all card lives. */
async function openPlugins() {
render(<CcConfig />);
// Toasts go through the app-wide queue, so the test mounts its renderer.
render(
<MemoryRouter>
<CcConfig />
<Toaster />
</MemoryRouter>
);
await settle();
await userEvent.click(screen.getByRole("tab", { name: /plugins/i }));
await settle();
@@ -10,6 +10,7 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
// vi.mock factories are hoisted above the rest of the module, so the mock
// object they reference must be created inside vi.hoisted() too.
@@ -131,7 +132,11 @@ describe("CodexConfig", () => {
codex_home: "/work/.codex",
env_owned: false,
});
render(<CodexConfig />);
render(
<MemoryRouter>
<CodexConfig />
</MemoryRouter>
);
await settle();
const overviewCalls = codexConfigMock.overview.mock.calls.length;
@@ -151,7 +156,11 @@ describe("CodexConfig", () => {
it("offers create on the authored surfaces and withholds it on the CLI-owned ones", async () => {
stubCodexApi({ skills: [], mcp: { servers: [] } });
render(<CodexConfig />);
render(
<MemoryRouter>
<CodexConfig />
</MemoryRouter>
);
await settle();
await userEvent.click(screen.getByRole("tab", { name: /skills/i }));
@@ -167,7 +176,11 @@ describe("CodexConfig", () => {
rules: [{ scope: "user", name: "default", file: "/r/default.rules", size: 3 }],
write,
});
render(<CodexConfig />);
render(
<MemoryRouter>
<CodexConfig />
</MemoryRouter>
);
await settle();
await userEvent.click(screen.getByRole("tab", { name: /rules/i }));
@@ -181,7 +194,11 @@ describe("CodexConfig", () => {
it("keeps the file-backed tabs working when the codex CLI read rejects", async () => {
stubCodexApi({ skills: [{ scope: "user", name: "my-skill", file: "/s/my-skill", size: 3 }] });
codexConfigMock.mcp.mockRejectedValueOnce(new Error("the codex CLI was not found on PATH"));
render(<CodexConfig />);
render(
<MemoryRouter>
<CodexConfig />
</MemoryRouter>
);
await settle();
// No page-wide error banner - the CLI-backed rejection is isolated.
@@ -199,7 +216,11 @@ describe("CodexConfig", () => {
it("creates a nameless-type artifact (config.toml) without requiring a name", async () => {
const write = vi.fn().mockResolvedValue({ file: "/c/config.toml", created: true });
stubCodexApi({ write });
render(<CodexConfig />);
render(
<MemoryRouter>
<CodexConfig />
</MemoryRouter>
);
await settle();
await userEvent.click(screen.getByRole("tab", { name: /config/i }));
@@ -228,7 +249,11 @@ describe("CodexConfig", () => {
},
],
});
render(<CodexConfig />);
render(
<MemoryRouter>
<CodexConfig />
</MemoryRouter>
);
await settle();
await userEvent.click(screen.getByRole("button", { name: /backups/i }));
@@ -11,6 +11,19 @@ import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { GitCredentialsConfig } from "../GitCredentialsConfig";
import { api } from "../../lib/api";
import { settleConfirm, subscribeConfirm, type PendingConfirm } from "../../lib/confirm";
import { subscribeToasts, type ToastItem } from "../../lib/toast";
/** Answer the next confirm dialogs in order; returns the titles asked. */
function answerConfirms(...answers: boolean[]) {
const asked: string[] = [];
const off = subscribeConfirm((p: PendingConfirm | null) => {
if (!p) return;
asked.push(p.title);
queueMicrotask(() => settleConfirm(answers.shift() ?? false));
});
return { asked, off };
}
vi.mock("../../lib/api", () => ({
api: { gitCredentials: { list: vi.fn(), save: vi.fn(), remove: vi.fn() } },
@@ -84,24 +97,22 @@ describe("GitCredentialsConfig", () => {
});
it("deletes only after a confirm", async () => {
const confirm = vi
.spyOn(window, "confirm")
.mockReturnValueOnce(false)
.mockReturnValueOnce(true);
const confirm = answerConfirms(false, true);
const user = userEvent.setup();
render(<GitCredentialsConfig />);
const del = await screen.findByRole("button", { name: "gitCreds.remove git.smartgift.io.vn" });
await user.click(del);
await waitFor(() => expect(confirm.asked).toHaveLength(1));
expect(api.gitCredentials.remove).not.toHaveBeenCalled();
await user.click(del);
await waitFor(() =>
expect(api.gitCredentials.remove).toHaveBeenCalledWith("https", "git.smartgift.io.vn")
);
confirm.mockRestore();
confirm.off();
});
it("offers Replace and Remove on a right-click, Remove behind the same confirm", async () => {
const confirm = vi.spyOn(window, "confirm").mockReturnValueOnce(true);
const confirm = answerConfirms(true);
const user = userEvent.setup();
render(<GitCredentialsConfig />);
const row = (await screen.findByText("git.smartgift.io.vn")).closest("tr")!;
@@ -113,10 +124,41 @@ describe("GitCredentialsConfig", () => {
await user.pointer({ keys: "[MouseRight]", target: row });
await user.click(screen.getByRole("menuitem", { name: "gitCreds.remove" }));
expect(confirm).toHaveBeenCalledWith("gitCreds.removeConfirm");
await waitFor(() => expect(confirm.asked).toEqual(["gitCreds.removeConfirm"]));
await waitFor(() =>
expect(api.gitCredentials.remove).toHaveBeenCalledWith("https", "git.smartgift.io.vn")
);
confirm.mockRestore();
confirm.off();
});
it("takes a bare host as https, and toasts the save", async () => {
let seen: ToastItem[] = [];
const off = subscribeToasts((i) => (seen = i));
const user = userEvent.setup();
render(<GitCredentialsConfig />);
await user.click(await screen.findByRole("button", { name: "gitCreds.add" }));
await user.type(screen.getByLabelText("gitCreds.fieldHost"), "git.example.com");
await user.type(screen.getByLabelText("gitCreds.fieldUser"), "bot");
await user.type(screen.getByLabelText("gitCreds.fieldToken"), "t");
await user.click(screen.getByRole("button", { name: "gitCreds.save" }));
await waitFor(() =>
expect(api.gitCredentials.save).toHaveBeenCalledWith({
protocol: "https",
host: "git.example.com",
username: "bot",
password: "t",
})
);
await waitFor(() => expect(seen.some((x) => x.kind === "success")).toBe(true));
off();
});
it("does not let Replace change the host", async () => {
const user = userEvent.setup();
render(<GitCredentialsConfig />);
await user.click(
await screen.findByRole("button", { name: "gitCreds.replace git.smartgift.io.vn" })
);
expect(screen.getByLabelText("gitCreds.fieldHost")).toHaveAttribute("readonly");
});
});
@@ -0,0 +1,125 @@
/**
* @file Settings.test.tsx
* @description The install-wide Settings pages: General and Notifications never
* read `/api/settings/info` (it COUNTs whole tables), Data reads it once and
* coalesces a burst of activity instead of re-reading per event, a purge takes
* a whole number of days and runs only after a dry-run count is confirmed, and
* Clear all data stays disabled until the confirmation phrase is typed.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { settleConfirm, subscribeConfirm, type PendingConfirm } from "../../lib/confirm";
vi.mock("../../components/ImportHistory", () => ({ ImportHistory: () => null }));
vi.mock("../../components/RemoteSources", () => ({ RemoteSources: () => null }));
vi.mock("../../components/AlertsNotifications", () => ({ AlertsNotifications: () => null }));
const info = vi.fn(async () => ({
db: { path: "/tmp/x.db", size: 1, counts: { sessions: 1 } },
hooks: { installed: true, path: "", hooks: {} },
server: { version: "1", uptime: 1, node_version: "v1", platform: "linux", ws_connections: 0 },
}));
const cleanup = vi.fn(async (p: { dry_run?: boolean }) => ({
ok: true,
dry_run: p.dry_run,
abandoned: 0,
purged_sessions: 2,
purged_events: 5,
purged_agents: 1,
}));
const clearData = vi.fn(async () => ({ ok: true, cleared: { sessions: 1 } }));
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: {
pricing: { totalCost: vi.fn(async () => ({ total_cost: 1 })) },
settings: {
info: (...a: unknown[]) => info(...(a as [])),
cleanup: (p: { dry_run?: boolean }) => cleanup(p),
clearData: () => clearData(),
gc: vi.fn(async () => ({ reaped: [], capped: [] })),
exportData: () => "/api/settings/export",
},
},
};
});
import { Settings } from "../Settings";
import { eventBus } from "../../lib/eventBus";
beforeEach(() => {
info.mockClear();
cleanup.mockClear();
clearData.mockClear();
});
const burst = () => {
for (let i = 0; i < 5; i++)
eventBus.publish({ type: "new_event", data: {} } as unknown as Parameters<
typeof eventBus.publish
>[0]);
};
describe("Settings", () => {
it("never reads server info on Notifications or General, even on activity", async () => {
const { unmount } = render(<Settings section="notifications" />);
burst();
await new Promise((r) => setTimeout(r, 30));
unmount();
render(<Settings section="general" />);
burst();
await new Promise((r) => setTimeout(r, 30));
expect(info).not.toHaveBeenCalled();
});
it("reads info once on Data and coalesces a burst of activity", async () => {
render(<Settings section="data" />);
await screen.findByText("/tmp/x.db");
expect(info).toHaveBeenCalledTimes(1);
burst();
await new Promise((r) => setTimeout(r, 50));
expect(info).toHaveBeenCalledTimes(1);
});
it("refuses a fractional purge and confirms a dry-run count before the real run", async () => {
const user = userEvent.setup();
render(<Settings section="data" />);
const days = await screen.findByLabelText(/purge completed/i);
const run = screen.getByRole("button", { name: /run cleanup/i });
await user.clear(days);
await user.type(days, "0.5");
expect(run).toBeDisabled();
await user.clear(days);
await user.type(days, "30");
let pending: PendingConfirm | null = null;
const off = subscribeConfirm((p) => (pending = p));
await user.click(run);
await waitFor(() => expect(pending).not.toBeNull());
expect(cleanup).toHaveBeenCalledTimes(1);
expect(cleanup.mock.calls[0]![0]).toMatchObject({ purge_days: 30, dry_run: true });
expect(pending!.message).toMatch(/2 old session/);
settleConfirm(true);
off();
await waitFor(() => expect(cleanup).toHaveBeenCalledTimes(2));
expect(cleanup.mock.calls[1]![0]).not.toHaveProperty("dry_run");
});
it("enables Clear all data only once the phrase is typed", async () => {
const user = userEvent.setup();
render(<Settings section="data" />);
await user.click(await screen.findByRole("button", { name: /clear all data/i }));
const dialog = screen.getByRole("dialog", { name: /clear all data/i });
const yes = screen.getByRole("button", { name: /yes, clear all/i });
expect(yes).toBeDisabled();
await user.type(screen.getByRole("textbox"), "clear all data");
expect(yes).toBeEnabled();
await user.click(yes);
await waitFor(() => expect(clearData).toHaveBeenCalled());
expect(dialog).not.toBeInTheDocument();
});
});
@@ -5,7 +5,9 @@
* unknown section falls back to General, a disabled tool has no page while an
* installed, enabled catalog tool gets one, 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.
* 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
* highlighted, Pricing sits in its own Cost group, and an unknown section toasts.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -16,7 +18,9 @@ import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
import type { AiTool, AiToolsView } from "../../lib/api";
vi.mock("../Settings", () => ({
Settings: ({ section }: { section: string }) => <div data-testid={`settings-${section}`} />,
Settings: ({ section }: { section: string }) => (
<div data-testid={`settings-${section}`} data-settings-anchor={`${section}-row`} />
),
}));
vi.mock("../CcConfig", () => ({ CcConfig: () => <div data-testid="cc-config" /> }));
vi.mock("../CodexConfig", () => ({ CodexConfig: () => <div data-testid="codex-config" /> }));
@@ -56,6 +60,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
import { SettingsHub } from "../SettingsHub";
import { __resetAiTools } from "../../hooks/useAiTools";
import { subscribeToasts, type ToastItem } from "../../lib/toast";
function Where() {
const loc = useLocation();
@@ -180,4 +185,70 @@ describe("SettingsHub", () => {
await user.type(box, "zzzz");
expect(within(nav()).getByText("No setting matches “zzzz”.")).toBeInTheDocument();
});
it("Ctrl+F leaves the search alone while a modal dialog is open", async () => {
renderAt("/settings/general");
await screen.findByTestId("settings-general");
const dialog = document.createElement("div");
dialog.setAttribute("role", "dialog");
dialog.setAttribute("aria-modal", "true");
const field = document.createElement("input");
dialog.appendChild(field);
document.body.appendChild(dialog);
field.focus();
const ev = fireEvent.keyDown(window, { key: "f", ctrlKey: true });
expect(ev).toBe(true); // not prevented
expect(field).toHaveFocus();
dialog.remove();
});
it("a second Ctrl+F with the search focused selects its text instead of reaching the browser", async () => {
const user = userEvent.setup();
renderAt("/settings/general");
await screen.findByTestId("settings-general");
const box = screen.getByRole<HTMLInputElement>("searchbox", { name: "Search settings" });
fireEvent.keyDown(window, { key: "f", ctrlKey: true });
await user.type(box, "pet");
const second = fireEvent.keyDown(window, { key: "f", ctrlKey: true });
expect(second).toBe(false); // default prevented
expect(box).toHaveFocus();
expect([box.selectionStart, box.selectionEnd]).toEqual([0, 3]);
});
it("lists matching rows and deep-links a Claude Code sub-tab", async () => {
const user = userEvent.setup();
renderAt("/settings/general");
await screen.findByTestId("settings-general");
await user.type(screen.getByRole("searchbox", { name: "Search settings" }), "marketplaces");
const list = screen.getByRole("list", { name: "Search results" });
await user.click(within(list).getAllByRole("link")[0]!);
expect(screen.getByTestId("where").textContent).toBe(
"/settings/agents/claude-code?tab=marketplaces"
);
});
it("puts Pricing in its own Cost group and splits General into General, Data and About", async () => {
renderAt("/settings/data");
expect(await screen.findByTestId("settings-data")).toBeInTheDocument();
expect(within(nav()).getByText("Cost")).toBeInTheDocument();
const links = within(nav())
.getAllByRole("link")
.map((a) => a.textContent);
expect(links.slice(0, 3)).toEqual(["General", "Data", "About"]);
});
it("scrolls to and highlights the anchor the URL names", async () => {
renderAt("/settings/data#data-row");
const row = await screen.findByTestId("settings-data");
await vi.waitFor(() => expect(row.className).toContain("ring-2"));
});
it("toasts when an unknown section falls back to General", async () => {
let seen: ToastItem[] = [];
const off = subscribeToasts((i) => (seen = i));
renderAt("/settings/nope");
await screen.findByTestId("settings-general");
expect(seen.some((x) => x.message.includes("nope"))).toBe(true);
off();
});
});
+121
View File
@@ -0,0 +1,121 @@
/**
* @file Tests for POST /api/settings/cleanup's input rules and dry run:
* `purge_days` must be a whole number of days ≥ 1 (a fraction such as 0.1
* would purge everything older than ~2 hours) and `dry_run: true` reports what
* would be abandoned/purged without changing a row.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("path");
const os = require("os");
const fs = require("fs");
const http = require("http");
const TEST_DB = path.join(os.tmpdir(), `dashboard-cleanup-test-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
process.env.DASHBOARD_LIVENESS_PROBE = "0";
const { createApp, startServer } = require("../index");
const { db } = require("../db");
let server;
let BASE;
function post(urlPath, body) {
return new Promise((resolve, reject) => {
const url = new URL(urlPath, BASE);
const req = http.request(
{
hostname: url.hostname,
port: url.port,
path: url.pathname,
method: "POST",
headers: { "Content-Type": "application/json" },
},
(res) => {
let text = "";
res.on("data", (c) => (text += c));
res.on("end", () => resolve({ status: res.statusCode, body: JSON.parse(text) }));
}
);
req.on("error", reject);
req.write(JSON.stringify(body));
req.end();
});
}
const OLD = new Date(Date.now() - 400 * 86400 * 1000).toISOString();
before(async () => {
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
db.prepare(
"INSERT INTO sessions (id, name, status, cwd, started_at, updated_at) VALUES (?, ?, 'completed', '/tmp', ?, ?)"
).run("old-done", "old", OLD, OLD);
});
after(() => {
if (server) server.close();
db.close();
for (const s of ["", "-wal", "-shm"]) {
try {
fs.unlinkSync(TEST_DB + s);
} catch {
/* ignore */
}
}
});
describe("POST /api/settings/cleanup", () => {
for (const bad of [0.1, 0, -3, 1.5, "30"]) {
it(`refuses purge_days=${JSON.stringify(bad)} with 400`, async () => {
const r = await post("/api/settings/cleanup", { purge_days: bad });
assert.equal(r.status, 400);
assert.equal(r.body.error.code, "INVALID_PURGE_DAYS");
});
}
it("dry_run counts what would be purged and deletes nothing", async () => {
const r = await post("/api/settings/cleanup", { purge_days: 30, dry_run: true });
assert.equal(r.status, 200);
assert.equal(r.body.dry_run, true);
assert.equal(r.body.purged_sessions, 1);
assert.ok(db.prepare("SELECT 1 FROM sessions WHERE id = 'old-done'").get());
});
it("a real run purges", async () => {
const r = await post("/api/settings/cleanup", { purge_days: 30 });
assert.equal(r.status, 200);
assert.equal(r.body.purged_sessions, 1);
assert.equal(db.prepare("SELECT 1 FROM sessions WHERE id = 'old-done'").get(), undefined);
});
it("with abandon AND purge, the dry run counts the sessions the real run abandons then purges", async () => {
// Still 'active' but silent for 400 days: a real run abandons it first,
// then the purge (completed/error/abandoned) deletes it.
db.prepare(
"INSERT INTO sessions (id, name, status, cwd, started_at, updated_at) VALUES (?, ?, 'active', '/tmp', ?, ?)"
).run("old-stale", "stale", OLD, OLD);
db.prepare(
"INSERT INTO agents (id, session_id, name, status, started_at) VALUES ('old-stale-a', 'old-stale', 'main', 'working', ?)"
).run(OLD);
db.prepare(
"INSERT INTO events (session_id, event_type, created_at) VALUES ('old-stale', 'PreToolUse', ?)"
).run(OLD);
const body = { abandon_hours: 1, purge_days: 30 };
const dry = await post("/api/settings/cleanup", { ...body, dry_run: true });
assert.equal(dry.status, 200);
const row = db.prepare("SELECT status FROM sessions WHERE id = 'old-stale'").get();
assert.equal(row.status, "active");
const real = await post("/api/settings/cleanup", body);
assert.equal(real.status, 200);
assert.equal(real.body.purged_sessions, 1);
for (const k of ["abandoned", "purged_sessions", "purged_events", "purged_agents"]) {
assert.equal(dry.body[k], real.body[k], k);
}
});
});
+56 -13
View File
@@ -357,18 +357,35 @@ homeRoutes("/claude-home", "CLAUDE_HOME", "claude_home", getClaudeHome, setClaud
// into the current home at its spawn, so a move needs no repair here.
homeRoutes("/codex-home", "CODEX_HOME", "codex_home", getCodexHome, setCodexHome);
// POST /api/settings/cleanup — abandon stale sessions, purge old data
// POST /api/settings/cleanup — abandon stale sessions, purge old data.
// `purge_days` is a whole number ≥ 1 (0.1 days would purge almost everything);
// `dry_run: true` answers the same counts and changes nothing.
const PURGE_CHUNK = 500;
router.post("/cleanup", (req, res) => {
const { abandon_hours, purge_days } = req.body;
const dryRun = req.body.dry_run === true;
if (purge_days !== undefined && purge_days !== null) {
if (!Number.isInteger(purge_days) || purge_days < 1) {
return res.status(400).json({
error: {
code: "INVALID_PURGE_DAYS",
message: "purge_days must be a whole number of days, 1 or more",
},
});
}
}
const result = { abandoned: 0, purged_sessions: 0, purged_events: 0, purged_agents: 0 };
if (dryRun) result.dry_run = true;
// A dry run abandons nothing, so the purge below would not see the sessions a
// real run abandons first and then purges; it adds them back from this list.
let wouldAbandon = [];
if (abandon_hours && typeof abandon_hours === "number" && abandon_hours > 0) {
// Mark active sessions with no recent events as abandoned
const cutoff = new Date(Date.now() - abandon_hours * 3600 * 1000).toISOString();
const stale = db
.prepare(
`SELECT s.id FROM sessions s
`SELECT s.id, s.started_at FROM sessions s
WHERE s.status = 'active'
AND s.started_at < ?
AND NOT EXISTS (
@@ -380,17 +397,22 @@ router.post("/cleanup", (req, res) => {
const abandon = db.prepare(
"UPDATE sessions SET status = 'abandoned', ended_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?"
);
// Also complete any lingering agents
const complete = db.prepare(
"UPDATE agents SET status = 'completed', ended_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE session_id = ? AND status IN ('waiting','working')"
);
db.transaction(() => {
for (const row of stale) {
abandon.run(row.id);
complete.run(row.id);
}
})();
result.abandoned = stale.length;
if (dryRun) {
result.abandoned = stale.length;
wouldAbandon = stale;
} else {
// Also complete any lingering agents
const complete = db.prepare(
"UPDATE agents SET status = 'completed', ended_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE session_id = ? AND status IN ('waiting','working')"
);
db.transaction(() => {
for (const row of stale) {
abandon.run(row.id);
complete.run(row.id);
}
})();
result.abandoned = stale.length;
}
}
if (purge_days && typeof purge_days === "number" && purge_days > 0) {
@@ -402,6 +424,27 @@ router.post("/cleanup", (req, res) => {
)
.all(cutoff);
if (dryRun) {
// Exactly the ids the real run deletes: those already purgeable, plus
// the stale ones it abandons first that are also older than the cutoff.
const ids = [
...toDelete.map((r) => r.id),
...wouldAbandon.filter((r) => r.started_at < cutoff).map((r) => r.id),
];
for (let i = 0; i < ids.length; i += PURGE_CHUNK) {
const chunk = ids.slice(i, i + PURGE_CHUNK);
const placeholders = chunk.map(() => "?").join(",");
result.purged_events += db
.prepare(`SELECT COUNT(*) AS n FROM events WHERE session_id IN (${placeholders})`)
.get(...chunk).n;
result.purged_agents += db
.prepare(`SELECT COUNT(*) AS n FROM agents WHERE session_id IN (${placeholders})`)
.get(...chunk).n;
}
result.purged_sessions = ids.length;
return res.json({ ok: true, ...result });
}
// One transaction, in chunks: a single IN (...) list of every id would
// pass SQLite's bound-variable limit on a large history.
db.transaction(() => {