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, BellOff,
Check, Check,
CheckCheck, CheckCheck,
ChevronDown,
ListChecks, ListChecks,
Pencil,
Plus, Plus,
RefreshCw, RefreshCw,
Trash2, Trash2,
@@ -88,6 +88,7 @@ import { Checkbox } from "./Checkbox";
import { FieldHelp } from "./FieldHelp"; import { FieldHelp } from "./FieldHelp";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "./ContextMenu"; import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "./ContextMenu";
import { timeAgo } from "../lib/format"; import { timeAgo } from "../lib/format";
import { toast } from "../lib/toast";
import type { AlertEvent, AlertRule, AlertRuleType, WSMessage } from "../lib/types"; import type { AlertEvent, AlertRule, AlertRuleType, WSMessage } from "../lib/types";
const PAGE_SIZE = 25; const PAGE_SIZE = 25;
@@ -155,6 +156,27 @@ const EMPTY_FORM: RuleFormState = {
cooldown_seconds: "300", 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"] { function buildConfig(form: RuleFormState): AlertRule["config"] {
switch (form.rule_type) { switch (form.rule_type) {
case "event_pattern": { case "event_pattern": {
@@ -217,6 +239,8 @@ export function AlertsNotifications() {
const [loadingRules, setLoadingRules] = useState(true); const [loadingRules, setLoadingRules] = useState(true);
const [formOpen, setFormOpen] = useState(false); const [formOpen, setFormOpen] = useState(false);
const [form, setForm] = useState<RuleFormState>(EMPTY_FORM); 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 [formError, setFormError] = useState<string | null>(null);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [confirmRule, setConfirmRule] = useState<AlertRule | null>(null); const [confirmRule, setConfirmRule] = useState<AlertRule | null>(null);
@@ -241,11 +265,11 @@ export function AlertsNotifications() {
const res = await api.alerts.rules.list(); const res = await api.alerts.rules.list();
setRules(res.rules); setRules(res.rules);
} catch (err) { } catch (err) {
console.error("Failed to load alert rules:", err); toast.error(t("errors.loadRules", { error: errText(err) }));
} finally { } finally {
setLoadingRules(false); setLoadingRules(false);
} }
}, []); }, [t]);
const loadAlerts = useCallback(async () => { const loadAlerts = useCallback(async () => {
setLoadingAlerts(true); setLoadingAlerts(true);
@@ -259,11 +283,11 @@ export function AlertsNotifications() {
setTotal(res.total); setTotal(res.total);
setUnacked(res.unacked); setUnacked(res.unacked);
} catch (err) { } catch (err) {
console.error("Failed to load alerts:", err); toast.error(t("errors.loadAlerts", { error: errText(err) }));
} finally { } finally {
setLoadingAlerts(false); setLoadingAlerts(false);
} }
}, [unackedOnly]); }, [unackedOnly, t]);
const loadMore = useCallback(async () => { const loadMore = useCallback(async () => {
try { try {
@@ -276,9 +300,9 @@ export function AlertsNotifications() {
setTotal(res.total); setTotal(res.total);
setUnacked(res.unacked); setUnacked(res.unacked);
} catch (err) { } 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(() => { useEffect(() => {
loadRules(); loadRules();
@@ -300,20 +324,33 @@ export function AlertsNotifications() {
const set = (patch: Partial<RuleFormState>) => setForm((prev) => ({ ...prev, ...patch })); const set = (patch: Partial<RuleFormState>) => setForm((prev) => ({ ...prev, ...patch }));
const onCreateRule = async () => { const closeForm = () => {
if (saving) return; 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); setSaving(true);
setFormError(null); setFormError(null);
try { try {
const cooldown = parseInt(form.cooldown_seconds, 10); const body = {
await api.alerts.rules.create({
name: form.name.trim(), name: form.name.trim(),
rule_type: form.rule_type,
config: buildConfig(form), config: buildConfig(form),
cooldown_seconds: Number.isFinite(cooldown) && cooldown >= 0 ? cooldown : 300, cooldown_seconds: cooldownVal,
}); };
setForm(EMPTY_FORM); if (editingId) await api.alerts.rules.update(editingId, body);
setFormOpen(false); else await api.alerts.rules.create({ ...body, rule_type: form.rule_type });
closeForm();
loadRules(); loadRules();
} catch (err) { } catch (err) {
setFormError(err instanceof Error ? err.message : String(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 }); await api.alerts.rules.update(rule.id, { enabled: !rule.enabled });
loadRules(); loadRules();
} catch (err) { } 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(); loadRules();
loadAlerts(); loadAlerts();
} catch (err) { } 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); await api.alerts.ack(id);
loadAlerts(); loadAlerts();
} catch (err) { } 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(); await api.alerts.ackAll();
loadAlerts(); loadAlerts();
} catch (err) { } 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 tokensVal = parseInt(form.total_tokens, 10);
const countVal = parseInt(form.count, 10); const countVal = parseInt(form.count, 10);
const windowVal = parseFloat(form.window_minutes); 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 = const canSubmit =
form.name.trim().length > 0 && form.name.trim().length > 0 &&
cooldownOk &&
(form.rule_type !== "event_pattern" || (form.rule_type !== "event_pattern" ||
(Boolean(form.event_type.trim() || form.tool_name.trim() || form.summary_contains.trim()) && (Boolean(form.event_type.trim() || form.tool_name.trim() || form.summary_contains.trim()) &&
Number.isFinite(countVal) && Number.isFinite(countVal) &&
@@ -440,8 +482,11 @@ export function AlertsNotifications() {
</div> </div>
<button <button
onClick={() => { onClick={() => {
setFormOpen((open) => !open); if (formOpen) closeForm();
setFormError(null); else {
setFormOpen(true);
setFormError(null);
}
}} }}
className="btn-ghost border border-border inline-flex items-center gap-1.5 text-xs flex-shrink-0" 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 <select
value={form.rule_type} value={form.rule_type}
onChange={(e) => set({ rule_type: e.target.value as AlertRuleType })} 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) => ( {RULE_TYPES.map((type) => (
<option key={type} value={type}> <option key={type} value={type}>
@@ -483,7 +530,6 @@ export function AlertsNotifications() {
</option> </option>
))} ))}
</select> </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> </div>
</label> </label>
</div> </div>
@@ -586,12 +632,11 @@ export function AlertsNotifications() {
<select <select
value={form.status} value={form.status}
onChange={(e) => set({ status: e.target.value as "working" | "waiting" })} 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="working">working</option>
<option value="waiting">waiting</option> <option value="waiting">waiting</option>
</select> </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> </div>
</label> </label>
)} )}
@@ -644,18 +689,25 @@ export function AlertsNotifications() {
<input <input
type="number" type="number"
min={0} min={0}
step={1}
value={form.cooldown_seconds} value={form.cooldown_seconds}
onChange={(e) => set({ cooldown_seconds: e.target.value })} onChange={(e) => set({ cooldown_seconds: e.target.value })}
aria-invalid={!cooldownOk}
className="input w-40" className="input w-40"
/> />
{!cooldownOk && (
<span className="mt-1 block text-[11px] text-status-danger">
{t("rules.cooldownInvalid")}
</span>
)}
</label> </label>
<button <button
onClick={onCreateRule} onClick={onSaveRule}
disabled={!canSubmit || saving} disabled={!canSubmit || saving}
className="btn-primary inline-flex items-center gap-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed" 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" /> {editingId ? <Check className="w-3.5 h-3.5" /> : <Plus className="w-3.5 h-3.5" />}
{saving ? t("rules.saving") : t("rules.create")} {saving ? t("rules.saving") : editingId ? t("rules.save") : t("rules.create")}
</button> </button>
</div> </div>
{formError && <p className="text-xs text-status-danger">{formError}</p>} {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")} {rule.enabled ? t("rules.enabled") : t("rules.disabled")}
</button> </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 <button
onClick={() => setConfirmRule(rule)} onClick={() => setConfirmRule(rule)}
className="p-1.5 rounded-md text-fg-muted hover:text-status-danger hover:bg-status-danger/10 transition-colors" 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)}> <MenuItem onSelect={pickRule(onToggleRule)}>
{ruleCtx.menu.item.enabled ? t("rules.disable") : t("rules.enable")} {ruleCtx.menu.item.enabled ? t("rules.disable") : t("rules.enable")}
</MenuItem> </MenuItem>
<MenuItem onSelect={pickRule(openEditRule)}>{t("rules.edit")}</MenuItem>
<MenuSeparator /> <MenuSeparator />
<MenuItem danger onSelect={pickRule(setConfirmRule)}> <MenuItem danger onSelect={pickRule(setConfirmRule)}>
{t("rules.delete")} {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" 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"> <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")} {t("import.instructions")}
</span> </span>
<span className="text-[11px] text-fg-muted">{instructionsOpen ? "▾" : "▸"}</span> <span className="text-[11px] text-fg-muted">{instructionsOpen ? "▾" : "▸"}</span>
@@ -455,8 +455,8 @@ export function ImportHistory() {
{mode === "upload" && ( {mode === "upload" && (
<div className="space-y-3"> <div className="space-y-3">
<div <label
onClick={() => fileInputRef.current?.click()} htmlFor="import-upload-files"
onDragOver={(e) => { onDragOver={(e) => {
e.preventDefault(); e.preventDefault();
setDragging(true); setDragging(true);
@@ -467,9 +467,9 @@ export function ImportHistory() {
setDragging(false); setDragging(false);
onSelectFiles(e.dataTransfer.files); 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 dragging
? "border-blue-500 bg-blue-600/5" ? "border-accent bg-accent/5"
: "border-border hover:border-border-light bg-surface-1" : "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-sm text-fg-secondary">{t("import.dropzoneHint")}</p>
<p className="text-[11px] text-fg-muted mt-1">{t("import.dropzoneSub")}</p> <p className="text-[11px] text-fg-muted mt-1">{t("import.dropzoneSub")}</p>
<input <input
id="import-upload-files"
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
multiple multiple
accept=".jsonl,.json,.zip,.tar,.tgz,.gz,application/gzip,application/zip,application/x-tar,application/octet-stream" accept=".jsonl,.json,.zip,.tar,.tgz,.gz,application/gzip,application/zip,application/x-tar,application/octet-stream"
onChange={(e) => onSelectFiles(e.target.files)} onChange={(e) => onSelectFiles(e.target.files)}
className="hidden" className="sr-only"
/> />
</div> </label>
{files.length > 0 && ( {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"> <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"> <span className="text-fg-secondary">
@@ -522,8 +523,8 @@ export function ImportHistory() {
{mode === "backup" && ( {mode === "backup" && (
<div className="space-y-3"> <div className="space-y-3">
<div <label
onClick={() => backupInputRef.current?.click()} htmlFor="import-backup-file"
onDragOver={(e) => { onDragOver={(e) => {
e.preventDefault(); e.preventDefault();
setDragging(true); setDragging(true);
@@ -535,9 +536,9 @@ export function ImportHistory() {
const f = e.dataTransfer.files?.[0]; const f = e.dataTransfer.files?.[0];
if (f) setBackupFile(f); 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 dragging
? "border-blue-500 bg-blue-600/5" ? "border-accent bg-accent/5"
: "border-border hover:border-border-light bg-surface-1" : "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-sm text-fg-secondary">{t("import.backupHint")}</p>
<p className="text-[11px] text-fg-muted mt-1">{t("import.backupSub")}</p> <p className="text-[11px] text-fg-muted mt-1">{t("import.backupSub")}</p>
<input <input
id="import-backup-file"
ref={backupInputRef} ref={backupInputRef}
type="file" type="file"
accept=".json,application/json" accept=".json,application/json"
onChange={(e) => setBackupFile(e.target.files?.[0] || null)} onChange={(e) => setBackupFile(e.target.files?.[0] || null)}
className="hidden" className="sr-only"
/> />
</div> </label>
{backupFile && ( {backupFile && (
<div className="flex flex-wrap items-center justify-between gap-2 text-xs bg-surface-3 rounded-md px-3 py-2"> <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"> <span className="text-fg-secondary min-w-0">
@@ -591,7 +593,7 @@ export function ImportHistory() {
{/* In-flight progress */} {/* In-flight progress */}
{running && progressText && ( {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"> <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> <span className="truncate">{progressText}</span>
{progress?.current && ( {progress?.current && (
<code className="font-mono text-[11px] text-fg-muted truncate"> <code className="font-mono text-[11px] text-fg-muted truncate">
@@ -625,7 +627,7 @@ export function ImportHistory() {
<ResultStat <ResultStat
label={t("import.result.backfilled", { count: result.backfilled ?? 0 })} label={t("import.result.backfilled", { count: result.backfilled ?? 0 })}
value={result.backfilled ?? 0} value={result.backfilled ?? 0}
color="text-blue-700 dark:text-blue-400" color="text-accent"
/> />
<ResultStat <ResultStat
label={t("import.result.skipped", { count: result.skipped })} label={t("import.result.skipped", { count: result.skipped })}
@@ -672,7 +674,7 @@ export function ImportHistory() {
<ResultStat <ResultStat
label={t("import.backupResult.events", { count: backupResult.events })} label={t("import.backupResult.events", { count: backupResult.events })}
value={backupResult.events} value={backupResult.events}
color="text-violet-700 dark:text-violet-300" color="text-fg-primary"
/> />
<ResultStat <ResultStat
label={t("import.backupResult.pricing", { count: backupResult.model_pricing })} label={t("import.backupResult.pricing", { count: backupResult.model_pricing })}
@@ -728,16 +730,16 @@ function ModeButton({
}) { }) {
return ( return (
<button <button
type="button"
aria-pressed={active}
onClick={onClick} onClick={onClick}
className={`text-left p-3 rounded-lg border transition-colors ${ className={`text-left p-3 rounded-lg border transition-colors ${
active active ? "border-accent/40 bg-accent/10" : "border-border bg-surface-2 hover:bg-surface-3"
? "border-blue-600/40 bg-blue-600/10"
: "border-border bg-surface-2 hover:bg-surface-3"
}`} }`}
> >
<div <div
className={`flex items-center gap-1.5 text-xs font-medium mb-1 ${ 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} {icon}
+37 -8
View File
@@ -82,10 +82,14 @@ import {
import { api } from "../lib/api"; import { api } from "../lib/api";
import type { RemoteSource, RemoteSourceInput } from "../lib/api"; import type { RemoteSource, RemoteSourceInput } from "../lib/api";
import { eventBus } from "../lib/eventBus"; import { eventBus } from "../lib/eventBus";
import { toast } from "../lib/toast";
import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents"; import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
import { useDataScope } from "../lib/dataScope"; import { useDataScope } from "../lib/dataScope";
import type { ScopeMode } 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 = { const EMPTY_FORM: RemoteSourceInput = {
label: "", label: "",
host: "", host: "",
@@ -258,8 +262,8 @@ export function RemoteSources() {
try { try {
await api.remoteSources.update(s.id, { enabled: !s.enabled }); await api.remoteSources.update(s.id, { enabled: !s.enabled });
load(); load();
} catch { } catch (err) {
/* surfaced via reload */ toast.error(err);
} finally { } finally {
setBusyId(null); setBusyId(null);
} }
@@ -301,8 +305,10 @@ export function RemoteSources() {
try { try {
await api.remoteSources.syncAll(); await api.remoteSources.syncAll();
load(); load();
} catch { } catch (err) {
/* per-source errors surface via each source's status on reload */ // Per-source errors still surface on each source's status after the reload.
toast.error(err);
load();
} finally { } finally {
setSyncingAll(false); setSyncingAll(false);
} }
@@ -316,8 +322,9 @@ export function RemoteSources() {
await api.remoteSources.remove(id, purge); await api.remoteSources.remove(id, purge);
setConfirmDelete(null); setConfirmDelete(null);
load(); load();
} catch { } catch (err) {
/* surfaced via reload */ setConfirmDelete(null);
toast.error(err);
} finally { } finally {
setBusyId(null); setBusyId(null);
} }
@@ -358,7 +365,26 @@ export function RemoteSources() {
</p> </p>
{/* Card selector — one card per scope mode, each with a short explanation {/* Card selector — one card per scope mode, each with a short explanation
so the choice is self-describing rather than a bare radio label. */} 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 ( return (
<button <button
key={mode} key={mode}
type="button"
role="radio" role="radio"
aria-checked={active} aria-checked={active}
tabIndex={active ? 0 : -1}
data-mode={mode}
onClick={() => setMode(mode)} onClick={() => setMode(mode)}
className={`relative text-left rounded-xl border p-3 transition-colors ${ className={`relative text-left rounded-xl border p-3 transition-colors ${
active active
@@ -492,7 +521,7 @@ export function RemoteSources() {
</label> </label>
<input <input
className="input w-full" className="input w-full"
placeholder="Dev box" placeholder={t("remoteSources.fieldLabelPlaceholder", "Dev box")}
value={form.label} value={form.label}
onChange={(e) => setForm((f) => ({ ...f, label: e.target.value }))} onChange={(e) => setForm((f) => ({ ...f, label: e.target.value }))}
/> />
+2
View File
@@ -28,6 +28,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { PET_SIZE, sagiPrefs, type PetChoice } from "./prefs"; import { PET_SIZE, sagiPrefs, type PetChoice } from "./prefs";
import { usePets } from "./usePets"; import { usePets } from "./usePets";
import { toast } from "../../lib/toast";
const ITEM = 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"; "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} className={ITEM}
onClick={() => { onClick={() => {
sagiPrefs.setEnabled(false); sagiPrefs.setEnabled(false);
toast.info(t("sagi.pet.hiddenToast"));
onClose(); onClose();
}} }}
> >
+33 -12
View File
@@ -8,8 +8,11 @@
* useSagiPosition, and the bubble/panel render in a self-clamping flyout so * 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. * 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 * The "do the job" path: an unmatched Ask query starts a claude session in
* deep-link to /run?prompt=…&autostart=1 - no new LLM backend. * 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> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
/* ============================================================================= /* =============================================================================
@@ -70,6 +73,7 @@ import {
type ReactNode, type ReactNode,
} from "react"; } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { SagiAvatar } from "./SagiAvatar"; import { SagiAvatar } from "./SagiAvatar";
import { PetSprite } from "./PetSprite"; import { PetSprite } from "./PetSprite";
import { PetMenu } from "./PetMenu"; import { PetMenu } from "./PetMenu";
@@ -85,6 +89,17 @@ import "./sagi.css";
const FLYOUT_GAP = 10; // px between avatar and flyout const FLYOUT_GAP = 10; // px between avatar and flyout
const VIEWPORT_MARGIN = 12; // min gap from any screen edge 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 { interface Anchor {
left: number; left: number;
top: number; top: number;
@@ -94,7 +109,8 @@ interface Anchor {
dragging: boolean; dragging: boolean;
} }
function usePrefersReducedMotion(): boolean { /** The OS "reduce motion" preference, followed live. */
export function usePrefersReducedMotion(): boolean {
const [reduced, setReduced] = useState( const [reduced, setReduced] = useState(
() => () =>
typeof window !== "undefined" && typeof window !== "undefined" &&
@@ -180,6 +196,7 @@ function SagiFlyout({
} }
export function Sagi() { export function Sagi() {
const { t } = useTranslation("settings");
const [enabled, setEnabled] = useState(() => sagiPrefs.getEnabled()); const [enabled, setEnabled] = useState(() => sagiPrefs.getEnabled());
const [pet, setPet] = useState(() => sagiPrefs.getPet()); const [pet, setPet] = useState(() => sagiPrefs.getPet());
const [size, setSize] = useState(() => sagiPrefs.getSize()); 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(() => { useEffect(() => {
if (!enabled) return;
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "b") { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "b") {
if (isEditable(e.target) || isEditable(document.activeElement)) return;
e.preventDefault(); e.preventDefault();
setOpen((v) => !v); setOpen((v) => !v);
} else if (e.key === "Escape") { } else if (e.key === "Escape") {
@@ -214,7 +234,7 @@ export function Sagi() {
}; };
window.addEventListener("keydown", onKey); window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey);
}, []); }, [enabled]);
const onNavigate = useCallback( const onNavigate = useCallback(
(route: string) => { (route: string) => {
@@ -230,7 +250,7 @@ export function Sagi() {
if (result.kind === "answer") return result.text; if (result.kind === "answer") return result.text;
// Handoff: ensure the "Sagi" lane exists for this dashboard, spawn a // Handoff: ensure the "Sagi" lane exists for this dashboard, spawn a
// fresh claude session in it carrying the user's prompt as its first // 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 // Fire-and-forget — the panel already clears its composer on null and
// closes; errors surface as an answer instead of silently swallowing. // closes; errors surface as an answer instead of silently swallowing.
const prompt = result.prompt; const prompt = result.prompt;
@@ -238,24 +258,24 @@ export function Sagi() {
(async () => { (async () => {
try { try {
const { lane } = await api.lanes.ensure({ title: "Sagi" }); const { lane } = await api.lanes.ensure({ title: "Sagi" });
await api.run.start({ const run = await api.run.start({
laneId: lane.id, laneId: lane.id,
cwd: lane.cwd, cwd: lane.cwd,
initialPrompt: prompt, initialPrompt: prompt,
permissionMode: "acceptEdits", permissionMode: "acceptEdits",
}); });
navigate(`/run?laneId=${lane.id}&autostart=1`); navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(run.id)}`);
setOpen(false); setOpen(false);
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : String(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 { } finally {
brain.setThinking(false); brain.setThinking(false);
} }
})(); })();
return null; return null;
}, },
[brain, navigate] [brain, navigate, t]
); );
if (!enabled) return null; if (!enabled) return null;
@@ -314,6 +334,7 @@ export function Sagi() {
onPointerDown={place.onPointerDown} onPointerDown={place.onPointerDown}
onPointerMove={place.onPointerMove} onPointerMove={place.onPointerMove}
onPointerUp={place.onPointerUp} onPointerUp={place.onPointerUp}
onKeyDown={place.onKeyDown}
onClick={() => { onClick={() => {
// A drag just ended - swallow the synthetic click so the panel // A drag just ended - swallow the synthetic click so the panel
// doesn't toggle when the user only repositioned the avatar. // doesn't toggle when the user only repositioned the avatar.
@@ -326,9 +347,9 @@ export function Sagi() {
setOpen(false); setOpen(false);
setMenu(true); setMenu(true);
}} }}
aria-label={open ? "Close Sagi" : "Open Sagi companion"} aria-label={open ? t("sagi.closeAvatar") : t("sagi.open")}
aria-expanded={open} aria-expanded={open}
title="Sagi - ⌘B · drag to move · right-click for pets" title={t("sagi.avatarTitle")}
> >
{pet.id === "sagi" || !("kind" in pet) ? ( {pet.id === "sagi" || !("kind" in pet) ? (
<SagiAvatar mood={brain.mood} reducedMotion={reducedMotion} size={size} /> <SagiAvatar mood={brain.mood} reducedMotion={reducedMotion} size={size} />
+27 -17
View File
@@ -63,6 +63,7 @@ import {
Radio, Radio,
type LucideIcon, type LucideIcon,
} from "lucide-react"; } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { SagiStatus } from "./brain"; import type { SagiStatus } from "./brain";
interface SagiPanelProps { interface SagiPanelProps {
@@ -85,6 +86,7 @@ export function SagiPanel({
onAsk, onAsk,
onClose, onClose,
}: SagiPanelProps) { }: SagiPanelProps) {
const { t } = useTranslation("settings");
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [answer, setAnswer] = useState<string | null>(null); const [answer, setAnswer] = useState<string | null>(null);
@@ -97,9 +99,9 @@ export function SagiPanel({
return ( return (
<div <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" role="dialog"
aria-label="Sagi companion" aria-label={t("sagi.panel.label")}
> >
{/* header */} {/* 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"> <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 aria-hidden
/> />
{status.connected ? "Live" : "Offline"} {status.connected ? t("sagi.panel.live") : t("sagi.panel.offline")}
</span> </span>
</div> </div>
<button <button
className="rounded-md p-1 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-secondary" className="rounded-md p-1 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-secondary"
onClick={onClose} onClick={onClose}
aria-label="Close Sagi" aria-label={t("sagi.panel.close")}
> >
<X size={15} /> <X size={15} />
</button> </button>
@@ -138,19 +140,19 @@ export function SagiPanel({
<div className="mb-3 grid grid-cols-3 gap-1.5"> <div className="mb-3 grid grid-cols-3 gap-1.5">
<StatChip <StatChip
icon={Radio} icon={Radio}
label="live" label={t("sagi.panel.statLive")}
value={status.liveCount} value={status.liveCount}
tone={status.liveCount > 0 ? "accent" : "muted"} tone={status.liveCount > 0 ? "accent" : "muted"}
/> />
<StatChip <StatChip
icon={Hourglass} icon={Hourglass}
label="waiting" label={t("sagi.panel.statWaiting")}
value={status.waitingCount} value={status.waitingCount}
tone={status.waitingCount > 0 ? "amber" : "muted"} tone={status.waitingCount > 0 ? "amber" : "muted"}
/> />
<StatChip <StatChip
icon={AlertTriangle} icon={AlertTriangle}
label="errored" label={t("sagi.panel.statErrored")}
value={status.errorCount} value={status.errorCount}
tone={status.errorCount > 0 ? "red" : "muted"} tone={status.errorCount > 0 ? "red" : "muted"}
/> />
@@ -158,27 +160,35 @@ export function SagiPanel({
{/* quick actions */} {/* quick actions */}
<div className="mb-3 grid grid-cols-2 gap-1.5"> <div className="mb-3 grid grid-cols-2 gap-1.5">
<ActionButton icon={Play} label="Run Claude" onClick={() => onNavigate("/workspace")} /> <ActionButton
<ActionButton icon={Activity} label="Activity" onClick={() => onNavigate("/activity")} /> icon={Play}
label={t("sagi.panel.runClaude")}
onClick={() => onNavigate("/workspace")}
/>
<ActionButton
icon={Activity}
label={t("sagi.panel.activity")}
onClick={() => onNavigate("/activity")}
/>
<ActionButton <ActionButton
icon={LayoutList} icon={LayoutList}
label="Sessions" label={t("sagi.panel.sessions")}
onClick={() => onNavigate("/sessions")} onClick={() => onNavigate("/sessions")}
/> />
<ActionButton <ActionButton
icon={AlertTriangle} icon={AlertTriangle}
label="Errored" label={t("sagi.panel.errored")}
disabled={status.errorCount === 0} disabled={status.errorCount === 0}
onClick={() => onNavigate("/sessions")} onClick={() => onNavigate("/sessions")}
/> />
<ActionButton <ActionButton
icon={muted ? BellOff : Bell} icon={muted ? BellOff : Bell}
label={muted ? "Unmute" : "Mute"} label={muted ? t("sagi.panel.unmute") : t("sagi.panel.mute")}
onClick={onToggleMute} onClick={onToggleMute}
/> />
<ActionButton <ActionButton
icon={Trash2} icon={Trash2}
label="Clear alerts" label={t("sagi.panel.clearAlerts")}
disabled={status.errorCount === 0} disabled={status.errorCount === 0}
onClick={onClearAlerts} onClick={onClearAlerts}
/> />
@@ -188,15 +198,15 @@ export function SagiPanel({
<form onSubmit={submit} className="flex items-center gap-1.5"> <form onSubmit={submit} className="flex items-center gap-1.5">
<input <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" 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} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
aria-label="Ask Sagi" aria-label={t("sagi.panel.ask")}
/> />
<button <button
type="submit" 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" className="btn-primary justify-center px-2.5 py-2"
aria-label="Send" aria-label={t("sagi.panel.send")}
> >
<Send size={14} /> <Send size={14} />
</button> </button>
+4 -1
View File
@@ -51,6 +51,8 @@
* *
* ----------------------------------------------------------------------------- */ * ----------------------------------------------------------------------------- */
import { useTranslation } from "react-i18next";
/** Props for {@link SpeechBubble}. */ /** Props for {@link SpeechBubble}. */
interface SpeechBubbleProps { interface SpeechBubbleProps {
/** Quip text to display inside the bubble. */ /** Quip text to display inside the bubble. */
@@ -64,13 +66,14 @@ interface SpeechBubbleProps {
* @param props See {@link SpeechBubbleProps}. * @param props See {@link SpeechBubbleProps}.
*/ */
export function SpeechBubble({ text, onDismiss }: SpeechBubbleProps) { export function SpeechBubble({ text, onDismiss }: SpeechBubbleProps) {
const { t } = useTranslation("settings");
return ( return (
<div <div
className="sagi-bubble sagi-bubble-enter" className="sagi-bubble sagi-bubble-enter"
role="status" role="status"
aria-live="polite" aria-live="polite"
onClick={onDismiss} onClick={onDismiss}
title="Dismiss" title={t("sagi.bubbleDismiss")}
> >
{text} {text}
</div> </div>
@@ -63,6 +63,16 @@ import { PetSprite } from "../PetSprite";
import { PetMenu } from "../PetMenu"; import { PetMenu } from "../PetMenu";
import { PetSettings } from "../../settings/PetSettings"; import { PetSettings } from "../../settings/PetSettings";
import { sagiPrefs } from "../prefs"; 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", () => { describe("PetSprite", () => {
beforeEach(() => vi.useFakeTimers()); beforeEach(() => vi.useFakeTimers());
@@ -144,8 +154,13 @@ describe("PetMenu", () => {
renderMenu(); renderMenu();
fireEvent.change(screen.getByTestId("pet-size"), { target: { value: "250" } }); fireEvent.change(screen.getByTestId("pet-size"), { target: { value: "250" } });
expect(sagiPrefs.getSize()).toBe(250); expect(sagiPrefs.getSize()).toBe(250);
let toasts: ToastItem[] = [];
const off = subscribeToasts((i) => (toasts = i));
fireEvent.click(screen.getByText("Hide pet")); fireEvent.click(screen.getByText("Hide pet"));
expect(sagiPrefs.getEnabled()).toBe(false); 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 () => { it("downloads a featured pet on its first pick, then wears it", async () => {
@@ -161,6 +176,9 @@ describe("PetMenu", () => {
renderMenu(); renderMenu();
fireEvent.click(screen.getByTestId("pet-choose")); fireEvent.click(screen.getByTestId("pet-choose"));
fireEvent.click(await screen.findByTestId("pet-delete-p-aaaaaaaaaaaa")); 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" })); await waitFor(() => expect(sagiPrefs.getPet()).toEqual({ id: "sagi" }));
expect(api.pets.remove).toHaveBeenCalledWith("p-aaaaaaaaaaaa"); expect(api.pets.remove).toHaveBeenCalledWith("p-aaaaaaaaaaaa");
}); });
@@ -207,6 +225,7 @@ describe("PetSettings", () => {
fireEvent.click(screen.getByTestId("pet-card-own-p-aaaaaaaaaaaa")); fireEvent.click(screen.getByTestId("pet-card-own-p-aaaaaaaaaaaa"));
expect(sagiPrefs.getPet()).toEqual({ id: "p-aaaaaaaaaaaa", kind: "image" }); expect(sagiPrefs.getPet()).toEqual({ id: "p-aaaaaaaaaaaa", kind: "image" });
fireEvent.click(screen.getByTestId("pet-card-own-p-aaaaaaaaaaaa-delete")); fireEvent.click(screen.getByTestId("pet-card-own-p-aaaaaaaaaaaa-delete"));
answerConfirm(true);
await waitFor(() => expect(sagiPrefs.getPet()).toEqual({ id: "sagi" })); await waitFor(() => expect(sagiPrefs.getPet()).toEqual({ id: "sagi" }));
expect(api.pets.remove).toHaveBeenCalledWith("p-aaaaaaaaaaaa"); expect(api.pets.remove).toHaveBeenCalledWith("p-aaaaaaaaaaaa");
}); });
@@ -219,4 +238,27 @@ describe("PetSettings", () => {
expect(sagiPrefs.getPet()).toEqual({ id: "p-bbbbbbbbbbbb", kind: "image" }) 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). // Left-docked → inline left equals the edge margin (16px).
expect(btn.style.left).toBe("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`): * right-click menu (`PetMenu.tsx`) and Settings → Pet (`settings/PetSettings`):
* `GET /api/pets`, wearing a pet (a viewer preference, `prefs.ts`), installing * `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 * 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 * `.codex-pet.zip` (worn at once), and deleting your own after a confirm
* when it was the one worn). Plus `useWornPet`, the live worn pet / size / * (Sagi comes back when it was the one worn). Plus `useWornPet`, the live worn pet / size /
* shown / speech-bubble prefs. * shown / speech-bubble prefs.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { api, type PetList, type PetView } from "../../lib/api"; import { api, type PetList, type PetView } from "../../lib/api";
import { confirmDialog } from "../../lib/confirm";
import i18n from "../../i18n";
import { sagiPrefs, type PetChoice } from "./prefs"; import { sagiPrefs, type PetChoice } from "./prefs";
const message = (err: unknown) => (err instanceof Error ? err.message : String(err)); const message = (err: unknown) => (err instanceof Error ? err.message : String(err));
@@ -57,12 +59,20 @@ export function usePets() {
wear(pet); wear(pet);
await reload(); await reload();
}); });
const remove = (p: PetView) => // One place both callers route through, so neither can delete on one click.
run(p.id, async () => { 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); await api.pets.remove(p.id);
if (sagiPrefs.getPet().id === p.id) sagiPrefs.setPet({ id: "sagi" }); if (sagiPrefs.getPet().id === p.id) sagiPrefs.setPet({ id: "sagi" });
await reload(); await reload();
}); });
};
const own = list?.pets.filter((p) => p.source === "upload") ?? []; const own = list?.pets.filter((p) => p.source === "upload") ?? [];
// Featured id → the installed copy, once downloaded. // 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 * 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 * nearest left/right edge, remembering its vertical offset (persisted as a
* viewport fraction so it survives resizes). A small movement threshold tells * 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> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
/* ============================================================================= /* =============================================================================
@@ -70,23 +73,28 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { sagiPrefs, type SagiPos } from "./prefs"; 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. // Avatar footprint + edge gap, in px. SIZE matches SagiAvatar's default size.
export const SAGI_SIZE = 68; export const SAGI_SIZE = 68;
export const SAGI_MARGIN = 16; export const SAGI_MARGIN = 16;
const DRAG_THRESHOLD = 5; 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 vw = () => (typeof window !== "undefined" ? window.innerWidth : 1024);
const vh = () => (typeof window !== "undefined" ? window.innerHeight : 768); const vh = () => (typeof window !== "undefined" ? window.innerHeight : 768);
function defaultPos(): SagiPos { 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. */ /** Resting top-left screen coords for a docked position. */
function restingScreen(pos: SagiPos, size: number) { 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 left = pos.side === "left" ? SAGI_MARGIN : vw() - size - SAGI_MARGIN;
const top = SAGI_MARGIN + pos.y * avail; const top = SAGI_MARGIN + pos.y * avail;
return { left, top }; return { left, top };
@@ -106,6 +114,8 @@ export interface SagiPlacement {
onPointerUp: (e: ReactPointerEvent) => void; onPointerUp: (e: ReactPointerEvent) => void;
/** Returns true (once) if a drag just ended, so the click handler can skip. */ /** Returns true (once) if a drag just ended, so the click handler can skip. */
consumeDrag: () => boolean; 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. */ /** `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; if (!movedRef.current && Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
movedRef.current = true; movedRef.current = true;
const left = Math.min(vw() - size - SAGI_MARGIN, Math.max(SAGI_MARGIN, start.left + dx)); 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 }; liveRef.current = { left, top };
setDrag({ left, top }); setDrag({ left, top });
}, },
@@ -174,7 +187,7 @@ export function useSagiPosition(size: number = SAGI_SIZE): SagiPlacement {
if (live) { if (live) {
draggedRef.current = true; draggedRef.current = true;
const side: "left" | "right" = live.left + size / 2 < vw() / 2 ? "left" : "right"; 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 y = Math.min(1, Math.max(0, (live.top - SAGI_MARGIN) / avail));
const next: SagiPos = { side, y }; const next: SagiPos = { side, y };
sagiPrefs.setPos(next); sagiPrefs.setPos(next);
@@ -188,6 +201,25 @@ export function useSagiPosition(size: number = SAGI_SIZE): SagiPlacement {
[size] [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 consumeDrag = useCallback(() => {
const was = draggedRef.current; const was = draggedRef.current;
draggedRef.current = false; draggedRef.current = false;
@@ -205,5 +237,6 @@ export function useSagiPosition(size: number = SAGI_SIZE): SagiPlacement {
onPointerMove, onPointerMove,
onPointerUp, onPointerUp,
consumeDrag, 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 * — 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 * keeps answering while the chain installs. New skills/slash commands still
* need `/reload-plugins` in an already-open Claude Code session; nothing a * 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 * ## State sources
* - Initial fetch via `api.updates.status()` on mount. * - 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 { useTranslation } from "react-i18next";
import { Download, X, Copy, Check, RefreshCw, Zap, ExternalLink } from "lucide-react"; import { Download, X, Copy, Check, RefreshCw, Zap, ExternalLink } from "lucide-react";
import { api } from "../lib/api"; import { api } from "../lib/api";
import { eventBus } from "../lib/eventBus"; import { eventBus } from "../lib/eventBus";
import { copyText } from "../lib/clipboard";
import { useDialog } from "../hooks/useDialog";
import type { UpdateStatusPayload, WSMessage } from "../lib/types"; import type { UpdateStatusPayload, WSMessage } from "../lib/types";
/** `localStorage` key storing the dismissed release tag (or upstream SHA). */ /** `localStorage` key storing the dismissed release tag (or upstream SHA). */
@@ -125,6 +129,10 @@ export function UpdateNotifier() {
const { t } = useTranslation("updates"); const { t } = useTranslation("updates");
const [status, setStatus] = useState<UpdateStatusPayload | null>(null); const [status, setStatus] = useState<UpdateStatusPayload | null>(null);
const [dismissedSha, setDismissedSha] = useState<string | null>(loadDismissedSha); 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 [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [checking, setChecking] = useState(false); const [checking, setChecking] = useState(false);
@@ -192,36 +200,37 @@ export function UpdateNotifier() {
status?.latest_release || status?.latest_release ||
status?.remote_sha || status?.remote_sha ||
(installed ? `installed:${installed.root}` : null); (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(() => { const dismiss = useCallback(() => {
if (restarting || !dismissKey) return; if (busy || !dismissKey) return;
try { try {
localStorage.setItem(DISMISS_KEY, dismissKey); localStorage.setItem(DISMISS_KEY, dismissKey);
} catch { } catch {
/* ignore */ /* ignore */
} }
setDismissedSha(dismissKey); setDismissedSha(dismissKey);
}, [restarting, dismissKey]); }, [busy, dismissKey]);
// Escape to dismiss - standard modal affordance. // Escape closes for now — never while an update is applying.
useEffect(() => { useDialog(show, panelRef, busy ? undefined : closeForNow);
if (!show) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") dismiss();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [show, dismiss]);
const copyCmd = async () => { const copyCmd = async () => {
if (!status?.manual_command) return; if (!status?.manual_command) return;
try { if (await copyText(status.manual_command, true)) {
await navigator.clipboard.writeText(status.manual_command);
setCopied(true); setCopied(true);
setTimeout(() => setCopied(false), 2000); setTimeout(() => setCopied(false), 2000);
} catch {
/* ignore */
} }
}; };
@@ -304,17 +313,20 @@ export function UpdateNotifier() {
return ( return (
<div <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" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby="update-notifier-title" aria-labelledby="update-notifier-title"
onClick={(e) => { 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 {/* max-w-2xl, not lg: the footer carries up to four buttons and the
longest label is a whole sentence ("restarting the dashboard…"). */} 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 */} {/* Header */}
<div className="flex items-start justify-between gap-3 px-5 py-4 border-b border-border"> <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"> <div className="flex items-center gap-3 min-w-0">
@@ -343,8 +355,9 @@ export function UpdateNotifier() {
</div> </div>
<button <button
type="button" type="button"
onClick={dismiss} onClick={closeForNow}
aria-label={t("dismiss")} 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" 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" /> <X className="w-4 h-4" />
@@ -460,10 +473,19 @@ export function UpdateNotifier() {
<button <button
type="button" type="button"
onClick={dismiss} onClick={dismiss}
disabled={restarting} disabled={busy}
title={t("skipVersionHint")}
className="btn-ghost disabled:opacity-60 disabled:cursor-not-allowed" 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> </button>
{status.manual_command ? ( {status.manual_command ? (
<button <button
+32 -17
View File
@@ -86,6 +86,7 @@ import { Checkbox } from "./Checkbox";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "./ContextMenu"; import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "./ContextMenu";
import { WEBHOOK_DOCS } from "./webhookGuides"; import { WEBHOOK_DOCS } from "./webhookGuides";
import { timeAgo } from "../lib/format"; import { timeAgo } from "../lib/format";
import { toast } from "../lib/toast";
import type { import type {
AlertRule, AlertRule,
WebhookProvider, WebhookProvider,
@@ -94,20 +95,21 @@ import type {
WebhookTestResult, WebhookTestResult,
} from "../lib/types"; } 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>> = { const TYPE_STYLES: Partial<Record<WebhookType, string>> = {
slack: "text-[#E01E5A] bg-[#E01E5A]/10 border-[#E01E5A]/20", slack: "border-[#E01E5A]/60",
discord: "text-[#5865F2] bg-[#5865F2]/10 border-[#5865F2]/20", discord: "border-[#5865F2]/60",
teams: "text-[#6264A7] bg-[#6264A7]/10 border-[#6264A7]/20", teams: "border-[#6264A7]/60",
google_chat: "text-[#1A73E8] bg-[#1A73E8]/10 border-[#1A73E8]/20", google_chat: "border-[#1A73E8]/60",
mattermost: "text-[#0058CC] bg-[#0058CC]/10 border-[#0058CC]/20", mattermost: "border-[#0058CC]/60",
rocketchat: "text-[#F5455C] bg-[#F5455C]/10 border-[#F5455C]/20", rocketchat: "border-[#F5455C]/60",
telegram: "text-[#26A5E4] bg-[#26A5E4]/10 border-[#26A5E4]/20", telegram: "border-[#26A5E4]/60",
pagerduty: "text-[#06AC38] bg-[#06AC38]/10 border-[#06AC38]/20", pagerduty: "border-[#06AC38]/60",
opsgenie: "text-[#2684FF] bg-[#2684FF]/10 border-[#2684FF]/20", opsgenie: "border-[#2684FF]/60",
splunk_oncall: "text-[#F99D1C] bg-[#F99D1C]/10 border-[#F99D1C]/20", splunk_oncall: "border-[#F99D1C]/60",
}; };
const NEUTRAL_STYLE = "text-fg-secondary bg-surface-2 border-border"; const NEUTRAL_STYLE = "border-border";
interface HeaderRow { interface HeaderRow {
key: string; key: string;
@@ -168,11 +170,13 @@ export function WebhookSettings() {
const res = await api.webhooks.list(); const res = await api.webhooks.list();
setTargets(res.targets); setTargets(res.targets);
} catch (err) { } catch (err) {
console.error("Failed to load webhook targets:", err); toast.error(
t("webhooks.loadFailed", { error: err instanceof Error ? err.message : String(err) })
);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, []); }, [t]);
useEffect(() => { useEffect(() => {
load(); load();
@@ -252,6 +256,9 @@ export function WebhookSettings() {
const canSubmit = useMemo(() => { const canSubmit = useMemo(() => {
if (!form || !provider) return false; if (!form || !provider) return false;
if (!form.name.trim()) 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 (isEdit) return true; // server merge - existing values fill the gaps
if (provider.url_required && !form.url.trim()) return false; if (provider.url_required && !form.url.trim()) return false;
for (const f of provider.fields) { for (const f of provider.fields) {
@@ -326,7 +333,9 @@ export function WebhookSettings() {
await api.webhooks.update(target.id, { enabled: !target.enabled }); await api.webhooks.update(target.id, { enabled: !target.enabled });
load(); load();
} catch (err) { } 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); setConfirmDelete(null);
load(); load();
} catch (err) { } 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"> <div className="flex items-center gap-3 flex-wrap">
<span <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 TYPE_STYLES[target.type] || NEUTRAL_STYLE
}`} }`}
> >
@@ -765,6 +777,9 @@ export function WebhookSettings() {
))} ))}
</div> </div>
)} )}
{!form.scopeAll && form.ruleIds.length === 0 && (
<p className="mt-1.5 text-[11px] text-status-warning">{t("webhooks.pickRule")}</p>
)}
</div> </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(); 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 * @file ConfigTabBar.tsx
* @description The tab strip both config explorers wear: one horizontally * @description The tab strip both config explorers wear: tabs WRAP instead of
* scrollable row with fade edges and chevrons that appear only when there is * scrolling behind a hidden scrollbar, an optional `group` per tab draws a
* something to scroll to. * 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 * Shared rather than copied because the two pages must look the same.
* 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.
* *
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { useCallback, useEffect, useRef, useState, type ComponentType } from "react"; import { Fragment, useRef, type ComponentType, type KeyboardEvent } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
export interface ConfigTab<K extends string> { export interface ConfigTab<K extends string> {
key: K; key: K;
@@ -21,15 +19,19 @@ export interface ConfigTab<K extends string> {
label: string; label: string;
/** Badge number, or null for tabs that count nothing (e.g. Overview). */ /** Badge number, or null for tabs that count nothing (e.g. Overview). */
count: number | null; count: number | null;
/** Group label; a change of group between neighbours draws a separator. */
group?: string;
} }
interface ConfigTabBarProps<K extends string> { interface ConfigTabBarProps<K extends string> {
tabs: ConfigTab<K>[]; tabs: ConfigTab<K>[];
current: K; current: K;
onSelect: (key: K) => void; 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; testIdPrefix: string;
ariaLabel: string; ariaLabel: string;
/** The id of the tabpanel element every tab controls. */
panelId?: string;
} }
export function ConfigTabBar<K extends string>({ export function ConfigTabBar<K extends string>({
@@ -38,90 +40,63 @@ export function ConfigTabBar<K extends string>({
onSelect, onSelect,
testIdPrefix, testIdPrefix,
ariaLabel, ariaLabel,
panelId,
}: ConfigTabBarProps<K>) { }: ConfigTabBarProps<K>) {
const scrollRef = useRef<HTMLDivElement | null>(null); const listRef = useRef<HTMLDivElement | null>(null);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);
// Update scroll affordances when content size or scroll position changes. const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
const updateAffordances = useCallback(() => { const i = tabs.findIndex((t) => t.key === current);
const el = scrollRef.current; const next =
if (!el) return; e.key === "ArrowRight"
setCanScrollLeft(el.scrollLeft > 1); ? (i + 1) % tabs.length
setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1); : e.key === "ArrowLeft"
}, []); ? (i - 1 + tabs.length) % tabs.length
: e.key === "Home"
useEffect(() => { ? 0
updateAffordances(); : e.key === "End"
const el = scrollRef.current; ? tabs.length - 1
if (!el) return; : -1;
el.addEventListener("scroll", updateAffordances, { passive: true }); if (next < 0) return;
const ro = new ResizeObserver(updateAffordances); e.preventDefault();
ro.observe(el); const key = tabs[next]!.key;
return () => { onSelect(key);
el.removeEventListener("scroll", updateAffordances); listRef.current?.querySelector<HTMLElement>(`[data-testid="${testIdPrefix}-${key}"]`)?.focus();
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" });
}; };
return ( return (
<div className="relative rounded-xl border border-border bg-surface-1"> <div
{/* Left edge gradient + chevron */} ref={listRef}
<div role="tablist"
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 ${ aria-label={ariaLabel}
canScrollLeft ? "opacity-100" : "opacity-0" onKeyDown={onKeyDown}
}`} className="flex flex-wrap items-center gap-1 rounded-xl border border-border bg-surface-1 p-1"
/> >
{canScrollLeft && ( {tabs.map(({ key, icon: Icon, label, count, group }, i) => {
<button const active = current === key;
onClick={() => scrollByButton(-1)} const newGroup = group && group !== tabs[i - 1]?.group;
aria-label="scroll tabs left" return (
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" <Fragment key={key}>
> {newGroup && (
<ChevronLeft className="w-4 h-4" /> <span
</button> 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" : ""
<div }`}
ref={scrollRef} >
role="tablist" {group}
aria-label={ariaLabel} </span>
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 (
<button <button
key={key} type="button"
id={`${testIdPrefix}-${key}`}
data-testid={`${testIdPrefix}-${key}`} data-testid={`${testIdPrefix}-${key}`}
data-tab-active={active ? "true" : undefined} data-tab-active={active ? "true" : undefined}
role="tab" role="tab"
aria-selected={active} aria-selected={active}
aria-controls={panelId}
tabIndex={active ? 0 : -1}
onClick={() => onSelect(key)} 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 active
? "bg-accent/15 text-accent border border-accent/30" ? "bg-accent/15 text-accent border border-accent/30"
: "text-fg-secondary hover:text-fg-primary hover:bg-surface-3 border border-transparent" : "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> </span>
)} )}
</button> </button>
); </Fragment>
})} );
</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>
)}
</div> </div>
); );
} }
@@ -6,7 +6,8 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @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 { useTranslation } from "react-i18next";
import { ShieldAlert, Trash2 } from "lucide-react"; import { ShieldAlert, Trash2 } from "lucide-react";
import type { CcArtifactType } from "../../lib/api"; import type { CcArtifactType } from "../../lib/api";
@@ -41,13 +42,10 @@ export function ConfirmDeleteModal<
const { t } = useTranslation("ccConfig"); const { t } = useTranslation("ccConfig");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
useEffect(() => { const panelRef = useRef<HTMLDivElement>(null);
const onKey = (e: KeyboardEvent) => { const cancelRef = useRef<HTMLButtonElement>(null);
if (e.key === "Escape") onCancel(); // Focus starts on Cancel — the safe answer — and Escape cancels.
}; useDialog(true, panelRef, busy ? undefined : onCancel, cancelRef);
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onCancel]);
const handleConfirm = useCallback(async () => { const handleConfirm = useCallback(async () => {
setBusy(true); setBusy(true);
@@ -60,27 +58,36 @@ export function ConfirmDeleteModal<
return ( return (
<div <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={onCancel} onClick={busy ? undefined : onCancel}
> >
<div <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" className="relative w-full max-w-lg rounded-xl border border-status-danger/40 bg-surface-1"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<div className="border-b border-border px-4 py-2.5 flex items-center gap-2"> <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" /> <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")} {t("edit.confirmDelete")}
</span> </span>
</div> </div>
<div className="p-4 space-y-3"> <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"> <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 })} {t("edit.confirmDeletePath", { path: state.path })}
</div> </div>
</div> </div>
<div className="border-t border-border px-4 py-3 flex items-center justify-end gap-2"> <div className="border-t border-border px-4 py-3 flex items-center justify-end gap-2">
<button <button
ref={cancelRef}
type="button"
onClick={onCancel} onClick={onCancel}
disabled={busy} 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" 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")} {t("edit.cancel")}
</button> </button>
<button <button
type="button"
onClick={handleConfirm} onClick={handleConfirm}
disabled={busy} 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" 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, * @description Create/edit modal for text-file config artifacts (skills,
* agents, commands, output styles, memory). Tool-agnostic: it takes a * agents, commands, output styles, memory). Tool-agnostic: it takes a
* `fetchContent` callback instead of reaching for any particular tool's API, * `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> * @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 { useTranslation } from "react-i18next";
import { Pencil, X, Save, AlertCircle } from "lucide-react"; import { Pencil, X, Save, AlertCircle } from "lucide-react";
import type { CcArtifactType } from "../../lib/api"; 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 /** 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 — * (CcConfig.tsx) don't need to name it. Codex has no `auto-memory` scope —
@@ -48,7 +61,7 @@ interface EditorModalProps<
TScope extends string = DefaultScope, TScope extends string = DefaultScope,
> { > {
state: NonNullable<EditorState<TType, TScope>>; state: NonNullable<EditorState<TType, TScope>>;
fetchContent: (filePath: string) => Promise<string>; fetchContent: (filePath: string) => Promise<Fetched>;
onClose: () => void; onClose: () => void;
onSave: (args: { onSave: (args: {
type: TType; type: TType;
@@ -60,6 +73,8 @@ interface EditorModalProps<
/** Which `state.type` values are nameless singleton files. Defaults to /** Which `state.type` values are nameless singleton files. Defaults to
* Claude Code's own `["memory"]` so existing callers are unaffected. */ * Claude Code's own `["memory"]` so existing callers are unaffected. */
namelessTypes?: readonly string[]; namelessTypes?: readonly string[];
/** Refuse the Project target (its folder is the plugin cache, wiped on update). */
projectDisabled?: boolean;
} }
export function EditorModal< export function EditorModal<
@@ -71,6 +86,7 @@ export function EditorModal<
onClose, onClose,
onSave, onSave,
namelessTypes = DEFAULT_NAMELESS_TYPES, namelessTypes = DEFAULT_NAMELESS_TYPES,
projectDisabled = false,
}: EditorModalProps<TType, TScope>) { }: EditorModalProps<TType, TScope>) {
const { t } = useTranslation("ccConfig"); const { t } = useTranslation("ccConfig");
const isCreate = state.mode === "create"; const isCreate = state.mode === "create";
@@ -88,41 +104,110 @@ export function EditorModal<
const [loading, setLoading] = useState(!isCreate); const [loading, setLoading] = useState(!isCreate);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null); 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 // Read through a ref: callers pass an inline fetcher, and a dependency on it
useEffect(() => { // reloaded the file on every parent render — overwriting the user's typing.
if (state.mode === "edit") { const fetchRef = useRef(fetchContent);
setLoading(true); fetchRef.current = fetchContent;
fetchContent(state.filePath) const editPath = state.mode === "edit" ? state.filePath : null;
.then((text) => {
setContent(text);
setLoading(false);
})
.catch((err: unknown) => {
const msg = err instanceof Error ? err.message : "unknown";
setError(msg);
setLoading(false);
});
}
}, [state, fetchContent]);
// 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(() => { useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (editPath === null) return;
if (e.key === "Escape") onClose(); 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); }, [editPath]);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]); 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 () => { const handleSave = useCallback(async () => {
setSaving(true); setSaving(true);
setError(null); setError(null);
try { try {
if (!isNameless && state.mode === "create" && !name) { if (!isNameless && state.mode === "create") {
setError(t("edit.nameLabel")); if (!name.trim()) {
setSaving(false); setError(t("edit.nameRequired"));
return; 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. // For auto-memory creates, append a .md extension when the user omits it.
const createName = isAutoMemory && !/\.md$/i.test(name) ? `${name}.md` : name; const createName = isAutoMemory && !/\.md$/i.test(name) ? `${name}.md` : name;
@@ -140,13 +225,14 @@ export function EditorModal<
content, content,
project: state.project, project: state.project,
}); });
original.current = content;
} catch (err: unknown) { } catch (err: unknown) {
const msg = err instanceof Error ? err.message : "unknown"; const msg = err instanceof Error ? err.message : "unknown";
setError(t("edit.writeError", { message: msg })); setError(t("edit.writeError", { message: msg }));
} finally { } finally {
setSaving(false); setSaving(false);
} }
}, [state, targetScope, name, content, isAutoMemory, onSave, t]); }, [state, targetScope, name, content, isAutoMemory, isNameless, onSave, t]);
const titleText = isCreate const titleText = isCreate
? isAutoMemory ? isAutoMemory
@@ -156,18 +242,25 @@ export function EditorModal<
return ( return (
<div <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} onClick={() => void requestClose()}
> >
<div <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" 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()} onClick={(e) => e.stopPropagation()}
> >
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5"> <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" /> <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 <button
onClick={onClose} type="button"
onClick={() => void requestClose()}
className="text-fg-secondary hover:text-fg-primary p-1 rounded-md hover:bg-surface-3" className="text-fg-secondary hover:text-fg-primary p-1 rounded-md hover:bg-surface-3"
aria-label={t("common.close")} aria-label={t("common.close")}
> >
@@ -214,8 +307,14 @@ export function EditorModal<
{(["user", "project"] as const).map((s) => ( {(["user", "project"] as const).map((s) => (
<button <button
key={s} key={s}
type="button"
aria-pressed={targetScope === s}
disabled={s === "project" && projectDisabled}
title={
s === "project" && projectDisabled ? t("scope.projectInCache") : undefined
}
onClick={() => setTargetScope(s)} 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 targetScope === s
? "bg-accent/20 text-accent border border-accent/30" ? "bg-accent/20 text-accent border border-accent/30"
: "text-fg-secondary hover:text-fg-primary" : "text-fg-secondary hover:text-fg-primary"
@@ -240,6 +339,8 @@ export function EditorModal<
<textarea <textarea
value={content} value={content}
onChange={(e) => setContent(e.target.value)} onChange={(e) => setContent(e.target.value)}
readOnly={loadState !== "ok"}
aria-label={t("edit.contentLabel")}
spellCheck={false} 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" 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> </div>
{error && ( {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" /> <AlertCircle className="w-3.5 h-3.5" />
{error} {error}
</div> </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"> <div className="border-t border-border px-4 py-3 flex items-center justify-end gap-2">
<button <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" 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")} {t("edit.cancel")}
</button> </button>
<button <button
type="button"
onClick={handleSave} 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" 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" /> <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> * @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 { useTranslation } from "react-i18next";
import { FileText, X, AlertCircle, Eye, Code2 } from "lucide-react"; import { FileText, X, AlertCircle, Eye, Code2 } from "lucide-react";
import type { CcFileResponse } from "../../lib/api"; import type { CcFileResponse } from "../../lib/api";
@@ -25,25 +26,27 @@ export function FileViewer({
const { t } = useTranslation("ccConfig"); const { t } = useTranslation("ccConfig");
const [preview, setPreview] = useState(false); const [preview, setPreview] = useState(false);
const canPreview = isMarkdown(state.path) && !!state.data; const canPreview = isMarkdown(state.path) && !!state.data;
useEffect(() => { const panelRef = useRef<HTMLDivElement>(null);
const onKey = (e: KeyboardEvent) => { useDialog(true, panelRef, onClose);
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
return ( return (
<div <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} onClick={onClose}
> >
<div <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" 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()} onClick={(e) => e.stopPropagation()}
> >
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5"> <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" /> <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} {state.path}
</span> </span>
{canPreview && ( {canPreview && (
@@ -59,6 +62,7 @@ export function FileViewer({
)} )}
<CopyButton value={state.path} /> <CopyButton value={state.path} />
<button <button
type="button"
onClick={onClose} onClick={onClose}
className="text-fg-secondary hover:text-fg-primary p-1 rounded-md hover:bg-surface-3" className="text-fg-secondary hover:text-fg-primary p-1 rounded-md hover:bg-surface-3"
aria-label={t("common.close")} 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 { useTranslation } from "react-i18next";
import { ExternalLink, Pencil, Trash2 } from "lucide-react"; import { ExternalLink, Pencil, Trash2 } from "lucide-react";
import type { CcArtifactType, CcMdItem } from "../../lib/api"; import type { CcArtifactType, CcMdItem } from "../../lib/api";
import { copyText } from "../../lib/clipboard";
import { import {
ContextMenu, ContextMenu,
MenuItem, MenuItem,
@@ -83,10 +84,7 @@ export function FileItemMenu({
<MenuItem testId="config-item-menu-edit" onSelect={pick(onEdit)}> <MenuItem testId="config-item-menu-edit" onSelect={pick(onEdit)}>
{l.edit} {l.edit}
</MenuItem> </MenuItem>
<MenuItem <MenuItem testId="config-item-menu-copy" onSelect={pick(() => void copyText(path))}>
testId="config-item-menu-copy"
onSelect={pick(() => void navigator.clipboard.writeText(path))}
>
{l.copyPath} {l.copyPath}
</MenuItem> </MenuItem>
<MenuSeparator /> <MenuSeparator />
@@ -8,15 +8,19 @@
* The directory list is `GET /api/run/cwds` (the dashboard's known projects), * 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: * 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 * 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> * @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 { useTranslation } from "react-i18next";
import { BookOpen, X, Save, AlertCircle } from "lucide-react"; import { BookOpen, X, Save, AlertCircle } from "lucide-react";
import { api, type CwdSuggestion } from "../../lib/api"; import { api, type CwdSuggestion } from "../../lib/api";
import { confirmDiscard } from "../../lib/confirm";
import { useDialog } from "../../hooks/useDialog";
import { useUnsavedGuard } from "../../hooks/useUnsavedGuard";
interface ProjectMemoryModalProps { interface ProjectMemoryModalProps {
/** i18n namespace of the calling page — "ccConfig" or "codexConfig". */ /** i18n namespace of the calling page — "ccConfig" or "codexConfig". */
@@ -55,13 +59,23 @@ export function ProjectMemoryModal({
.catch(() => undefined); .catch(() => undefined);
}, []); }, []);
useEffect(() => { // Typed text is a draft: closing (X, Cancel, backdrop, Escape) asks first,
const onKey = (e: KeyboardEvent) => { // and Escape inside the textarea only leaves the field.
if (e.key === "Escape") onClose(); const dirty = dir.trim() !== "" || content !== template;
}; useUnsavedGuard(dirty);
window.addEventListener("keydown", onKey); const panelRef = useRef<HTMLDivElement>(null);
return () => window.removeEventListener("keydown", onKey); const requestClose = useCallback(async () => {
}, [onClose]); 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 handleCreate = useCallback(async () => {
const target = dir.trim(); const target = dir.trim();
@@ -82,22 +96,28 @@ export function ProjectMemoryModal({
return ( return (
<div <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} onClick={() => void requestClose()}
> >
<div <div
ref={panelRef}
role="dialog" 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" 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()} onClick={(e) => e.stopPropagation()}
> >
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5"> <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" /> <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 })} {t("projectMemory.title", { file: filename })}
</span> </span>
<button <button
onClick={onClose} type="button"
onClick={() => void requestClose()}
className="text-fg-secondary hover:text-fg-primary p-1 rounded-md hover:bg-surface-3" className="text-fg-secondary hover:text-fg-primary p-1 rounded-md hover:bg-surface-3"
aria-label={t("projectMemory.close")} aria-label={t("projectMemory.close")}
> >
@@ -151,7 +171,10 @@ export function ProjectMemoryModal({
</div> </div>
{error && ( {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" /> <AlertCircle className="w-3.5 h-3.5" />
{error} {error}
</div> </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"> <div className="border-t border-border px-4 py-3 flex items-center justify-end gap-2">
<button <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" 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")} {t("projectMemory.cancel")}
</button> </button>
<button <button
type="button"
onClick={handleCreate} onClick={handleCreate}
disabled={saving} 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" 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 * @file ToastNotice.tsx
* @description Bottom-right success/error toast for the config explorer. * @description The config explorers' `{kind, message}` toast shape and
* Tool-agnostic — the page owns the 5s auto-dismiss timer and passes the * `showToast`, which hands it to the app-wide toast queue (`lib/toast.ts`): a
* message down. * 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> * @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 type Toast = { kind: "success" | "error"; message: string } | null;
export function ToastNotice({ /** Show a config-page toast through the global queue. */
toast, export function showToast(t: NonNullable<Toast>): void {
onDismiss, if (t.kind === "error") toast.error(t.message);
}: { else toast.success(t.message);
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>
);
} }
@@ -7,9 +7,11 @@
*/ */
import { describe, it, expect, vi } from "vitest"; 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 userEvent from "@testing-library/user-event";
import { EditorModal } from "../EditorModal"; import { EditorModal } from "../EditorModal";
import { settleConfirm, subscribeConfirm, type PendingConfirm } from "../../../lib/confirm";
describe("EditorModal", () => { describe("EditorModal", () => {
it("loads the existing body through the injected fetcher, not a hardcoded API", async () => { it("loads the existing body through the injected fetcher, not a hardcoded API", async () => {
@@ -46,3 +48,156 @@ describe("EditorModal", () => {
expect(onSave).not.toHaveBeenCalled(); 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 { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Copy, Check } from "lucide-react"; import { Copy, Check } from "lucide-react";
import { copyText } from "../../lib/clipboard";
export function CopyButton({ value }: { value: string }) { export function CopyButton({ value }: { value: string }) {
const { t } = useTranslation("ccConfig"); const { t } = useTranslation("ccConfig");
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
return ( return (
<button <button
type="button"
onClick={async () => { onClick={async () => {
try { if (await copyText(value, true)) {
await navigator.clipboard.writeText(value);
setCopied(true); setCopied(true);
setTimeout(() => setCopied(false), 1500); 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" 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")} title={t("common.copyPath")}
aria-label={t("common.copyPath")}
> >
{copied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />} {copied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
</button> </button>
@@ -5,13 +5,16 @@
* `index.css`). Each card's swatch re-scopes the theme's CSS variables on its * `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 * 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. * 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> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { useTranslation } from "react-i18next"; 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 { SettingsRow, SettingsSegmented } from "./SettingsRow";
import { THEMES, useTheme, type Theme, type ThemeMode } from "../../hooks/useTheme"; 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> = { const THEME_LABELS: Record<string, string> = {
darcula: "Darcula", darcula: "Darcula",
@@ -61,6 +64,19 @@ export function AppearanceSettings() {
const { t } = useTranslation("settings"); const { t } = useTranslation("settings");
const { mode, theme, themeFor, setMode, setThemeFor } = useTheme(); 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 ( return (
<div className="card p-5 space-y-5"> <div className="card p-5 space-y-5">
<SettingsRow <SettingsRow
@@ -116,6 +132,13 @@ export function AppearanceSettings() {
</div> </div>
</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> </div>
); );
} }
@@ -4,7 +4,7 @@
* under Settings → Claude Code → Overview: the CLAUDE_HOME the server reads * under Settings → Claude Code → Overview: the CLAUDE_HOME the server reads
* transcripts and config from, the AgentHub hooks installed into Claude Code * transcripts and config from, the AgentHub hooks installed into Claude Code
* with a Reinstall button, and Model Pricing for Claude models (the shared * 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> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -128,8 +128,9 @@ export function ClaudeAgentHubSettings({ onHomeChanged }: { onHomeChanged?: () =
id="claude-home" id="claude-home"
title={t("claudeHome.title")} title={t("claudeHome.title")}
description={t("claudeHome.description")} description={t("claudeHome.description")}
note={t("cursorPathsNote")} note={t("claudeHome.note")}
placeholder={t("claudeHome.placeholder")} placeholder={t("claudeHome.placeholder")}
confirm={{ title: t("claudeHome.confirmTitle"), message: t("claudeHome.confirmBody") }}
load={() => load={() =>
api.settings.claudeHome api.settings.claudeHome
.get() .get()
@@ -5,14 +5,18 @@
* server's refusal inline, the saved value while the field differs from it, * server's refusal inline, the saved value while the field differs from it,
* and a warning when the real * and a warning when the real
* environment sets the variable (the environment wins at the next start, so a * 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; * saved value would not survive a restart). Enter saves; a caller may ask for
* this component only loads, submits and reports. * 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> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { AlertTriangle, FolderOpen } from "lucide-react"; import { AlertTriangle, FolderOpen } from "lucide-react";
import { confirmDialog } from "../../lib/confirm";
import { isSubmitEnter } from "../../lib/keys";
import { SettingsSection } from "./SettingsRow";
export interface HomeDirValue { export interface HomeDirValue {
value: string; value: string;
@@ -29,6 +33,8 @@ interface Props {
/** Optional second line under the description. */ /** Optional second line under the description. */
note?: string; note?: string;
placeholder: string; placeholder: string;
/** Ask this before saving (a change with wide effects, like CLAUDE_HOME). */
confirm?: { title: string; message?: string };
load: () => Promise<HomeDirValue>; load: () => Promise<HomeDirValue>;
save: (path: string) => Promise<HomeDirValue>; save: (path: string) => Promise<HomeDirValue>;
} }
@@ -37,7 +43,16 @@ interface Props {
* An editable home directory with its own load/save cycle. * An editable home directory with its own load/save cycle.
* @returns The section, or its skeleton-free shell while loading. * @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 { t } = useTranslation("settings");
const [current, setCurrent] = useState<HomeDirValue | null>(null); const [current, setCurrent] = useState<HomeDirValue | null>(null);
const [input, setInput] = useState(""); const [input, setInput] = useState("");
@@ -61,7 +76,8 @@ export function HomeDirSetting({ id, title, description, note, placeholder, load
}, []); }, []);
const handleSave = async () => { 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); setSaving(true);
setError(null); setError(null);
try { try {
@@ -76,15 +92,18 @@ export function HomeDirSetting({ id, title, description, note, placeholder, load
}; };
return ( return (
<section id={id} className="scroll-mt-24"> <SettingsSection
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1"> id={id}
<FolderOpen className="w-4 h-4 text-fg-muted" /> title={
{title} <span className="flex items-center gap-2">
</h3> <FolderOpen className="w-4 h-4 text-fg-muted" />
<p className="text-xs text-fg-muted mb-1">{description}</p> {title}
{note && <p className="text-[11px] text-fg-muted italic mb-4 leading-snug">{note}</p>} </span>
}
<div className={`card p-5 space-y-4 ${note ? "" : "mt-3"}`}> 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"> <div className="flex items-center gap-3">
<input <input
type="text" type="text"
@@ -94,10 +113,17 @@ export function HomeDirSetting({ id, title, description, note, placeholder, load
setInput(e.target.value); setInput(e.target.value);
setError(null); 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} placeholder={placeholder}
/> />
<button <button
type="button"
onClick={handleSave} onClick={handleSave}
disabled={saving || !current || input === current.value} disabled={saving || !current || input === current.value}
className="btn-primary px-4 py-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed" 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> </p>
)} )}
</div> </div>
</section> </SettingsSection>
); );
} }
+55 -6
View File
@@ -6,17 +6,31 @@
* uploading an image or importing a `.codex-pet.zip`. Everything here is the * 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`, * 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 * `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> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { useRef, type ReactNode } from "react"; import { useRef, type ReactNode } from "react";
import { useTranslation } from "react-i18next"; 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 { SettingsRow, SettingsSwitchRow } from "./SettingsRow";
import { SagiAvatar } from "../Sagi/SagiAvatar"; import { SagiAvatar } from "../Sagi/SagiAvatar";
import { PetSprite } from "../Sagi/PetSprite"; import { PetSprite } from "../Sagi/PetSprite";
import { PET_SIZE, sagiPrefs } from "../Sagi/prefs"; 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 { usePets, useWornPet } from "../Sagi/usePets";
import type { PetView } from "../../lib/api"; import type { PetView } from "../../lib/api";
@@ -78,7 +92,9 @@ function PetCard({
disabled={disabled} disabled={disabled}
aria-label={deleteLabel} aria-label={deleteLabel}
title={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" /> <Trash2 className="h-3.5 w-3.5" />
</button> </button>
@@ -88,13 +104,14 @@ function PetCard({
} }
function Preview({ pet, size }: { pet: PetView; size: number }) { function Preview({ pet, size }: { pet: PetView; size: number }) {
const reducedMotion = usePrefersReducedMotion();
return ( return (
<PetSprite <PetSprite
id={pet.id} id={pet.id}
kind={pet.kind} kind={pet.kind}
mood="idle" mood="idle"
size={size} size={size}
reducedMotion={false} reducedMotion={reducedMotion}
onBroken={() => {}} onBroken={() => {}}
/> />
); );
@@ -108,6 +125,22 @@ export function PetSettings() {
const bundleInput = useRef<HTMLInputElement>(null); const bundleInput = useRef<HTMLInputElement>(null);
const wornPet = list?.pets.find((p) => p.id === worn.pet.id); const wornPet = list?.pets.find((p) => p.id === worn.pet.id);
const sizePreview = Math.min(worn.size, 120); 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -123,6 +156,7 @@ export function PetSettings() {
onChange={(v) => sagiPrefs.setMuted(!v)} onChange={(v) => sagiPrefs.setMuted(!v)}
label={t("sagi.bubbles")} label={t("sagi.bubbles")}
description={t("sagi.bubblesDesc")} description={t("sagi.bubblesDesc")}
disabled={!worn.shown}
/> />
<SettingsRow <SettingsRow
label={t("sagi.pet.size")} label={t("sagi.pet.size")}
@@ -136,8 +170,9 @@ export function PetSettings() {
min={PET_SIZE.min} min={PET_SIZE.min}
max={PET_SIZE.max} max={PET_SIZE.max}
value={worn.size} value={worn.size}
disabled={!worn.shown}
onChange={(e) => sagiPrefs.setSize(Number(e.target.value))} 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"> <span className="w-12 text-right text-xs tabular-nums text-fg-muted">
{worn.size}px {worn.size}px
@@ -152,9 +187,23 @@ export function PetSettings() {
{wornPet ? ( {wornPet ? (
<Preview pet={wornPet} size={sizePreview} /> <Preview pet={wornPet} size={sizePreview} />
) : ( ) : (
<SagiAvatar mood="idle" reducedMotion={false} size={sizePreview} /> <SagiAvatar mood="idle" reducedMotion={reducedMotion} size={sizePreview} />
)} )}
</div> </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>
<div className="card p-5"> <div className="card p-5">
@@ -9,7 +9,10 @@
* filter turns it on rather than vanishing. Reset Defaults reseeds Claude's * 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 * 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 * 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> * @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 { getCurrentLocale } from "../../lib/format";
import { Skeleton } from "../Skeleton"; import { Skeleton } from "../Skeleton";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu"; 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"; import type { ModelPricing, WSMessage } from "../../lib/types";
interface EditRow { interface EditRow {
@@ -185,6 +191,20 @@ function PricingInfoTooltip({ vendor }: { vendor: PricingVendor }) {
export type PricingVendor = "claude" | "openai" | "google"; 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> = { const VENDOR_PATTERN: Record<PricingVendor, RegExp> = {
claude: /^claude-/, claude: /^claude-/,
openai: /^(gpt-|o\d|codex-)/, 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 [error, setError] = useState<string | null>(null);
const [showAll, setShowAll] = useState(false); const [showAll, setShowAll] = useState(false);
const [resetting, setResetting] = 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>(); const ctx = useContextMenu<ModelPricing>();
// A menu item: close the menu, then act on the rule it was opened on. // A menu item: close the menu, then act on the rule it was opened on.
const pick = (fn: (rule: ModelPricing) => unknown) => () => { const pick = (fn: (rule: ModelPricing) => unknown) => () => {
@@ -232,7 +254,7 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing", he
const load = useCallback(async () => { const load = useCallback(async () => {
try { try {
const res = await api.pricing.list(); const res = await listPricing();
setPricing(res.pricing); setPricing(res.pricing);
setError(null); setError(null);
} catch (err) { } catch (err) {
@@ -244,6 +266,11 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing", he
useEffect(() => { useEffect(() => {
load(); load();
const reload = () => void load();
tables.add(reload);
return () => {
tables.delete(reload);
};
}, [load]); }, [load]);
// A remote source can bring its own pricing rules; reload when one syncs. // 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); if (!ours(editRow.model_pattern.trim())) setShowAll(true);
setEditingPattern(null); setEditingPattern(null);
setAdding(false); setAdding(false);
await load(); reloadAllTables();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : t("messages.failedSave")); setError(err instanceof Error ? err.message : t("messages.failedSave"));
} finally { } finally {
@@ -341,9 +368,17 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing", he
}; };
const deleteRule = async (pattern: string) => { const deleteRule = async (pattern: string) => {
if (
!(await confirmDialog({
title: t("pricing.deleteConfirmTitle", { pattern }),
message: t("pricing.deleteConfirmBody"),
confirmLabel: t("common:delete"),
}))
)
return;
try { try {
await api.pricing.delete(pattern); await api.pricing.delete(pattern);
await load(); reloadAllTables();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : t("messages.failedDelete")); 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 () => { const handleResetPricing = async () => {
setResetting(true); setResetting(true);
setActionResult(null); setActionResult(null);
setConfirmReset(false); resetArm.disarm();
try { try {
// Only the Claude tab offers the button, and it resets only Claude's rows. // Only the Claude tab offers the button, and it resets only Claude's rows.
const res = await api.settings.resetPricing("claude"); const res = await api.settings.resetPricing("claude");
@@ -363,7 +398,7 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing", he
}), }),
isError: false, isError: false,
}); });
await load(); reloadAllTables();
} catch (err) { } catch (err) {
setActionResult({ setActionResult({
key: "reset-pricing", key: "reset-pricing",
@@ -591,7 +626,7 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing", he
)} )}
{allowReset && ( {allowReset && (
<button <button
onClick={() => (confirmReset ? handleResetPricing() : setConfirmReset(true))} onClick={() => (confirmReset ? handleResetPricing() : resetArm.arm("reset"))}
disabled={isEditing || resetting} 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 ${ className={`text-xs px-3 py-1.5 rounded-md transition-colors disabled:opacity-50 inline-flex items-center gap-1.5 ${
confirmReset confirmReset
@@ -749,7 +784,7 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing", he
<button <button
onClick={() => startEdit(rule)} onClick={() => startEdit(rule)}
disabled={isEditing} 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")} title={t("common:edit")}
> >
<Pencil className="w-3.5 h-3.5" /> <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)}> <MenuItem disabled={isEditing} onSelect={pick(startEdit)}>
{t("common:edit")} {t("common:edit")}
</MenuItem> </MenuItem>
<MenuItem <MenuItem onSelect={pick((rule) => void copyText(rule.model_pattern))}>
onSelect={pick((rule) => void navigator.clipboard.writeText(rule.model_pattern))}
>
{t("rowMenu.copyPattern")} {t("rowMenu.copyPattern")}
</MenuItem> </MenuItem>
<MenuSeparator /> <MenuSeparator />
+27 -5
View File
@@ -2,25 +2,29 @@
* @file SettingsRow — the settings page's row primitives: a titled card * @file SettingsRow — the settings page's row primitives: a titled card
* (`SettingsSection`), a label/description | control row (`SettingsRow`), a * (`SettingsSection`), a label/description | control row (`SettingsRow`), a
* switch row (`SettingsSwitchRow`) and a segmented radio group * switch row (`SettingsSwitchRow`) and a segmented radio group
* (`SettingsSegmented`). Rows divide themselves with a hairline, none after the * (`SettingsSegmented`, arrow keys + one roving tab stop). Rows divide
* last, so a section is just its rows in order. * 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> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { Switch } from "../Switch"; import { Switch } from "../Switch";
import type { ReactNode } from "react"; import type { KeyboardEvent, ReactNode } from "react";
export function SettingsSection({ export function SettingsSection({
id,
title, title,
description, description,
children, children,
}: { }: {
/** `<section id>` — a deep-link target. */
id?: string;
title: ReactNode; title: ReactNode;
description?: ReactNode; description?: ReactNode;
children: ReactNode; children: ReactNode;
}) { }) {
return ( 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> <h3 className="text-sm font-semibold text-fg-primary">{title}</h3>
{description && <p className="mt-0.5 text-xs text-fg-muted">{description}</p>} {description && <p className="mt-0.5 text-xs text-fg-muted">{description}</p>}
<div className="mt-2">{children}</div> <div className="mt-2">{children}</div>
@@ -87,18 +91,36 @@ export function SettingsSegmented<T extends string>({
onChange: (v: T) => void; onChange: (v: T) => void;
ariaLabel?: string; 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 ( return (
<div <div
role="radiogroup" role="radiogroup"
aria-label={ariaLabel} aria-label={ariaLabel}
onKeyDown={onKeyDown}
className="inline-flex rounded-lg border border-border bg-surface-2 p-0.5" className="inline-flex rounded-lg border border-border bg-surface-2 p-0.5"
> >
{options.map((o) => ( {options.map((o, i) => (
<button <button
key={o.value} key={o.value}
type="button" type="button"
role="radio" role="radio"
aria-checked={value === o.value} aria-checked={value === o.value}
tabIndex={
value === o.value || (i === 0 && !options.some((x) => x.value === value)) ? 0 : -1
}
onClick={() => onChange(o.value)} onClick={() => onChange(o.value)}
className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${ className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
value === o.value value === o.value
@@ -13,6 +13,12 @@ import i18n from "i18next";
import { ClaudeAgentHubSettings } from "../ClaudeAgentHubSettings"; import { ClaudeAgentHubSettings } from "../ClaudeAgentHubSettings";
import { api } from "../../../lib/api"; import { api } from "../../../lib/api";
import type { ModelPricing } from "../../../lib/types"; 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) => { vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>(); const actual = await importOriginal<Record<string, unknown>>();
@@ -89,8 +95,10 @@ describe("ClaudeAgentHubSettings", () => {
await user.clear(input); await user.clear(input);
await user.type(input, "/srv/claude"); await user.type(input, "/srv/claude");
const home = container.querySelector("section#claude-home") as HTMLElement; const home = container.querySelector("section#claude-home") as HTMLElement;
const off = confirmYes();
await user.click(within(home).getByRole("button")); 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; const hooks = container.querySelector("section#hooks") as HTMLElement;
await user.click(within(hooks).getByRole("button")); await user.click(within(hooks).getByRole("button"));
@@ -106,8 +114,10 @@ describe("ClaudeAgentHubSettings", () => {
await user.type(input, "/srv/claude"); await user.type(input, "/srv/claude");
const home = container.querySelector("section#claude-home") as HTMLElement; const home = container.querySelector("section#claude-home") as HTMLElement;
const reads = vi.mocked(api.settings.info).mock.calls.length; const reads = vi.mocked(api.settings.info).mock.calls.length;
const off = confirmYes();
await user.click(within(home).getByRole("button")); await user.click(within(home).getByRole("button"));
await waitFor(() => expect(onHomeChanged).toHaveBeenCalledTimes(1)); await waitFor(() => expect(onHomeChanged).toHaveBeenCalledTimes(1));
off();
expect(vi.mocked(api.settings.info).mock.calls.length).toBeGreaterThan(reads); 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 { beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "i18next"; import i18n from "i18next";
import { HomeDirSetting, type HomeDirValue } from "../HomeDirSetting"; import { HomeDirSetting, type HomeDirValue } from "../HomeDirSetting";
import { settleConfirm, subscribeConfirm, type PendingConfirm } from "../../../lib/confirm";
beforeEach(() => { beforeEach(() => {
i18n.changeLanguage("en"); i18n.changeLanguage("en");
@@ -80,4 +81,42 @@ describe("HomeDirSetting", () => {
await user.click(screen.getByRole("button", { name: "Save" })); await user.click(screen.getByRole("button", { name: "Save" }));
expect(await screen.findByText("lane 3 not updated")).toBeInTheDocument(); 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 { PricingSettings } from "../PricingSettings";
import { api } from "../../../lib/api"; import { api } from "../../../lib/api";
import type { ModelPricing } from "../../../lib/types"; 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) => { vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>(); const actual = await importOriginal<Record<string, unknown>>();
@@ -113,16 +125,56 @@ describe("PricingSettings", () => {
vi.mocked(api.pricing.delete) vi.mocked(api.pricing.delete)
.mockReset() .mockReset()
.mockResolvedValue({} as never); .mockResolvedValue({} as never);
const confirm = answerConfirms(true);
const user = userEvent.setup(); const user = userEvent.setup();
render(<PricingSettings vendor="openai" />); render(<PricingSettings vendor="openai" />);
const row = (await screen.findByText("gpt-5.6-sol%")).closest("tr")!; const row = (await screen.findByText("gpt-5.6-sol%")).closest("tr")!;
await user.pointer({ keys: "[MouseRight]", target: row }); await user.pointer({ keys: "[MouseRight]", target: row });
await user.click(screen.getByRole("menuitem", { name: "Delete" })); 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.pointer({ keys: "[MouseRight]", target: row });
await user.click(screen.getByRole("menuitem", { name: "Edit" })); await user.click(screen.getByRole("menuitem", { name: "Edit" }));
expect(screen.getByDisplayValue("GPT-5.6 Sol")).toBeInTheDocument(); 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).", "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.", "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." "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": { "feed": {
"title": "Alert feed", "title": "Alert feed",
@@ -76,5 +80,12 @@
"emptyTitle": "No alerts", "emptyTitle": "No alerts",
"emptyDescription": "Alerts fired by your rules will appear here in real time.", "emptyDescription": "Alerts fired by your rules will appear here in real time.",
"emptyUnacked": "Everything is acknowledged. New alerts will appear here as they fire." "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", "user": "User",
"project": "Project", "project": "Project",
"projectLocal": "Project (local)", "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": { "tabs": {
"overview": "Overview", "overview": "Overview",
@@ -155,7 +157,9 @@
"fileMissing": "(no settings file at this scope)" "fileMissing": "(no settings file at this scope)"
}, },
"settings": { "settings": {
"fileMissing": "(no settings file at this scope)" "fileMissing": "(no settings file at this scope)",
"structured": "Structured",
"rawJson": "Raw JSON"
}, },
"memory": { "memory": {
"userScope": "User memory", "userScope": "User memory",
@@ -222,7 +226,17 @@
"output-styles": "---\nname: my-style\ndescription: Output style description\n---\n\nStyle instructions.\n", "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", "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" "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": { "backups": {
"title": "Backups", "title": "Backups",
@@ -230,7 +244,7 @@
"viewLink": "Show backup history", "viewLink": "Show backup history",
"openButton": "Backups", "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.", "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", "size": "Size",
"created": "Created", "created": "Created",
"type": "Type", "type": "Type",
@@ -268,7 +282,9 @@
"renameLabel": "Profile name", "renameLabel": "Profile name",
"renameHint": "Click to rename", "renameHint": "Click to rename",
"rename": "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": { "explain": {
"hooks": { "hooks": {
@@ -390,6 +406,42 @@
"settingsBadJson": "Value of {{key}} is not valid JSON.", "settingsBadJson": "Value of {{key}} is not valid JSON.",
"settingsSaved": "Settings saved to {{file}}.", "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.", "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", "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.", "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.", "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": { "snippet": {
"copy": "Copy", "copy": "Copy",
+118 -18
View File
@@ -20,13 +20,46 @@
"upload": "Upload your own…", "upload": "Upload your own…",
"import": "Import .codex-pet bundle…", "import": "Import .codex-pet bundle…",
"browse": "Find more on codex-pets.net", "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", "bubbles": "Speech bubbles",
"bubblesDesc": "Let the pet comment on your sessions in a bubble", "bubblesDesc": "Let the pet comment on your sessions in a bubble",
"sizeDesc": "{{min}}–{{max}} px; the preview is capped at 120 px", "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.", "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": { "pricing": {
"vendor": { "vendor": {
@@ -85,12 +118,17 @@
} }
}, },
"autoBadge": "Auto", "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": { "claudeHome": {
"title": "CLAUDE_HOME", "title": "CLAUDE_HOME",
"description": "Root directory for Claude Code session data. Changing this updates the scan path for transcripts and imports immediately.", "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": { "hooks": {
"title": "Hook Configuration", "title": "Hook Configuration",
@@ -106,7 +144,16 @@
"description": "Machine-wide workspace housekeeping.", "description": "Machine-wide workspace housekeeping.",
"runHousekeeping": "Run housekeeping", "runHousekeeping": "Run housekeeping",
"dryRun": "Dry run", "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": { "notifications": {
"awaitingInput": "Claude needs confirmation", "awaitingInput": "Claude needs confirmation",
@@ -127,7 +174,7 @@
"testFailed": "Nothing could be shown.", "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.", "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.", "testSent": "Sent via push — check your notification centre.",
"testTitle": "Agent Monitor", "testTitle": "AgentHub",
"title": "Notifications", "title": "Notifications",
"turnComplete": "Claude finishes a turn", "turnComplete": "Claude finishes a turn",
"sound": { "sound": {
@@ -140,7 +187,16 @@
"pop": "Pop", "pop": "Pop",
"alert": "Alert", "alert": "Alert",
"forType": "Sound for this type" "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": { "import": {
"title": "Import History", "title": "Import History",
@@ -230,7 +286,10 @@
"runCleanup": "Run Cleanup", "runCleanup": "Run Cleanup",
"nothingToClean": "Nothing to clean up", "nothingToClean": "Nothing to clean up",
"abandonedResult": " sessions abandoned", "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": { "danger": {
"title": "Danger Zone", "title": "Danger Zone",
@@ -238,7 +297,11 @@
"warning": "This will delete all sessions, agents, events, and token data. Are you sure?", "warning": "This will delete all sessions, agents, events, and token data. Are you sure?",
"yesClearAll": "Yes, Clear All", "yesClearAll": "Yes, Clear All",
"clearAllData": "Clear All Data", "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": { "about": {
"title": "About", "title": "About",
@@ -322,7 +385,11 @@
"deleteMessage": "Delete \"{{name}}\"? Fired alerts will stop being delivered to this target. This cannot be undone.", "deleteMessage": "Delete \"{{name}}\"? Fired alerts will stop being delivered to this target. This cannot be undone.",
"guideToggle": "How to set up {{provider}}", "guideToggle": "How to set up {{provider}}",
"guideDocs": "Official {{provider}} docs", "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": { "alertsHub": {
"title": "Alerts", "title": "Alerts",
@@ -493,7 +560,8 @@
"purgeData": "Also delete the sessions imported from this source (cannot be undone)", "purgeData": "Also delete the sessions imported from this source (cannot be undone)",
"deleteAndPurge": "Remove + delete data", "deleteAndPurge": "Remove + delete data",
"removeKeep": "Remove (keep 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).", "cursorPathsNote": "Informational: Cursor sessions count here too — Cursor happens to use the same ~/.claude paths as Claude Code (locally and on synced remotes).",
"aiRouter": { "aiRouter": {
@@ -579,7 +647,11 @@
"darkThemes": "Dark themes", "darkThemes": "Dark themes",
"lightThemes": "Light themes", "lightThemes": "Light themes",
"active": "· in use now", "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": { "gitCreds": {
"title": "Git credentials", "title": "Git credentials",
@@ -597,7 +669,9 @@
"colUser": "Username", "colUser": "Username",
"colSaved": "Saved", "colSaved": "Saved",
"empty": "No git login saved. The git bar asks for one the first time a host needs it.", "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": { "homeDir": {
"save": "Save", "save": "Save",
@@ -660,7 +734,11 @@
"missing": "This file does not exist yet", "missing": "This file does not exist yet",
"save": "Save", "save": "Save",
"close": "Close", "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": { "settingsNav": {
"label": "Settings sections", "label": "Settings sections",
@@ -669,12 +747,13 @@
"general": "General", "general": "General",
"agents": "Agents", "agents": "Agents",
"routing": "Routing", "routing": "Routing",
"git": "Git" "git": "Git",
"cost": "Cost"
}, },
"section": { "section": {
"general": { "general": {
"title": "General", "title": "General",
"description": "Workspace housekeeping, data and about." "description": "The dashboard's display language."
}, },
"appearance": { "appearance": {
"title": "Appearance", "title": "Appearance",
@@ -718,12 +797,23 @@
"worktrees": { "worktrees": {
"title": "Worktrees", "title": "Worktrees",
"description": "Which untracked git worktrees a workspace's Add folder suggests." "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": { "search": {
"placeholder": "Search settings", "placeholder": "Search settings",
"noMatch": "No setting matches “{{query}}”." "noMatch": "No setting matches “{{query}}”.",
"results": "Search results"
}, },
"worktrees": { "worktrees": {
"title": "Worktree suggestions", "title": "Worktree suggestions",
@@ -751,5 +841,15 @@
"copyPath": "Copy path", "copyPath": "Copy path",
"enable": "Enable", "enable": "Enable",
"disable": "Disable" "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...", "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.", "lastPluginUpdateFailed": "The last plugin update failed at step \"{{step}}\": {{error}} — run /agenthub-update in Claude Code to recover.",
"restartingShort": "Restarting…", "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).", "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.", "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ờ." "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": { "feed": {
"title": "Luồng cảnh báo", "title": "Luồng cảnh báo",
@@ -76,5 +80,12 @@
"emptyTitle": "Không có cảnh báo", "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.", "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." "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", "user": "Người dùng",
"project": "Dự án", "project": "Dự án",
"projectLocal": "Dự án (cục bộ)", "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": { "tabs": {
"overview": "Tổng quan", "overview": "Tổng quan",
@@ -150,7 +152,9 @@
"fileMissing": "(không có file settings trong phạm vi này)" "fileMissing": "(không có file settings trong phạm vi này)"
}, },
"settings": { "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": { "memory": {
"userScope": "Bộ nhớ người dùng", "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", "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", "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" "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": { "backups": {
"title": "Bản sao lưu", "title": "Bản sao lưu",
@@ -225,7 +239,7 @@
"viewLink": "Xem lịch sử sao lưu", "viewLink": "Xem lịch sử sao lưu",
"openButton": "Bản 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.", "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", "size": "Kích thước",
"created": "Tạo lúc", "created": "Tạo lúc",
"type": "Loại", "type": "Loại",
@@ -263,7 +277,9 @@
"renameLabel": "Tên profile", "renameLabel": "Tên profile",
"renameHint": "Bấm để đổi tên", "renameHint": "Bấm để đổi tên",
"rename": "Đổ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": { "explain": {
"hooks": { "hooks": {
@@ -385,6 +401,42 @@
"settingsBadJson": "Giá trị của {{key}} không phải JSON hợp lệ.", "settingsBadJson": "Giá trị của {{key}} không phải JSON hợp lệ.",
"settingsSaved": "Đã lưu settings vào {{file}}.", "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.", "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", "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.", "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.", "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": { "snippet": {
"copy": "Sao chép", "copy": "Sao chép",
+120 -20
View File
@@ -20,13 +20,46 @@
"upload": "Tải ảnh của bạn lên…", "upload": "Tải ảnh của bạn lên…",
"import": "Nhập bundle .codex-pet…", "import": "Nhập bundle .codex-pet…",
"browse": "Tìm thêm trên codex-pets.net", "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", "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", "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", "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.", "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": { "pricing": {
"vendor": { "vendor": {
@@ -62,8 +95,8 @@
}, },
"cacheWrite5m": "Ghi bộ nhớ đệm (5m)", "cacheWrite5m": "Ghi bộ nhớ đệm (5m)",
"cacheWrite1h": "Ghi bộ nhớ đệm (1h)", "cacheWrite1h": "Ghi bộ nhớ đệm (1h)",
"fastInput": "Fast Input", "fastInput": "Đầu vào nhanh",
"fastOutput": "Fast Output", "fastOutput": "Đầu ra nhanh",
"showAll": "Hiện mọi model (thêm {{count}})", "showAll": "Hiện mọi model (thêm {{count}})",
"empty": { "empty": {
"claude": "Chưa có giá model Claude. Nút Khôi phục mặc định sẽ đưa chúng trở lại.", "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", "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": { "claudeHome": {
"title": "CLAUDE_HOME", "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.", "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": { "hooks": {
"title": "Cấu hình hook", "title": "Cấu hình hook",
@@ -106,7 +144,16 @@
"description": "Dọn dẹp workspace trên toàn máy.", "description": "Dọn dẹp workspace trên toàn máy.",
"runHousekeeping": "Chạy dọn dẹp", "runHousekeeping": "Chạy dọn dẹp",
"dryRun": "Chạy thử (dry run)", "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": { "notifications": {
"awaitingInput": "Claude cần xác nhận", "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.", "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ý.", "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.", "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", "title": "Thông báo",
"turnComplete": "Claude trả lời xong", "turnComplete": "Claude trả lời xong",
"sound": { "sound": {
@@ -140,7 +187,16 @@
"pop": "Pop", "pop": "Pop",
"alert": "Cảnh báo", "alert": "Cảnh báo",
"forType": "Âm thanh cho loại này" "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": { "import": {
"title": "Nhập lịch sử", "title": "Nhập lịch sử",
@@ -230,7 +286,10 @@
"runCleanup": "Chạy dọn dẹp", "runCleanup": "Chạy dọn dẹp",
"nothingToClean": "Không có gì để dọn dẹp", "nothingToClean": "Không có gì để dọn dẹp",
"abandonedResult": " phiên đã bị bỏ dở", "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": { "danger": {
"title": "Vùng nguy hiểm", "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ứ?", "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ả", "yesClearAll": "Có, xóa tất cả",
"clearAllData": "Xóa toàn bộ dữ liệu", "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": { "about": {
"title": "Giới thiệu", "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.", "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}}", "guideToggle": "Cách thiết lập {{provider}}",
"guideDocs": "Tài liệu chính thức của {{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": { "alertsHub": {
"title": "Cảnh báo", "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)", "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", "deleteAndPurge": "Xóa + xóa dữ liệu",
"removeKeep": "Xóa (giữ 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ộ).", "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": { "aiRouter": {
@@ -579,7 +647,11 @@
"darkThemes": "Theme tối", "darkThemes": "Theme tối",
"lightThemes": "Theme sáng", "lightThemes": "Theme sáng",
"active": "· đang dù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": { "gitCreds": {
"title": "Git credentials", "title": "Git credentials",
@@ -597,7 +669,9 @@
"colUser": "Tên đăng nhập", "colUser": "Tên đăng nhập",
"colSaved": "Đã lưu", "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.", "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": { "homeDir": {
"save": "Lưu", "save": "Lưu",
@@ -660,7 +734,11 @@
"missing": "File này chưa có", "missing": "File này chưa có",
"save": "Lưu", "save": "Lưu",
"close": "Đóng", "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": { "settingsNav": {
"label": "Các mục cài đặt", "label": "Các mục cài đặt",
@@ -669,12 +747,13 @@
"general": "Chung", "general": "Chung",
"agents": "Tác tử", "agents": "Tác tử",
"routing": "Định tuyến", "routing": "Định tuyến",
"git": "Git" "git": "Git",
"cost": "Chi phí"
}, },
"section": { "section": {
"general": { "general": {
"title": "Chung", "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": { "appearance": {
"title": "Giao diện", "title": "Giao diện",
@@ -718,12 +797,23 @@
"worktrees": { "worktrees": {
"title": "Worktree", "title": "Worktree",
"description": "Những git worktree chưa theo dõi được gợi ý khi thêm thư mục cho workspace." "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": { "search": {
"placeholder": "Tìm cài đặt", "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": { "worktrees": {
"title": "Gợi ý worktree", "title": "Gợi ý worktree",
@@ -751,5 +841,15 @@
"copyPath": "Sao chép đường dẫn", "copyPath": "Sao chép đường dẫn",
"enable": "Bật", "enable": "Bật",
"disable": "Tắ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...", "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.", "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…", "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 * @file settingsSearch.test.ts
* @description `rankSettings`: an empty query matches nothing, a section title * @description `rankSettings`: an empty query matches nothing, a section title
* outranks an entry title, which outranks a description and a keyword, exact * 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> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { rankSettings, type SettingsEntry } from "../settingsSearch"; import {
rankSettings,
rankSettingsEntries,
SETTINGS_INDEX,
type SettingsEntry,
} from "../settingsSearch";
const entries: SettingsEntry[] = [ const entries: SettingsEntry[] = [
{ section: "pricing", title: "Pricing", sectionTitle: true }, { section: "pricing", title: "Pricing", sectionTitle: true },
@@ -38,3 +45,42 @@ describe("rankSettings", () => {
expect(rankSettings("zzz", entries)).toEqual([]); 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 * `rankSettings` scores sections against a query. Per field, exact beats
* prefix beats substring: section title 900/850/800, entry title 700/650/600, * 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. * 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> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -14,9 +16,14 @@ export interface SettingsEntry {
keywords?: string[]; keywords?: string[];
/** The section's own title (scored in the top tier). */ /** The section's own title (scored in the top tier). */
sectionTitle?: boolean; 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 * section whose id starts with `agents/` and is not listed here is a catalog
* tool's page and takes the `agents/*` entries. */ * tool's page and takes the `agents/*` entries. */
export const SETTINGS_INDEX: { export const SETTINGS_INDEX: {
@@ -24,16 +31,64 @@ export const SETTINGS_INDEX: {
titleKey: string; titleKey: string;
descKey?: string; descKey?: string;
keywords?: string[]; keywords?: string[];
to?: string;
}[] = [ }[] = [
{ {
section: "general", section: "general",
titleKey: "language.title", titleKey: "language.title",
keywords: ["english", "tiếng việt", "vietnamese", "ngôn ngữ"], keywords: ["english", "tiếng việt", "vietnamese", "ngôn ngữ"],
}, },
{ section: "general", titleKey: "lanesSection.title", descKey: "lanesSection.description" }, {
{ section: "general", titleKey: "lanesSection.runHousekeeping" }, section: "data",
{ section: "general", titleKey: "import.title", keywords: ["history", "transcripts"] }, titleKey: "lanesSection.title",
{ section: "general", titleKey: "remoteSources.title", keywords: ["ssh", "machines"] }, 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", section: "pet",
titleKey: "sagi.title", titleKey: "sagi.title",
@@ -43,17 +98,41 @@ export const SETTINGS_INDEX: {
{ section: "pet", titleKey: "sagi.bubbles", descKey: "sagi.bubblesDesc" }, { section: "pet", titleKey: "sagi.bubbles", descKey: "sagi.bubblesDesc" },
{ section: "pet", titleKey: "sagi.pet.size" }, { section: "pet", titleKey: "sagi.pet.size" },
{ section: "pet", titleKey: "sagi.pet.choose", keywords: ["codex-pets", "upload", "import"] }, { 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.mode", keywords: ["dark", "light", "system"] },
{ section: "appearance", titleKey: "appearance.darkThemes", keywords: ["theme", "colors"] }, { section: "appearance", titleKey: "appearance.darkThemes", keywords: ["theme", "colors"] },
{ section: "appearance", titleKey: "appearance.lightThemes" }, { section: "appearance", titleKey: "appearance.lightThemes" },
{ section: "notifications", titleKey: "notifications.enable", keywords: ["push", "browser"] }, {
{ section: "notifications", titleKey: "notifications.awaitingInput" }, section: "notifications",
{ section: "notifications", titleKey: "notifications.turnComplete" }, titleKey: "notifications.enable",
{ section: "notifications", titleKey: "alertsHub.title", descKey: "alertsHub.description" }, keywords: ["push", "browser"],
{ section: "notifications", titleKey: "webhooks.title", keywords: ["slack", "discord"] }, 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", section: "agents",
titleKey: "aiTools.title", 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: "pricing.title", keywords: ["cost", "price"] },
{ section: "agents/claude-code", titleKey: "hooks.title" }, { 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: "pricing.title", keywords: ["cost", "price"] },
{ section: "agents/codex", titleKey: "codexHooks.title" }, { section: "agents/codex", titleKey: "codexHooks.title" },
{ section: "agents/codex", titleKey: "codexHome.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: "agents/*", titleKey: "toolTab.defaultModel", keywords: ["model", "config"] },
{ section: "ai-router", titleKey: "aiRouter.title", keywords: ["proxy", "endpoint", "key"] }, { section: "ai-router", titleKey: "aiRouter.title", keywords: ["proxy", "endpoint", "key"] },
{ section: "pricing", titleKey: "pricing.title", keywords: ["cost", "price", "tokens"] }, { section: "pricing", titleKey: "pricing.title", keywords: ["cost", "price", "tokens"] },
{ section: "git-credentials", titleKey: "gitCreds.title", keywords: ["login", "token"] }, { 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.claude" },
{ section: "worktrees", titleKey: "worktrees.agenthub" }, { section: "worktrees", titleKey: "worktrees.agenthub" },
]; ];
@@ -84,6 +237,30 @@ function tier(hay: string | undefined, q: string, exact: number): number {
return 0; 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. */ /** Sections matching `query`, best first; ties keep the entries' order. */
export function rankSettings( export function rankSettings(
query: string, query: string,
@@ -93,11 +270,7 @@ export function rankSettings(
if (!q) return []; if (!q) return [];
const best = new Map<string, number>(); const best = new Map<string, number>();
for (const e of entries) { for (const e of entries) {
const score = Math.max( const score = scoreEntry(e, q);
tier(e.title, q, e.sectionTitle ? 900 : 700),
tier(e.description, q, 500),
...(e.keywords ?? []).map((k) => tier(k, q, 300))
);
if (score > (best.get(e.section) ?? 0)) best.set(e.section, score); if (score > (best.get(e.section) ?? 0)) best.set(e.section, score);
} }
return [...best] 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 * 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 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) * A right-click on a profile row opens its Edit / Remove (plus Copy endpoint)
* as a menu; the buttons stay. * as a menu; the buttons stay.
* *
@@ -25,11 +28,14 @@
* *
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @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 { useTranslation } from "react-i18next";
import { Plus, Pencil, Trash2, Lock } from "lucide-react"; import { Plus, Pencil, Trash2, Lock } from "lucide-react";
import { api, type AiRouter, type AiRouterInput, type AiRouterModels } from "../lib/api"; import { api, type AiRouter, type AiRouterInput, type AiRouterModels } from "../lib/api";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../components/ContextMenu"; 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"]; const TIERS: (keyof AiRouterModels)[] = ["opus", "sonnet", "haiku", "smallFast", "subagent"];
@@ -54,6 +60,10 @@ export function AiRouterConfig() {
const [form, setForm] = useState(emptyForm()); const [form, setForm] = useState(emptyForm());
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [busy, setBusy] = 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 // 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 // every field change: a green "works" left sitting above an edited base URL
// is a claim about a configuration that no longer exists. // is a claim about a configuration that no longer exists.
@@ -98,6 +108,7 @@ export function AiRouterConfig() {
const startAdd = () => { const startAdd = () => {
setEditing(null); setEditing(null);
setForm(emptyForm()); setForm(emptyForm());
setFormError(null);
setOpen(true); setOpen(true);
}; };
@@ -111,6 +122,7 @@ export function AiRouterConfig() {
model: item.model, model: item.model,
models: { ...item.models }, models: { ...item.models },
}); });
setFormError(null);
setOpen(true); setOpen(true);
}; };
@@ -148,7 +160,7 @@ export function AiRouterConfig() {
const submit = async () => { const submit = async () => {
setBusy(true); setBusy(true);
setError(null); setFormError(null);
try { try {
// Send only the tiers that differ from the headline model, so the server // 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 // keeps filling the rest from it — that is what makes one symbolic name
@@ -172,14 +184,20 @@ export function AiRouterConfig() {
setOpen(false); setOpen(false);
await load(); await load();
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : String(e)); setFormError(e instanceof Error ? e.message : String(e));
} finally { } finally {
setBusy(false); setBusy(false);
} }
}; };
const remove = async (item: AiRouter) => { 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); setError(null);
try { try {
await api.aiRouters.remove(item.name); 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"> <ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="ai-router-menu">
<MenuItem onSelect={pick(startEdit)}>{t("aiRouter.edit")}</MenuItem> <MenuItem onSelect={pick(startEdit)}>{t("aiRouter.edit")}</MenuItem>
{ctx.menu.item.baseUrl && ( {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")} {t("rowMenu.copyUrl")}
</MenuItem> </MenuItem>
)} )}
@@ -301,16 +319,17 @@ export function AiRouterConfig() {
{open && ( {open && (
<div <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" role="presentation"
> >
<div <div
ref={dialogRef}
role="dialog" role="dialog"
aria-modal="true" 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" 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")} {editing ? t("aiRouter.edit") : t("aiRouter.add")}
</h3> </h3>
@@ -375,6 +394,15 @@ export function AiRouterConfig() {
</details> </details>
</div> </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 && ( {testResult && (
<p <p
data-testid="router-test-result" data-testid="router-test-result"
@@ -409,7 +437,7 @@ export function AiRouterConfig() {
type="button" type="button"
onClick={submit} onClick={submit}
disabled={busy || !form.label.trim() || !form.baseUrl.trim() || !form.model.trim()} 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")} {t("aiRouter.save")}
</button> </button>
@@ -451,7 +479,7 @@ function Field({
value={value} value={value}
placeholder={placeholder} placeholder={placeholder}
onChange={(e) => onChange(e.target.value)} 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" : "" mono ? "font-mono" : ""
}`} }`}
/> />
+58 -6
View File
@@ -7,7 +7,10 @@
* default model (`PUT /api/ai-tools/:id {defaultModel}`) for a tool with a * 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 * 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 * 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> * @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 { useTranslation } from "react-i18next";
import { Cpu, ExternalLink, FileCog, RefreshCw } from "lucide-react"; import { Cpu, ExternalLink, FileCog, RefreshCw } from "lucide-react";
import { api, type AiToolConfigFile } from "../lib/api"; 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 type { Lane } from "../lib/types";
import { useAiTools } from "../hooks/useAiTools"; import { useAiTools } from "../hooks/useAiTools";
import { AiToolIcon } from "../components/AiToolIcon"; import { AiToolIcon } from "../components/AiToolIcon";
@@ -39,6 +46,7 @@ function DefaultModel({ toolId, saved }: { toolId: string; saved: string }) {
setError(null); setError(null);
try { try {
await aiTools.update(toolId, { defaultModel: value || null }); await aiTools.update(toolId, { defaultModel: value || null });
toast.success(t("toolTab.defaultModelSaved"));
} catch (err) { } catch (err) {
setError(message(err)); setError(message(err));
} }
@@ -70,8 +78,14 @@ function ConfigFiles({ toolId }: { toolId: string }) {
const [files, setFiles] = useState<AiToolConfigFile[] | null>(null); const [files, setFiles] = useState<AiToolConfigFile[] | null>(null);
const [open, setOpen] = useState<{ index: number; path: string; sha: string } | null>(null); const [open, setOpen] = useState<{ index: number; path: string; sha: string } | null>(null);
const [text, setText] = useState(""); const [text, setText] = useState("");
// The text as opened/saved: an edit differs from it.
const [base, setBase] = useState("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [note, setNote] = useState<{ ok: boolean; text: string } | null>(null); 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(() => { useEffect(() => {
api.aiTools api.aiTools
@@ -80,26 +94,42 @@ function ConfigFiles({ toolId }: { toolId: string }) {
.catch((err) => setNote({ ok: false, text: message(err) })); .catch((err) => setNote({ ok: false, text: message(err) }));
}, [toolId]); }, [toolId]);
const openFile = async (f: AiToolConfigFile) => { const load = async (index: number) => {
setNote(null); setNote(null);
setStale(false);
try { try {
const r = await api.aiTools.readConfig(toolId, f.index); const r = await api.aiTools.readConfig(toolId, index);
setOpen({ index: f.index, path: r.path, sha: r.sha }); setOpen({ index, path: r.path, sha: r.sha });
setText(r.text); setText(r.text);
setBase(r.text);
} catch (err) { } catch (err) {
setNote({ ok: false, text: message(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 () => { const save = async () => {
if (!open) return; if (!open) return;
setBusy(true); setBusy(true);
setNote(null); setNote(null);
setStale(false);
try { try {
const r = await api.aiTools.saveConfig(toolId, open.index, text, open.sha); const r = await api.aiTools.saveConfig(toolId, open.index, text, open.sha);
setOpen({ ...open, sha: r.sha }); setOpen({ ...open, sha: r.sha });
setBase(text);
setNote({ ok: true, text: t("toolTab.saved", { backup: r.backup }) }); setNote({ ok: true, text: t("toolTab.saved", { backup: r.backup }) });
} catch (err) { } catch (err) {
setStale((err as { code?: string }).code === "ESTALE");
setNote({ ok: false, text: message(err) }); setNote({ ok: false, text: message(err) });
} finally { } finally {
setBusy(false); setBusy(false);
@@ -141,16 +171,38 @@ function ConfigFiles({ toolId }: { toolId: string }) {
<button <button
type="button" type="button"
onClick={save} onClick={save}
disabled={busy} disabled={busy || !dirty}
className="btn-ghost text-xs disabled:opacity-50" className="btn-ghost text-xs disabled:opacity-50"
> >
{busy && <RefreshCw className="h-3.5 w-3.5 animate-spin" />} {busy && <RefreshCw className="h-3.5 w-3.5 animate-spin" />}
{t("toolTab.save")} {t("toolTab.save")}
</button> </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")} {t("toolTab.close")}
</button> </button>
</div> </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> </div>
)} )}
{note && ( {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 { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useSearchParams } from "react-router-dom";
import { eventBus } from "../lib/eventBus"; import { eventBus } from "../lib/eventBus";
import { import {
Boxes, Boxes,
@@ -53,7 +54,10 @@ import {
ConfirmDeleteModal, ConfirmDeleteModal,
type ConfirmDeleteState, type ConfirmDeleteState,
} from "../components/config/ConfirmDeleteModal"; } 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 { ScopeBadge } from "../components/config/ScopeBadge";
import { FileItemMenu } from "../components/config/MdItemList"; import { FileItemMenu } from "../components/config/MdItemList";
import { import {
@@ -98,17 +102,19 @@ interface TabDef {
key: TabKey; key: TabKey;
icon: typeof Sparkles; icon: typeof Sparkles;
i18nKey: string; i18nKey: string;
/** i18n key (in ccConfig) of the tab's group label. */
group?: string;
} }
const TABS: TabDef[] = [ const TABS: TabDef[] = [
{ key: "overview", icon: Boxes, i18nKey: "tabs.overview" }, { key: "overview", icon: Boxes, i18nKey: "tabs.overview" },
{ key: "skills", icon: Sparkles, i18nKey: "tabs.skills" }, { key: "skills", icon: Sparkles, i18nKey: "tabs.skills", group: "tabGroups.content" },
{ key: "agents", icon: UserRound, i18nKey: "tabs.agents" }, { key: "agents", icon: UserRound, i18nKey: "tabs.agents", group: "tabGroups.content" },
{ key: "rules", icon: FileText, i18nKey: "tabs.rules" }, { key: "rules", icon: FileText, i18nKey: "tabs.rules", group: "tabGroups.content" },
{ key: "memory", icon: BookOpen, i18nKey: "tabs.memory" }, { key: "memory", icon: BookOpen, i18nKey: "tabs.memory", group: "tabGroups.content" },
{ key: "config", icon: SettingsIcon, i18nKey: "tabs.config" }, { key: "mcp", icon: Server, i18nKey: "tabs.mcp", group: "tabGroups.extensions" },
{ key: "mcp", icon: Server, i18nKey: "tabs.mcp" }, { key: "plugins", icon: PlugZap, i18nKey: "tabs.plugins", group: "tabGroups.extensions" },
{ key: "plugins", icon: PlugZap, i18nKey: "tabs.plugins" }, { key: "config", icon: SettingsIcon, i18nKey: "tabs.config", group: "tabGroups.config" },
]; ];
function isNamedArtifact(tab: TabKey): tab is "skills" | "agents" | "rules" { function isNamedArtifact(tab: TabKey): tab is "skills" | "agents" | "rules" {
@@ -159,7 +165,24 @@ interface NamedArtifactItem {
export function CodexConfig() { export function CodexConfig() {
const { t } = useTranslation("codexConfig"); const { t } = useTranslation("codexConfig");
const { t: tSettings } = useTranslation("settings"); 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. // Bumped by Refresh: the AgentHub cards load on mount, so a new key reloads them.
const [agenthubKey, setAgenthubKey] = useState(0); const [agenthubKey, setAgenthubKey] = useState(0);
// Bumped after a CODEX_HOME save: the hooks card reads the new home's hooks.json. // 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 [backupsOpen, setBackupsOpen] = useState(false);
const [projectMemoryOpen, setProjectMemoryOpen] = useState(false); const [projectMemoryOpen, setProjectMemoryOpen] = useState(false);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null); 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<{ const [viewer, setViewer] = useState<{
path: string; path: string;
data: CodexFileResponse | null; data: CodexFileResponse | null;
@@ -184,13 +210,7 @@ export function CodexConfig() {
const [editor, setEditor] = useState<EditorState<CodexArtifactType, "user" | "project">>(null); const [editor, setEditor] = useState<EditorState<CodexArtifactType, "user" | "project">>(null);
const [confirmDelete, setConfirmDelete] = const [confirmDelete, setConfirmDelete] =
useState<ConfirmDeleteState<CodexArtifactType, "user" | "project">>(null); useState<ConfirmDeleteState<CodexArtifactType, "user" | "project">>(null);
const [toast, setToast] = useState<Toast>(null); const setToast = showToast;
useEffect(() => {
if (!toast) return;
const id = setTimeout(() => setToast(null), 5000);
return () => clearTimeout(id);
}, [toast]);
const fetchAll = useCallback(async () => { const fetchAll = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -269,6 +289,12 @@ export function CodexConfig() {
}, []); }, []);
const wsConnected = useSyncExternalStore(eventBus.onConnection, () => eventBus.connected); 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) => { const openViewer = useCallback(async (path: string) => {
setViewer({ path, data: null, error: null }); setViewer({ path, data: null, error: null });
@@ -285,10 +311,10 @@ export function CodexConfig() {
(type: CodexArtifactType, overrideScope?: "user" | "project") => { (type: CodexArtifactType, overrideScope?: "user" | "project") => {
const template = t(`edit.templates.${type}`); const template = t(`edit.templates.${type}`);
const defaultScope: "user" | "project" = const defaultScope: "user" | "project" =
overrideScope ?? (scope === "project" ? "project" : "user"); overrideScope ?? (scope === "project" && !projectDisabled ? "project" : "user");
setEditor({ mode: "create", type, defaultScope, template }); setEditor({ mode: "create", type, defaultScope, template });
}, },
[scope, t] [scope, t, projectDisabled]
); );
const openEdit = useCallback( const openEdit = useCallback(
@@ -381,8 +407,6 @@ export function CodexConfig() {
<Header <Header
loading={loading} loading={loading}
lastUpdated={lastUpdated} lastUpdated={lastUpdated}
scope={scope}
onScopeChange={setScope}
onRefresh={() => { onRefresh={() => {
void fetchAll(); void fetchAll();
setAgenthubKey((k) => k + 1); setAgenthubKey((k) => k + 1);
@@ -400,29 +424,46 @@ export function CodexConfig() {
<Tabs current={tab} onSelect={setTab} counts={data.overview?.counts} /> <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" && ( {tab !== "overview" && tab !== "memory" && tab !== "config" && (
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5"> <div className="flex flex-wrap items-center gap-2 border-b border-border px-4 py-2.5">
<Search className="w-4 h-4 text-fg-muted" /> <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">
<input <Search className="w-4 h-4 text-fg-muted" aria-hidden />
value={search} <input
onChange={(e) => setSearch(e.target.value)} value={search}
placeholder={t("common.search")} onChange={(e) => setSearch(e.target.value)}
className="h-7 bg-transparent text-sm text-fg-primary placeholder:text-fg-muted focus:outline-none flex-1" placeholder={t("common.search")}
/> aria-label={t("common.search")}
{search && ( className="h-7 bg-transparent text-sm text-fg-primary placeholder:text-fg-muted focus:outline-none flex-1"
<button />
type="button" {search && (
onClick={() => setSearch("")} <button
title={t("common.clearSearch")} type="button"
aria-label={t("common.clearSearch")} onClick={() => setSearch("")}
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" title={t("common.clearSearch")}
> aria-label={t("common.clearSearch")}
<X className="w-3.5 h-3.5" /> 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"
</button> >
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
{isNamedArtifact(tab) && (
<ScopeToggle
value={scope}
onChange={setScope}
projectRoot={projectRoot}
projectDisabled={projectDisabled}
/>
)} )}
{isNamedArtifact(tab) && ( {isNamedArtifact(tab) && (
<button <button
type="button"
onClick={() => openCreate(tab)} 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" 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 && ( {editor && (
<EditorModal<CodexArtifactType, "user" | "project"> <EditorModal<CodexArtifactType, "user" | "project">
state={editor} state={editor}
fetchContent={(p) => api.codexConfig.file(p).then((r) => r.text)} fetchContent={(p) => api.codexConfig.file(p)}
onClose={() => setEditor(null)} onClose={() => setEditor(null)}
onSave={handleSave} onSave={handleSave}
namelessTypes={NAMELESS_TYPES} namelessTypes={NAMELESS_TYPES}
projectDisabled={projectDisabled}
/> />
)} )}
{confirmDelete && ( {confirmDelete && (
@@ -522,7 +564,6 @@ export function CodexConfig() {
onCreate={handleCreateProjectMemory} onCreate={handleCreateProjectMemory}
/> />
)} )}
{toast && <ToastNotice toast={toast} onDismiss={() => setToast(null)} />}
</div> </div>
); );
} }
@@ -532,22 +573,12 @@ export function CodexConfig() {
interface HeaderProps { interface HeaderProps {
loading: boolean; loading: boolean;
lastUpdated: Date | null; lastUpdated: Date | null;
scope: CodexScope;
onScopeChange: (s: CodexScope) => void;
onRefresh: () => void; onRefresh: () => void;
onOpenBackups: () => void; onOpenBackups: () => void;
wsConnected: boolean; wsConnected: boolean;
} }
function Header({ function Header({ loading, lastUpdated, onRefresh, onOpenBackups, wsConnected }: HeaderProps) {
loading,
lastUpdated,
scope,
onScopeChange,
onRefresh,
onOpenBackups,
wsConnected,
}: HeaderProps) {
const { t } = useTranslation("codexConfig"); const { t } = useTranslation("codexConfig");
const { t: tCommon } = useTranslation("common"); const { t: tCommon } = useTranslation("common");
const formatted = lastUpdated const formatted = lastUpdated
@@ -559,32 +590,25 @@ function Header({
: "-"; : "-";
return ( return (
<header className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between"> <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"> {/* No title of its own: the Settings hub already draws one. */}
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center flex-shrink-0"> <div className="flex items-start gap-2 min-w-0 flex-1">
<Boxes className="w-4.5 h-4.5 text-accent" /> {wsConnected ? (
</div> <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">
<div className="min-w-0"> <span className="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse-dot" />
<div className="flex items-center gap-2"> {tCommon("live")}
<h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1> </span>
{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="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-status-success animate-pulse-dot" /> <span className="w-1.5 h-1.5 rounded-full bg-surface-4" />
{tCommon("live")} {tCommon("offline")}
</span> </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"> <p className="text-xs text-fg-muted max-w-2xl">{t("subtitle")}</p>
<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>
</div> </div>
<div className="flex flex-col items-stretch lg:items-end gap-2 flex-shrink-0"> <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"> <div className="flex items-center gap-2 justify-end flex-wrap">
<ScopeToggle value={scope} onChange={onScopeChange} />
<button <button
type="button"
onClick={onOpenBackups} 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" 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")} {t("backups.openButton")}
</button> </button>
<button <button
type="button"
onClick={onRefresh} onClick={onRefresh}
disabled={loading} 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" 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({ function ScopeToggle({
value, value,
onChange, onChange,
projectRoot,
projectDisabled,
}: { }: {
value: CodexScope; value: CodexScope;
onChange: (s: CodexScope) => void; onChange: (s: CodexScope) => void;
projectRoot: string | null;
projectDisabled: boolean;
}) { }) {
const { t } = useTranslation("codexConfig"); const { t } = useTranslation("codexConfig");
const { t: tCc } = useTranslation("ccConfig");
const opts: { v: CodexScope; label: string }[] = [ const opts: { v: CodexScope; label: string }[] = [
{ v: "all", label: t("scope.all") }, { v: "all", label: t("scope.all") },
{ v: "user", label: t("scope.user") }, { v: "user", label: t("scope.user") },
{ v: "project", label: t("scope.project") }, { v: "project", label: t("scope.project") },
]; ];
return ( return (
<div className="inline-flex rounded-lg border border-border bg-surface-2 p-0.5"> <div className="flex items-center gap-2 min-w-0">
{opts.map((o) => ( <div
<button role="group"
key={o.v} aria-label={tCc("common.scopeLabel")}
onClick={() => onChange(o.v)} className="inline-flex rounded-lg border border-border bg-surface-2 p-0.5 flex-shrink-0"
className={`px-2.5 py-1 text-[11px] font-medium rounded-md transition-colors ${ >
value === o.v {opts.map((o) => {
? "bg-accent/20 text-accent border border-accent/30" const disabled = o.v === "project" && projectDisabled;
: "text-fg-secondary hover:text-fg-primary" 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} {tCc("scope.projectIs", { path: projectRoot })}
</button> </span>
))} )}
</div> </div>
); );
} }
@@ -661,6 +714,7 @@ function Tabs({
counts?: CodexOverview["counts"]; counts?: CodexOverview["counts"];
}) { }) {
const { t } = useTranslation("codexConfig"); const { t } = useTranslation("codexConfig");
const { t: tCc } = useTranslation("ccConfig");
const countFor = (key: TabKey): number | null => { const countFor = (key: TabKey): number | null => {
if (!counts) return null; if (!counts) return null;
switch (key) { switch (key) {
@@ -680,15 +734,17 @@ function Tabs({
}; };
return ( return (
<ConfigTabBar <ConfigTabBar
tabs={TABS.map(({ key, icon, i18nKey }) => ({ tabs={TABS.map(({ key, icon, i18nKey, group }) => ({
key, key,
icon, icon,
label: t(i18nKey), label: t(i18nKey),
count: countFor(key), count: countFor(key),
group: group ? tCc(group) : undefined,
}))} }))}
current={current} current={current}
onSelect={onSelect} onSelect={onSelect}
testIdPrefix="codex-tab" testIdPrefix="codex-tab"
panelId="codex-tabpanel"
ariaLabel={t("title")} ariaLabel={t("title")}
/> />
); );
@@ -1300,7 +1356,7 @@ function CliRowMenu({
<MenuItem <MenuItem
onSelect={() => { onSelect={() => {
onClose(); onClose();
void navigator.clipboard.writeText(item.label); void copyText(item.label);
}} }}
> >
{t("list.copyName")} {t("list.copyName")}
@@ -1756,27 +1812,29 @@ function BackupsModal({
.catch((err: unknown) => setError(err instanceof Error ? err.message : "unknown")); .catch((err: unknown) => setError(err instanceof Error ? err.message : "unknown"));
}, []); }, []);
useEffect(() => { const panelRef = useRef<HTMLDivElement>(null);
const onKey = (e: KeyboardEvent) => { useDialog(true, panelRef, onClose);
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
return ( return (
<div <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} onClick={onClose}
> >
<div <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" 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()} onClick={(e) => e.stopPropagation()}
> >
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5"> <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" /> <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 <button
type="button"
onClick={onClose} onClick={onClose}
className="text-fg-secondary hover:text-fg-primary p-1 rounded-md hover:bg-surface-3" className="text-fg-secondary hover:text-fg-primary p-1 rounded-md hover:bg-surface-3"
aria-label={t("common.close")} aria-label={t("common.close")}
@@ -1818,7 +1876,11 @@ function BackupRow({
}) { }) {
const { t } = useTranslation("codexConfig"); const { t } = useTranslation("codexConfig");
const scope = deriveScope(backup.file, roots); 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 ( return (
<div className="rounded-lg border border-border bg-surface-2 px-3 py-2.5"> <div className="rounded-lg border border-border bg-surface-2 px-3 py-2.5">
<div className="flex items-center gap-2 flex-wrap"> <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" /> <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> <code className="font-mono text-[11px] text-fg-secondary truncate flex-1">{command}</code>
<button <button
type="button"
onClick={async () => { onClick={async () => {
try { if (await copyText(command, true)) {
await navigator.clipboard.writeText(command);
setCopied(true); setCopied(true);
setTimeout(() => setCopied(false), 1500); 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" 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; const root = scope === "user" ? roots.codexHome : roots.projectCodexDir;
return `${root}/${b.type}/${baseName}`; 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 * 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. * 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 * 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> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
@@ -14,6 +16,9 @@ import { useTranslation } from "react-i18next";
import { Pencil, Plus, Trash2 } from "lucide-react"; import { Pencil, Plus, Trash2 } from "lucide-react";
import { api, type GitCredential } from "../lib/api"; import { api, type GitCredential } from "../lib/api";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../components/ContextMenu"; 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 = 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"; "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 { t } = useTranslation("settings");
const [items, setItems] = useState<GitCredential[]>([]); const [items, setItems] = useState<GitCredential[]>([]);
const [error, setError] = useState<string | null>(null); 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>(); const ctx = useContextMenu<GitCredential>();
// A menu item: close the menu, then act on the row it was opened on. // A menu item: close the menu, then act on the row it was opened on.
const pick = (fn: (c: GitCredential) => unknown) => () => { const pick = (fn: (c: GitCredential) => unknown) => () => {
@@ -36,6 +49,8 @@ export function GitCredentialsConfig() {
setItems((await api.gitCredentials.list()).items); setItems((await api.gitCredentials.list()).items);
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : String(e)); setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
} }
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -46,12 +61,15 @@ export function GitCredentialsConfig() {
if (!form) return; if (!form) return;
setError(null); setError(null);
let url: URL; let url: URL;
const typed = form.url.trim();
try { 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 { } catch {
setError(t("gitCreds.badUrl")); setError(t("gitCreds.badUrl"));
return; return;
} }
setSaving(true);
try { try {
const r = await api.gitCredentials.save({ const r = await api.gitCredentials.save({
protocol: url.protocol.replace(/:$/, ""), protocol: url.protocol.replace(/:$/, ""),
@@ -61,16 +79,25 @@ export function GitCredentialsConfig() {
}); });
setItems(r.items); setItems(r.items);
setForm(null); setForm(null);
toast.success(t("gitCreds.saved", { host: url.host }));
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : String(e)); setError(e instanceof Error ? e.message : String(e));
} finally {
setSaving(false);
} }
}; };
const replace = (c: GitCredential) => 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) => { 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 { try {
setItems((await api.gitCredentials.remove(c.protocol, c.host)).items); setItems((await api.gitCredentials.remove(c.protocol, c.host)).items);
} catch (e) { } catch (e) {
@@ -111,8 +138,9 @@ export function GitCredentialsConfig() {
aria-label={t("gitCreds.fieldHost")} aria-label={t("gitCreds.fieldHost")}
placeholder="https://git.example.com" placeholder="https://git.example.com"
value={form.url} value={form.url}
readOnly={form.replacing}
onChange={(e) => setForm({ ...form, url: e.target.value })} onChange={(e) => setForm({ ...form, url: e.target.value })}
className={`${INPUT} font-mono`} className={`${INPUT} font-mono read-only:opacity-70`}
/> />
<input <input
aria-label={t("gitCreds.fieldUser")} aria-label={t("gitCreds.fieldUser")}
@@ -134,7 +162,7 @@ export function GitCredentialsConfig() {
<div className="flex gap-2 sm:col-span-3"> <div className="flex gap-2 sm:col-span-3">
<button <button
type="submit" 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" 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")} {t("gitCreds.save")}
@@ -199,7 +227,7 @@ export function GitCredentialsConfig() {
{items.length === 0 && ( {items.length === 0 && (
<tr className="border-t border-border"> <tr className="border-t border-border">
<td colSpan={4} className="px-3 py-4 text-center text-fg-muted"> <td colSpan={4} className="px-3 py-4 text-center text-fg-muted">
{t("gitCreds.empty")} {loading ? t("gitCreds.loading") : t("gitCreds.empty")}
</td> </td>
</tr> </tr>
)} )}
@@ -209,9 +237,7 @@ export function GitCredentialsConfig() {
{ctx.menu && ( {ctx.menu && (
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="git-cred-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(replace)}>{t("gitCreds.replace")}</MenuItem>
<MenuItem onSelect={pick((c) => void navigator.clipboard.writeText(c.host))}> <MenuItem onSelect={pick((c) => void copyText(c.host))}>{t("rowMenu.copyHost")}</MenuItem>
{t("rowMenu.copyHost")}
</MenuItem>
<MenuSeparator /> <MenuSeparator />
<MenuItem danger onSelect={pick((c) => void remove(c))}> <MenuItem danger onSelect={pick((c) => void remove(c))}>
{t("gitCreds.remove")} {t("gitCreds.remove")}
File diff suppressed because it is too large Load Diff
+160 -13
View File
@@ -1,22 +1,36 @@
/** /**
* @file SettingsHub.tsx * @file SettingsHub.tsx
* @description The Settings page: `/settings/<section>`, a left nav grouped * @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 * search box over `SETTINGS_INDEX` (`lib/settingsSearch.ts`; Ctrl/Cmd+F
* focuses it, Enter opens the first match, Escape clears), and one page per * focuses it — and selects its text when it already has focus —, a result list
* section. A tool's own settings live under `agents/<id>` — Claude Code and * 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 * 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 * generic `AiToolConfig` — and a tool's page is hidden while that tool is
* disabled. A workspace's own settings live on its home, not here. * disabled. 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. * The old `?tool=<id>` query and `#ai-tools` hash redirect to the path form.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @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 { Link, Navigate, useLocation, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
ArrowLeft, ArrowLeft,
Bell, Bell,
Database,
Info,
Bot, Bot,
Boxes, Boxes,
Coins, Coins,
@@ -43,9 +57,16 @@ import { AiToolsSettings } from "../components/settings/AiToolsSettings";
import { PricingSettings } from "../components/settings/PricingSettings"; import { PricingSettings } from "../components/settings/PricingSettings";
import { WorktreeVisibilitySettings } from "../components/settings/WorktreeVisibilitySettings"; import { WorktreeVisibilitySettings } from "../components/settings/WorktreeVisibilitySettings";
import { useAiTools } from "../hooks/useAiTools"; 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]; type Group = (typeof GROUPS)[number];
interface Section { interface Section {
@@ -66,6 +87,24 @@ const LEGACY_TOOL: Record<string, string> = {
"git-credentials": "git-credentials", "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() { export function SettingsHub() {
const { t } = useTranslation("settings"); const { t } = useTranslation("settings");
const location = useLocation(); const location = useLocation();
@@ -73,6 +112,11 @@ export function SettingsHub() {
const aiTools = useAiTools(); const aiTools = useAiTools();
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const searchRef = useRef<HTMLInputElement>(null); 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(() => { useEffect(() => {
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
@@ -81,13 +125,53 @@ export function SettingsHub() {
const el = e.target; const el = e.target;
if (el instanceof Element && el.closest("textarea, [contenteditable='true'], .cm-editor")) if (el instanceof Element && el.closest("textarea, [contenteditable='true'], .cm-editor"))
return; return;
// An open modal owns the keyboard: never pull focus to a box behind it.
if (document.querySelector('[aria-modal="true"]')) return;
e.preventDefault(); 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); window.addEventListener("keydown", onKey);
return () => window.removeEventListener("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 enabled = (id: string) => aiTools.byId(id)?.enabled !== false;
const s = (key: string) => ({ const s = (key: string) => ({
title: t(`settingsNav.section.${key}.title`), title: t(`settingsNav.section.${key}.title`),
@@ -101,6 +185,20 @@ export function SettingsHub() {
...s("general"), ...s("general"),
render: () => <Settings section="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", id: "appearance",
group: "general", group: "general",
@@ -172,7 +270,7 @@ export function SettingsHub() {
}, },
{ {
id: "pricing", id: "pricing",
group: "routing", group: "cost",
Icon: Coins, Icon: Coins,
...s("pricing"), ...s("pricing"),
render: () => ( render: () => (
@@ -225,6 +323,7 @@ export function SettingsHub() {
title: t(e.titleKey), title: t(e.titleKey),
description: e.descKey ? t(e.descKey) : undefined, description: e.descKey ? t(e.descKey) : undefined,
keywords: e.keywords, keywords: e.keywords,
to: e.to,
})), })),
]); ]);
@@ -243,23 +342,39 @@ export function SettingsHub() {
const active = sections.find((x) => x.id === rest); const active = sections.find((x) => x.id === rest);
if (!active) { if (!active) {
// A catalog tool page exists only once the tool list has loaded. // A catalog tool page exists only once the tool list has loaded.
if (!aiTools.loaded) return null; if (!aiTools.loaded)
return <Navigate to="/settings/general" replace />; 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 ranked = query.trim() ? rankSettings(query, entries) : null;
const shown = ranked ? sections.filter((x) => ranked.some((r) => r.section === x.id)) : sections; const shown = ranked ? sections.filter((x) => ranked.some((r) => r.section === x.id)) : sections;
const first = ranked?.[0]?.section ?? null; 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 go = (id: string) => navigate(`/settings/${id}`);
const back = (e: MouseEvent) => {
if (!canGoBack) return;
e.preventDefault();
navigate(-(historyIdx() - (entryIdx.current ?? 0) + 1));
};
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<Link <Link
to="/workspace" to="/workspace"
onClick={back}
className="inline-flex items-center gap-1.5 text-xs text-fg-secondary hover:text-fg-primary" 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" /> <ArrowLeft className="h-3.5 w-3.5" />
{t("settingsNav.back")} {canGoBack ? t("settingsNav.backPrevious") : t("settingsNav.back")}
</Link> </Link>
<label className="relative block md:w-56"> <label className="relative block md:w-56">
@@ -270,7 +385,7 @@ export function SettingsHub() {
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter" && first) go(first); if (e.key === "Enter" && results[0]) navigate(hrefOf(results[0]));
if (e.key === "Escape") setQuery(""); if (e.key === "Escape") setQuery("");
}} }}
placeholder={t("search.placeholder")} placeholder={t("search.placeholder")}
@@ -279,6 +394,38 @@ export function SettingsHub() {
/> />
</label> </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"> <div className="flex gap-6">
<nav <nav
aria-label={t("settingsNav.label")} aria-label={t("settingsNav.label")}
@@ -323,7 +470,7 @@ export function SettingsHub() {
aria-label={t("settingsNav.label")} aria-label={t("settingsNav.label")}
value={active.id} value={active.id}
onChange={(e) => go(e.target.value)} 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) => { {GROUPS.map((g) => {
const own = sections.filter((x) => x.group === g); const own = sections.filter((x) => x.group === g);
@@ -7,10 +7,11 @@
*/ */
import { describe, it, expect, vi, beforeEach } from "vitest"; 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 userEvent from "@testing-library/user-event";
import { AiRouterConfig } from "../AiRouterConfig"; import { AiRouterConfig } from "../AiRouterConfig";
import { api } from "../../lib/api"; import { api } from "../../lib/api";
import { settleConfirm, subscribeConfirm, type PendingConfirm } from "../../lib/confirm";
vi.mock("../../lib/api", () => ({ vi.mock("../../lib/api", () => ({
api: { api: {
@@ -158,8 +159,18 @@ describe("AiRouterConfig", () => {
await user.type(screen.getByLabelText("aiRouter.fieldModel"), "x"); await user.type(screen.getByLabelText("aiRouter.fieldModel"), "x");
await user.click(screen.getByRole("button", { name: "aiRouter.save" })); 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(); 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 () => { 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] }); 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(); const user = userEvent.setup();
render(<AiRouterConfig />); render(<AiRouterConfig />);
const row = (await screen.findByText("9Router")).closest("tr")!; 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.pointer({ keys: "[MouseRight]", target: row });
await user.click(screen.getByRole("menuitem", { name: "aiRouter.remove" })); 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(); expect(api.aiRouters.remove).not.toHaveBeenCalled();
await user.pointer({ keys: "[MouseRight]", target: row }); await user.pointer({ keys: "[MouseRight]", target: row });
await user.click(screen.getByRole("menuitem", { name: "aiRouter.edit" })); await user.click(screen.getByRole("menuitem", { name: "aiRouter.edit" }));
expect(screen.getByRole("dialog")).toBeInTheDocument(); 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 { api, type AiTool } from "../../lib/api";
import { __resetAiTools } from "../../hooks/useAiTools"; import { __resetAiTools } from "../../hooks/useAiTools";
import { AiToolConfig } from "../AiToolConfig"; import { AiToolConfig } from "../AiToolConfig";
import { settleConfirm, subscribeConfirm, type PendingConfirm } from "../../lib/confirm";
import { subscribeToasts, type ToastItem } from "../../lib/toast";
vi.mock("../../lib/api", () => ({ vi.mock("../../lib/api", () => ({
api: { api: {
@@ -138,4 +140,54 @@ describe("AiToolConfig", () => {
expect(await screen.findByText(/not valid JSON/)).toBeInTheDocument(); expect(await screen.findByText(/not valid JSON/)).toBeInTheDocument();
expect(api.aiTools.saveConfig).toHaveBeenCalledWith("gemini", 0, "{nope", "s1"); 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 { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, act } from "@testing-library/react"; import { render, screen, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import { settleConfirm } from "../../lib/confirm";
const ccConfigMock = vi.hoisted(() => { const ccConfigMock = vi.hoisted(() => {
const r = (value: unknown) => vi.fn().mockResolvedValue(value); const r = (value: unknown) => vi.fn().mockResolvedValue(value);
@@ -99,7 +101,11 @@ afterEach(() => vi.clearAllMocks());
describe("CcConfig — right-click menus", () => { describe("CcConfig — right-click menus", () => {
it("uninstalls a plugin from its menu only on the second click, like the button", async () => { it("uninstalls a plugin from its menu only on the second click, like the button", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<CcConfig />); render(
<MemoryRouter>
<CcConfig />
</MemoryRouter>
);
await settle(); await settle();
await user.click(screen.getByRole("tab", { name: /plugins/i })); await user.click(screen.getByRole("tab", { name: /plugins/i }));
await settle(); 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 () => { it("deletes a saved profile from its menu through the same API as the trash button", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<CcConfig />); render(
<MemoryRouter>
<CcConfig />
</MemoryRouter>
);
await settle(); await settle();
await user.click(screen.getByRole("button", { name: /profile/i })); await user.click(screen.getByRole("button", { name: /profile/i }));
await settle(); await settle();
@@ -129,12 +139,20 @@ describe("CcConfig — right-click menus", () => {
await user.pointer({ keys: "[MouseRight]", target: screen.getByTestId("profile-row-spare") }); await user.pointer({ keys: "[MouseRight]", target: screen.getByTestId("profile-row-spare") });
await user.click(screen.getByTestId("profile-menu-delete")); await user.click(screen.getByTestId("profile-menu-delete"));
await settle(); 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"); expect(ccConfigMock.deleteProfile).toHaveBeenCalledWith("spare");
}); });
it("offers no delete for the only saved copy of the live account", async () => { it("offers no delete for the only saved copy of the live account", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<CcConfig />); render(
<MemoryRouter>
<CcConfig />
</MemoryRouter>
);
await settle(); await settle();
await user.click(screen.getByRole("button", { name: /profile/i })); await user.click(screen.getByRole("button", { name: /profile/i }));
await settle(); 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 { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, act } from "@testing-library/react"; import { render, screen, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
const ccConfigMock = vi.hoisted(() => { const ccConfigMock = vi.hoisted(() => {
const r = (value: unknown) => vi.fn().mockResolvedValue(value); const r = (value: unknown) => vi.fn().mockResolvedValue(value);
@@ -109,7 +110,11 @@ async function settle() {
} }
async function openProfiles() { async function openProfiles() {
render(<CcConfig />); render(
<MemoryRouter>
<CcConfig />
</MemoryRouter>
);
await settle(); await settle();
await userEvent.click(screen.getByRole("button", { name: /profile/i })); await userEvent.click(screen.getByRole("button", { name: /profile/i }));
await settle(); await settle();
@@ -13,6 +13,8 @@
import { describe, it, expect, vi, afterEach } from "vitest"; import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, act, waitFor } from "@testing-library/react"; import { render, screen, act, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import { Toaster } from "../../components/Toaster";
const ccConfigMock = vi.hoisted(() => { const ccConfigMock = vi.hoisted(() => {
const r = (value: unknown) => vi.fn().mockResolvedValue(value); 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. */ /** Open the plugins tab, where the Update all card lives. */
async function openPlugins() { 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 settle();
await userEvent.click(screen.getByRole("tab", { name: /plugins/i })); await userEvent.click(screen.getByRole("tab", { name: /plugins/i }));
await settle(); await settle();
@@ -10,6 +10,7 @@
import { describe, it, expect, vi, afterEach } from "vitest"; import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, act } from "@testing-library/react"; import { render, screen, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; 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 // vi.mock factories are hoisted above the rest of the module, so the mock
// object they reference must be created inside vi.hoisted() too. // object they reference must be created inside vi.hoisted() too.
@@ -131,7 +132,11 @@ describe("CodexConfig", () => {
codex_home: "/work/.codex", codex_home: "/work/.codex",
env_owned: false, env_owned: false,
}); });
render(<CodexConfig />); render(
<MemoryRouter>
<CodexConfig />
</MemoryRouter>
);
await settle(); await settle();
const overviewCalls = codexConfigMock.overview.mock.calls.length; 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 () => { it("offers create on the authored surfaces and withholds it on the CLI-owned ones", async () => {
stubCodexApi({ skills: [], mcp: { servers: [] } }); stubCodexApi({ skills: [], mcp: { servers: [] } });
render(<CodexConfig />); render(
<MemoryRouter>
<CodexConfig />
</MemoryRouter>
);
await settle(); await settle();
await userEvent.click(screen.getByRole("tab", { name: /skills/i })); 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 }], rules: [{ scope: "user", name: "default", file: "/r/default.rules", size: 3 }],
write, write,
}); });
render(<CodexConfig />); render(
<MemoryRouter>
<CodexConfig />
</MemoryRouter>
);
await settle(); await settle();
await userEvent.click(screen.getByRole("tab", { name: /rules/i })); 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 () => { 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 }] }); 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")); codexConfigMock.mcp.mockRejectedValueOnce(new Error("the codex CLI was not found on PATH"));
render(<CodexConfig />); render(
<MemoryRouter>
<CodexConfig />
</MemoryRouter>
);
await settle(); await settle();
// No page-wide error banner - the CLI-backed rejection is isolated. // 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 () => { it("creates a nameless-type artifact (config.toml) without requiring a name", async () => {
const write = vi.fn().mockResolvedValue({ file: "/c/config.toml", created: true }); const write = vi.fn().mockResolvedValue({ file: "/c/config.toml", created: true });
stubCodexApi({ write }); stubCodexApi({ write });
render(<CodexConfig />); render(
<MemoryRouter>
<CodexConfig />
</MemoryRouter>
);
await settle(); await settle();
await userEvent.click(screen.getByRole("tab", { name: /config/i })); await userEvent.click(screen.getByRole("tab", { name: /config/i }));
@@ -228,7 +249,11 @@ describe("CodexConfig", () => {
}, },
], ],
}); });
render(<CodexConfig />); render(
<MemoryRouter>
<CodexConfig />
</MemoryRouter>
);
await settle(); await settle();
await userEvent.click(screen.getByRole("button", { name: /backups/i })); 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 userEvent from "@testing-library/user-event";
import { GitCredentialsConfig } from "../GitCredentialsConfig"; import { GitCredentialsConfig } from "../GitCredentialsConfig";
import { api } from "../../lib/api"; 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", () => ({ vi.mock("../../lib/api", () => ({
api: { gitCredentials: { list: vi.fn(), save: vi.fn(), remove: vi.fn() } }, api: { gitCredentials: { list: vi.fn(), save: vi.fn(), remove: vi.fn() } },
@@ -84,24 +97,22 @@ describe("GitCredentialsConfig", () => {
}); });
it("deletes only after a confirm", async () => { it("deletes only after a confirm", async () => {
const confirm = vi const confirm = answerConfirms(false, true);
.spyOn(window, "confirm")
.mockReturnValueOnce(false)
.mockReturnValueOnce(true);
const user = userEvent.setup(); const user = userEvent.setup();
render(<GitCredentialsConfig />); render(<GitCredentialsConfig />);
const del = await screen.findByRole("button", { name: "gitCreds.remove git.smartgift.io.vn" }); const del = await screen.findByRole("button", { name: "gitCreds.remove git.smartgift.io.vn" });
await user.click(del); await user.click(del);
await waitFor(() => expect(confirm.asked).toHaveLength(1));
expect(api.gitCredentials.remove).not.toHaveBeenCalled(); expect(api.gitCredentials.remove).not.toHaveBeenCalled();
await user.click(del); await user.click(del);
await waitFor(() => await waitFor(() =>
expect(api.gitCredentials.remove).toHaveBeenCalledWith("https", "git.smartgift.io.vn") 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 () => { 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(); const user = userEvent.setup();
render(<GitCredentialsConfig />); render(<GitCredentialsConfig />);
const row = (await screen.findByText("git.smartgift.io.vn")).closest("tr")!; 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.pointer({ keys: "[MouseRight]", target: row });
await user.click(screen.getByRole("menuitem", { name: "gitCreds.remove" })); await user.click(screen.getByRole("menuitem", { name: "gitCreds.remove" }));
expect(confirm).toHaveBeenCalledWith("gitCreds.removeConfirm"); await waitFor(() => expect(confirm.asked).toEqual(["gitCreds.removeConfirm"]));
await waitFor(() => await waitFor(() =>
expect(api.gitCredentials.remove).toHaveBeenCalledWith("https", "git.smartgift.io.vn") 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 * unknown section falls back to General, a disabled tool has no page while an
* installed, enabled catalog tool gets one, the mobile * installed, enabled catalog tool gets one, the mobile
* select navigates, and the search filters the nav, opens the first match on * 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> * @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"; import type { AiTool, AiToolsView } from "../../lib/api";
vi.mock("../Settings", () => ({ 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("../CcConfig", () => ({ CcConfig: () => <div data-testid="cc-config" /> }));
vi.mock("../CodexConfig", () => ({ CodexConfig: () => <div data-testid="codex-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 { SettingsHub } from "../SettingsHub";
import { __resetAiTools } from "../../hooks/useAiTools"; import { __resetAiTools } from "../../hooks/useAiTools";
import { subscribeToasts, type ToastItem } from "../../lib/toast";
function Where() { function Where() {
const loc = useLocation(); const loc = useLocation();
@@ -180,4 +185,70 @@ describe("SettingsHub", () => {
await user.type(box, "zzzz"); await user.type(box, "zzzz");
expect(within(nav()).getByText("No setting matches “zzzz”.")).toBeInTheDocument(); 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. // into the current home at its spawn, so a move needs no repair here.
homeRoutes("/codex-home", "CODEX_HOME", "codex_home", getCodexHome, setCodexHome); 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; const PURGE_CHUNK = 500;
router.post("/cleanup", (req, res) => { router.post("/cleanup", (req, res) => {
const { abandon_hours, purge_days } = req.body; 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 }; 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) { if (abandon_hours && typeof abandon_hours === "number" && abandon_hours > 0) {
// Mark active sessions with no recent events as abandoned // Mark active sessions with no recent events as abandoned
const cutoff = new Date(Date.now() - abandon_hours * 3600 * 1000).toISOString(); const cutoff = new Date(Date.now() - abandon_hours * 3600 * 1000).toISOString();
const stale = db const stale = db
.prepare( .prepare(
`SELECT s.id FROM sessions s `SELECT s.id, s.started_at FROM sessions s
WHERE s.status = 'active' WHERE s.status = 'active'
AND s.started_at < ? AND s.started_at < ?
AND NOT EXISTS ( AND NOT EXISTS (
@@ -380,17 +397,22 @@ router.post("/cleanup", (req, res) => {
const abandon = db.prepare( const abandon = db.prepare(
"UPDATE sessions SET status = 'abandoned', ended_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?" "UPDATE sessions SET status = 'abandoned', ended_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?"
); );
// Also complete any lingering agents if (dryRun) {
const complete = db.prepare( result.abandoned = stale.length;
"UPDATE agents SET status = 'completed', ended_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE session_id = ? AND status IN ('waiting','working')" wouldAbandon = stale;
); } else {
db.transaction(() => { // Also complete any lingering agents
for (const row of stale) { const complete = db.prepare(
abandon.run(row.id); "UPDATE agents SET status = 'completed', ended_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE session_id = ? AND status IN ('waiting','working')"
complete.run(row.id); );
} db.transaction(() => {
})(); for (const row of stale) {
result.abandoned = stale.length; abandon.run(row.id);
complete.run(row.id);
}
})();
result.abandoned = stale.length;
}
} }
if (purge_days && typeof purge_days === "number" && purge_days > 0) { if (purge_days && typeof purge_days === "number" && purge_days > 0) {
@@ -402,6 +424,27 @@ router.post("/cleanup", (req, res) => {
) )
.all(cutoff); .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 // One transaction, in chunks: a single IN (...) list of every id would
// pass SQLite's bound-variable limit on a large history. // pass SQLite's bound-variable limit on a large history.
db.transaction(() => { db.transaction(() => {