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

- EditorModal loads once per path (a parent re-render no longer reloads the
  file over the user's typing), refuses Save after a failed load, opens a
  truncated (>256 KB) file read-only, and asks before overwriting a file
  that changed on disk or could not be re-read.
- Settings/Hooks editors unset only keys from the snapshot taken at Edit,
  warn when the file changed underneath, keep a renamed key's value, and
  refuse a new key named like a redacted one (env) or a read-only hook
  event, which used to replace the secret/hooks.
- Project scope shows its resolved folder and is disabled inside the
  plugin cache. Account-profile, pricing-row and pet deletes confirm;
  two-click actions disarm after 3 s; unsaved drafts ask before closing.
- Search indexes every Claude Code / Codex sub-tab, opens it via ?tab= and
  scrolls to + highlights the row; the active config tab is in the URL.
- General splits into General / Data / About; Pricing moves to a Cost
  group with one shared load; config tabs group as Content / Extensions /
  Config with arrow-key roving; Promise.allSettled per tab.
- Data: browser-preferences export/import (allowlisted keys), per-section
  reset, housekeeping dry-run first, clear-all behind a typed phrase.
  Server: purge days must be a whole number >= 1 (INVALID_PURGE_DAYS) and
  dry_run returns exactly what the real run would delete.
- Alerts can be edited; webhooks/alerts/remote-source failures toast;
  copies go through copyText; modals get dialog roles and useDialog.
- Sagi: Ctrl/Cmd+B only when shown and not typing, default spot clear of
  row controls, arrow keys move it, reduced motion honoured; the dead
  /run launch link goes to the workspace.
This commit is contained in:
2026-10-05 13:22:08 +07:00
parent 03f1673fce
commit 6715fbd971
68 changed files with 4817 additions and 1374 deletions
+90 -29
View File
@@ -70,8 +70,8 @@ import {
BellOff,
Check,
CheckCheck,
ChevronDown,
ListChecks,
Pencil,
Plus,
RefreshCw,
Trash2,
@@ -88,6 +88,7 @@ import { Checkbox } from "./Checkbox";
import { FieldHelp } from "./FieldHelp";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "./ContextMenu";
import { timeAgo } from "../lib/format";
import { toast } from "../lib/toast";
import type { AlertEvent, AlertRule, AlertRuleType, WSMessage } from "../lib/types";
const PAGE_SIZE = 25;
@@ -155,6 +156,27 @@ const EMPTY_FORM: RuleFormState = {
cooldown_seconds: "300",
};
/** A rule's stored values as form fields, for editing it. */
function formFromRule(rule: AlertRule): RuleFormState {
const c = rule.config;
const str = (v: unknown, fallback: string) => (v == null ? fallback : String(v));
return {
name: rule.name,
rule_type: rule.rule_type,
event_type: str(c.event_type, ""),
tool_name: str(c.tool_name, ""),
summary_contains: str(c.summary_contains, ""),
count: str(c.count, EMPTY_FORM.count),
window_minutes: str(c.window_minutes, EMPTY_FORM.window_minutes),
minutes: str(c.minutes, EMPTY_FORM.minutes),
status: c.status === "waiting" ? "waiting" : "working",
total_tokens: str(c.total_tokens, EMPTY_FORM.total_tokens),
cooldown_seconds: String(rule.cooldown_seconds),
};
}
const errText = (err: unknown) => (err instanceof Error ? err.message : String(err));
function buildConfig(form: RuleFormState): AlertRule["config"] {
switch (form.rule_type) {
case "event_pattern": {
@@ -217,6 +239,8 @@ export function AlertsNotifications() {
const [loadingRules, setLoadingRules] = useState(true);
const [formOpen, setFormOpen] = useState(false);
const [form, setForm] = useState<RuleFormState>(EMPTY_FORM);
// The rule being edited, or null while the form creates a new one.
const [editingId, setEditingId] = useState<string | null>(null);
const [formError, setFormError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [confirmRule, setConfirmRule] = useState<AlertRule | null>(null);
@@ -241,11 +265,11 @@ export function AlertsNotifications() {
const res = await api.alerts.rules.list();
setRules(res.rules);
} catch (err) {
console.error("Failed to load alert rules:", err);
toast.error(t("errors.loadRules", { error: errText(err) }));
} finally {
setLoadingRules(false);
}
}, []);
}, [t]);
const loadAlerts = useCallback(async () => {
setLoadingAlerts(true);
@@ -259,11 +283,11 @@ export function AlertsNotifications() {
setTotal(res.total);
setUnacked(res.unacked);
} catch (err) {
console.error("Failed to load alerts:", err);
toast.error(t("errors.loadAlerts", { error: errText(err) }));
} finally {
setLoadingAlerts(false);
}
}, [unackedOnly]);
}, [unackedOnly, t]);
const loadMore = useCallback(async () => {
try {
@@ -276,9 +300,9 @@ export function AlertsNotifications() {
setTotal(res.total);
setUnacked(res.unacked);
} catch (err) {
console.error("Failed to load more alerts:", err);
toast.error(t("errors.loadAlerts", { error: errText(err) }));
}
}, [unackedOnly, alerts.length]);
}, [unackedOnly, alerts.length, t]);
useEffect(() => {
loadRules();
@@ -300,20 +324,33 @@ export function AlertsNotifications() {
const set = (patch: Partial<RuleFormState>) => setForm((prev) => ({ ...prev, ...patch }));
const onCreateRule = async () => {
if (saving) return;
const closeForm = () => {
setForm(EMPTY_FORM);
setEditingId(null);
setFormOpen(false);
setFormError(null);
};
const openEditRule = (rule: AlertRule) => {
setForm(formFromRule(rule));
setEditingId(rule.id);
setFormError(null);
setFormOpen(true);
};
const onSaveRule = async () => {
if (saving || !canSubmit) return;
setSaving(true);
setFormError(null);
try {
const cooldown = parseInt(form.cooldown_seconds, 10);
await api.alerts.rules.create({
const body = {
name: form.name.trim(),
rule_type: form.rule_type,
config: buildConfig(form),
cooldown_seconds: Number.isFinite(cooldown) && cooldown >= 0 ? cooldown : 300,
});
setForm(EMPTY_FORM);
setFormOpen(false);
cooldown_seconds: cooldownVal,
};
if (editingId) await api.alerts.rules.update(editingId, body);
else await api.alerts.rules.create({ ...body, rule_type: form.rule_type });
closeForm();
loadRules();
} catch (err) {
setFormError(err instanceof Error ? err.message : String(err));
@@ -327,7 +364,7 @@ export function AlertsNotifications() {
await api.alerts.rules.update(rule.id, { enabled: !rule.enabled });
loadRules();
} catch (err) {
console.error("Failed to toggle alert rule:", err);
toast.error(t("errors.toggleRule", { error: errText(err) }));
}
};
@@ -338,7 +375,8 @@ export function AlertsNotifications() {
loadRules();
loadAlerts();
} catch (err) {
console.error("Failed to delete alert rule:", err);
setConfirmRule(null);
toast.error(t("errors.deleteRule", { error: errText(err) }));
}
};
@@ -347,7 +385,7 @@ export function AlertsNotifications() {
await api.alerts.ack(id);
loadAlerts();
} catch (err) {
console.error("Failed to acknowledge alert:", err);
toast.error(t("errors.ack", { error: errText(err) }));
}
};
@@ -356,7 +394,7 @@ export function AlertsNotifications() {
await api.alerts.ackAll();
loadAlerts();
} catch (err) {
console.error("Failed to acknowledge alerts:", err);
toast.error(t("errors.ack", { error: errText(err) }));
}
};
@@ -366,8 +404,12 @@ export function AlertsNotifications() {
const tokensVal = parseInt(form.total_tokens, 10);
const countVal = parseInt(form.count, 10);
const windowVal = parseFloat(form.window_minutes);
// Cooldown is a whole number of seconds ≥ 0 — never silently replaced by 300.
const cooldownOk = /^\d+$/.test(form.cooldown_seconds.trim());
const cooldownVal = cooldownOk ? parseInt(form.cooldown_seconds, 10) : 0;
const canSubmit =
form.name.trim().length > 0 &&
cooldownOk &&
(form.rule_type !== "event_pattern" ||
(Boolean(form.event_type.trim() || form.tool_name.trim() || form.summary_contains.trim()) &&
Number.isFinite(countVal) &&
@@ -440,8 +482,11 @@ export function AlertsNotifications() {
</div>
<button
onClick={() => {
setFormOpen((open) => !open);
setFormError(null);
if (formOpen) closeForm();
else {
setFormOpen(true);
setFormError(null);
}
}}
className="btn-ghost border border-border inline-flex items-center gap-1.5 text-xs flex-shrink-0"
>
@@ -475,7 +520,9 @@ export function AlertsNotifications() {
<select
value={form.rule_type}
onChange={(e) => set({ rule_type: e.target.value as AlertRuleType })}
className="input w-full appearance-none pr-8"
disabled={!!editingId}
title={editingId ? t("rules.typeFixed") : undefined}
className="select w-full"
>
{RULE_TYPES.map((type) => (
<option key={type} value={type}>
@@ -483,7 +530,6 @@ export function AlertsNotifications() {
</option>
))}
</select>
<ChevronDown className="w-3.5 h-3.5 absolute right-2.5 top-1/2 -translate-y-1/2 text-fg-muted pointer-events-none" />
</div>
</label>
</div>
@@ -586,12 +632,11 @@ export function AlertsNotifications() {
<select
value={form.status}
onChange={(e) => set({ status: e.target.value as "working" | "waiting" })}
className="input w-full appearance-none pr-8"
className="select w-full"
>
<option value="working">working</option>
<option value="waiting">waiting</option>
</select>
<ChevronDown className="w-3.5 h-3.5 absolute right-2.5 top-1/2 -translate-y-1/2 text-fg-muted pointer-events-none" />
</div>
</label>
)}
@@ -644,18 +689,25 @@ export function AlertsNotifications() {
<input
type="number"
min={0}
step={1}
value={form.cooldown_seconds}
onChange={(e) => set({ cooldown_seconds: e.target.value })}
aria-invalid={!cooldownOk}
className="input w-40"
/>
{!cooldownOk && (
<span className="mt-1 block text-[11px] text-status-danger">
{t("rules.cooldownInvalid")}
</span>
)}
</label>
<button
onClick={onCreateRule}
onClick={onSaveRule}
disabled={!canSubmit || saving}
className="btn-primary inline-flex items-center gap-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
<Plus className="w-3.5 h-3.5" />
{saving ? t("rules.saving") : t("rules.create")}
{editingId ? <Check className="w-3.5 h-3.5" /> : <Plus className="w-3.5 h-3.5" />}
{saving ? t("rules.saving") : editingId ? t("rules.save") : t("rules.create")}
</button>
</div>
{formError && <p className="text-xs text-status-danger">{formError}</p>}
@@ -710,6 +762,14 @@ export function AlertsNotifications() {
>
{rule.enabled ? t("rules.enabled") : t("rules.disabled")}
</button>
<button
onClick={() => openEditRule(rule)}
className="p-1.5 rounded-md text-fg-muted hover:text-fg-primary hover:bg-surface-3 transition-colors"
title={t("rules.edit")}
aria-label={t("rules.edit")}
>
<Pencil className="w-3.5 h-3.5" />
</button>
<button
onClick={() => setConfirmRule(rule)}
className="p-1.5 rounded-md text-fg-muted hover:text-status-danger hover:bg-status-danger/10 transition-colors"
@@ -733,6 +793,7 @@ export function AlertsNotifications() {
<MenuItem onSelect={pickRule(onToggleRule)}>
{ruleCtx.menu.item.enabled ? t("rules.disable") : t("rules.enable")}
</MenuItem>
<MenuItem onSelect={pickRule(openEditRule)}>{t("rules.edit")}</MenuItem>
<MenuSeparator />
<MenuItem danger onSelect={pickRule(setConfirmRule)}>
{t("rules.delete")}
+22 -20
View File
@@ -294,7 +294,7 @@ export function ImportHistory() {
className="w-full flex items-center justify-between px-4 py-3 bg-surface-2 hover:bg-surface-3 transition-colors"
>
<span className="flex items-center gap-2 text-xs font-semibold text-fg-secondary uppercase tracking-wider">
<ListChecks className="w-3.5 h-3.5 text-blue-500" />
<ListChecks className="w-3.5 h-3.5 text-accent" />
{t("import.instructions")}
</span>
<span className="text-[11px] text-fg-muted">{instructionsOpen ? "▾" : "▸"}</span>
@@ -455,8 +455,8 @@ export function ImportHistory() {
{mode === "upload" && (
<div className="space-y-3">
<div
onClick={() => fileInputRef.current?.click()}
<label
htmlFor="import-upload-files"
onDragOver={(e) => {
e.preventDefault();
setDragging(true);
@@ -467,9 +467,9 @@ export function ImportHistory() {
setDragging(false);
onSelectFiles(e.dataTransfer.files);
}}
className={`border-2 border-dashed rounded-lg px-4 py-8 text-center cursor-pointer transition-colors ${
className={`block border-2 border-dashed rounded-lg px-4 py-8 text-center cursor-pointer transition-colors focus-within:ring-2 focus-within:ring-accent/50 ${
dragging
? "border-blue-500 bg-blue-600/5"
? "border-accent bg-accent/5"
: "border-border hover:border-border-light bg-surface-1"
}`}
>
@@ -477,14 +477,15 @@ export function ImportHistory() {
<p className="text-sm text-fg-secondary">{t("import.dropzoneHint")}</p>
<p className="text-[11px] text-fg-muted mt-1">{t("import.dropzoneSub")}</p>
<input
id="import-upload-files"
ref={fileInputRef}
type="file"
multiple
accept=".jsonl,.json,.zip,.tar,.tgz,.gz,application/gzip,application/zip,application/x-tar,application/octet-stream"
onChange={(e) => onSelectFiles(e.target.files)}
className="hidden"
className="sr-only"
/>
</div>
</label>
{files.length > 0 && (
<div className="flex flex-wrap items-center justify-between gap-2 text-xs bg-surface-3 rounded-md px-3 py-2">
<span className="text-fg-secondary">
@@ -522,8 +523,8 @@ export function ImportHistory() {
{mode === "backup" && (
<div className="space-y-3">
<div
onClick={() => backupInputRef.current?.click()}
<label
htmlFor="import-backup-file"
onDragOver={(e) => {
e.preventDefault();
setDragging(true);
@@ -535,9 +536,9 @@ export function ImportHistory() {
const f = e.dataTransfer.files?.[0];
if (f) setBackupFile(f);
}}
className={`border-2 border-dashed rounded-lg px-4 py-8 text-center cursor-pointer transition-colors ${
className={`block border-2 border-dashed rounded-lg px-4 py-8 text-center cursor-pointer transition-colors focus-within:ring-2 focus-within:ring-accent/50 ${
dragging
? "border-blue-500 bg-blue-600/5"
? "border-accent bg-accent/5"
: "border-border hover:border-border-light bg-surface-1"
}`}
>
@@ -545,13 +546,14 @@ export function ImportHistory() {
<p className="text-sm text-fg-secondary">{t("import.backupHint")}</p>
<p className="text-[11px] text-fg-muted mt-1">{t("import.backupSub")}</p>
<input
id="import-backup-file"
ref={backupInputRef}
type="file"
accept=".json,application/json"
onChange={(e) => setBackupFile(e.target.files?.[0] || null)}
className="hidden"
className="sr-only"
/>
</div>
</label>
{backupFile && (
<div className="flex flex-wrap items-center justify-between gap-2 text-xs bg-surface-3 rounded-md px-3 py-2">
<span className="text-fg-secondary min-w-0">
@@ -591,7 +593,7 @@ export function ImportHistory() {
{/* In-flight progress */}
{running && progressText && (
<div className="flex items-center gap-2 text-xs text-fg-secondary bg-surface-2 border border-border rounded-md px-3 py-2">
<Loader2 className="w-3.5 h-3.5 animate-spin text-blue-500 flex-shrink-0" />
<Loader2 className="w-3.5 h-3.5 animate-spin text-accent flex-shrink-0" />
<span className="truncate">{progressText}</span>
{progress?.current && (
<code className="font-mono text-[11px] text-fg-muted truncate">
@@ -625,7 +627,7 @@ export function ImportHistory() {
<ResultStat
label={t("import.result.backfilled", { count: result.backfilled ?? 0 })}
value={result.backfilled ?? 0}
color="text-blue-700 dark:text-blue-400"
color="text-accent"
/>
<ResultStat
label={t("import.result.skipped", { count: result.skipped })}
@@ -672,7 +674,7 @@ export function ImportHistory() {
<ResultStat
label={t("import.backupResult.events", { count: backupResult.events })}
value={backupResult.events}
color="text-violet-700 dark:text-violet-300"
color="text-fg-primary"
/>
<ResultStat
label={t("import.backupResult.pricing", { count: backupResult.model_pricing })}
@@ -728,16 +730,16 @@ function ModeButton({
}) {
return (
<button
type="button"
aria-pressed={active}
onClick={onClick}
className={`text-left p-3 rounded-lg border transition-colors ${
active
? "border-blue-600/40 bg-blue-600/10"
: "border-border bg-surface-2 hover:bg-surface-3"
active ? "border-accent/40 bg-accent/10" : "border-border bg-surface-2 hover:bg-surface-3"
}`}
>
<div
className={`flex items-center gap-1.5 text-xs font-medium mb-1 ${
active ? "text-blue-700 dark:text-blue-400" : "text-fg-secondary"
active ? "text-accent" : "text-fg-secondary"
}`}
>
{icon}
+37 -8
View File
@@ -82,10 +82,14 @@ import {
import { api } from "../lib/api";
import type { RemoteSource, RemoteSourceInput } from "../lib/api";
import { eventBus } from "../lib/eventBus";
import { toast } from "../lib/toast";
import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
import { useDataScope } from "../lib/dataScope";
import type { ScopeMode } from "../lib/dataScope";
/** Data-scope choices in display order (the radio group's arrow-key order). */
const SCOPE_MODES: ScopeMode[] = ["all", "local", "selected"];
const EMPTY_FORM: RemoteSourceInput = {
label: "",
host: "",
@@ -258,8 +262,8 @@ export function RemoteSources() {
try {
await api.remoteSources.update(s.id, { enabled: !s.enabled });
load();
} catch {
/* surfaced via reload */
} catch (err) {
toast.error(err);
} finally {
setBusyId(null);
}
@@ -301,8 +305,10 @@ export function RemoteSources() {
try {
await api.remoteSources.syncAll();
load();
} catch {
/* per-source errors surface via each source's status on reload */
} catch (err) {
// Per-source errors still surface on each source's status after the reload.
toast.error(err);
load();
} finally {
setSyncingAll(false);
}
@@ -316,8 +322,9 @@ export function RemoteSources() {
await api.remoteSources.remove(id, purge);
setConfirmDelete(null);
load();
} catch {
/* surfaced via reload */
} catch (err) {
setConfirmDelete(null);
toast.error(err);
} finally {
setBusyId(null);
}
@@ -358,7 +365,26 @@ export function RemoteSources() {
</p>
{/* Card selector — one card per scope mode, each with a short explanation
so the choice is self-describing rather than a bare radio label. */}
<div className="grid gap-2.5 sm:grid-cols-3" role="radiogroup" aria-label="Data scope">
<div
className="grid gap-2.5 sm:grid-cols-3"
role="radiogroup"
aria-label={t("remoteSources.scopeTitle", "Data scope")}
onKeyDown={(e) => {
// Arrow keys move the choice, like a native radio group.
const step =
e.key === "ArrowRight" || e.key === "ArrowDown"
? 1
: e.key === "ArrowLeft" || e.key === "ArrowUp"
? -1
: 0;
if (!step) return;
e.preventDefault();
const i = SCOPE_MODES.indexOf(scope.mode);
const next = SCOPE_MODES[(i + step + SCOPE_MODES.length) % SCOPE_MODES.length]!;
setMode(next);
e.currentTarget.querySelector<HTMLElement>(`[data-mode="${next}"]`)?.focus();
}}
>
{(
[
{
@@ -394,8 +420,11 @@ export function RemoteSources() {
return (
<button
key={mode}
type="button"
role="radio"
aria-checked={active}
tabIndex={active ? 0 : -1}
data-mode={mode}
onClick={() => setMode(mode)}
className={`relative text-left rounded-xl border p-3 transition-colors ${
active
@@ -492,7 +521,7 @@ export function RemoteSources() {
</label>
<input
className="input w-full"
placeholder="Dev box"
placeholder={t("remoteSources.fieldLabelPlaceholder", "Dev box")}
value={form.label}
onChange={(e) => setForm((f) => ({ ...f, label: e.target.value }))}
/>
+2
View File
@@ -28,6 +28,7 @@ import {
} from "lucide-react";
import { PET_SIZE, sagiPrefs, type PetChoice } from "./prefs";
import { usePets } from "./usePets";
import { toast } from "../../lib/toast";
const ITEM =
"flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm text-fg-secondary hover:bg-surface-4 hover:text-fg-primary disabled:opacity-50";
@@ -84,6 +85,7 @@ export function PetMenu({
className={ITEM}
onClick={() => {
sagiPrefs.setEnabled(false);
toast.info(t("sagi.pet.hiddenToast"));
onClose();
}}
>
+33 -12
View File
@@ -8,8 +8,11 @@
* useSagiPosition, and the bubble/panel render in a self-clamping flyout so
* they never spill off any screen edge regardless of where the mascot is docked.
*
* The "do the job" path reuses the existing Run page: unmatched Ask queries
* deep-link to /run?prompt=…&autostart=1 - no new LLM backend.
* The "do the job" path: an unmatched Ask query starts a claude session in
* the "Sagi" workspace carrying it as the first prompt, then opens that
* workspace's session view (`/workspace?lane=<id>&run=<runId>`) — no new LLM
* backend. ⌘B/Ctrl+B is taken only while the mascot is shown and focus is not
* in an editable field (a terminal's Ctrl+B is tmux's prefix).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -70,6 +73,7 @@ import {
type ReactNode,
} from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { SagiAvatar } from "./SagiAvatar";
import { PetSprite } from "./PetSprite";
import { PetMenu } from "./PetMenu";
@@ -85,6 +89,17 @@ import "./sagi.css";
const FLYOUT_GAP = 10; // px between avatar and flyout
const VIEWPORT_MARGIN = 12; // min gap from any screen edge
/** True while focus sits where typing happens (an input, a terminal's textarea). */
function isEditable(el: EventTarget | null): boolean {
if (!(el instanceof HTMLElement)) return false;
return (
el.isContentEditable ||
el.tagName === "INPUT" ||
el.tagName === "TEXTAREA" ||
el.tagName === "SELECT"
);
}
interface Anchor {
left: number;
top: number;
@@ -94,7 +109,8 @@ interface Anchor {
dragging: boolean;
}
function usePrefersReducedMotion(): boolean {
/** The OS "reduce motion" preference, followed live. */
export function usePrefersReducedMotion(): boolean {
const [reduced, setReduced] = useState(
() =>
typeof window !== "undefined" &&
@@ -180,6 +196,7 @@ function SagiFlyout({
}
export function Sagi() {
const { t } = useTranslation("settings");
const [enabled, setEnabled] = useState(() => sagiPrefs.getEnabled());
const [pet, setPet] = useState(() => sagiPrefs.getPet());
const [size, setSize] = useState(() => sagiPrefs.getSize());
@@ -202,10 +219,13 @@ export function Sagi() {
[]
);
// ⌘B / Ctrl+B toggles the panel; Esc closes it.
// ⌘B / Ctrl+B toggles the panel; Esc closes it. Only while the mascot is
// shown, and never from an editable field, where Ctrl+B belongs to the field.
useEffect(() => {
if (!enabled) return;
const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "b") {
if (isEditable(e.target) || isEditable(document.activeElement)) return;
e.preventDefault();
setOpen((v) => !v);
} else if (e.key === "Escape") {
@@ -214,7 +234,7 @@ export function Sagi() {
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
}, [enabled]);
const onNavigate = useCallback(
(route: string) => {
@@ -230,7 +250,7 @@ export function Sagi() {
if (result.kind === "answer") return result.text;
// Handoff: ensure the "Sagi" lane exists for this dashboard, spawn a
// fresh claude session in it carrying the user's prompt as its first
// message, then jump to the Run page so the live pane is observable.
// message, then open that workspace's session view on the new pane.
// Fire-and-forget — the panel already clears its composer on null and
// closes; errors surface as an answer instead of silently swallowing.
const prompt = result.prompt;
@@ -238,24 +258,24 @@ export function Sagi() {
(async () => {
try {
const { lane } = await api.lanes.ensure({ title: "Sagi" });
await api.run.start({
const run = await api.run.start({
laneId: lane.id,
cwd: lane.cwd,
initialPrompt: prompt,
permissionMode: "acceptEdits",
});
navigate(`/run?laneId=${lane.id}&autostart=1`);
navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(run.id)}`);
setOpen(false);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
brain.setBubble(`couldn't start that session: ${msg}`);
brain.setBubble(t("sagi.startFailed", { error: msg }));
} finally {
brain.setThinking(false);
}
})();
return null;
},
[brain, navigate]
[brain, navigate, t]
);
if (!enabled) return null;
@@ -314,6 +334,7 @@ export function Sagi() {
onPointerDown={place.onPointerDown}
onPointerMove={place.onPointerMove}
onPointerUp={place.onPointerUp}
onKeyDown={place.onKeyDown}
onClick={() => {
// A drag just ended - swallow the synthetic click so the panel
// doesn't toggle when the user only repositioned the avatar.
@@ -326,9 +347,9 @@ export function Sagi() {
setOpen(false);
setMenu(true);
}}
aria-label={open ? "Close Sagi" : "Open Sagi companion"}
aria-label={open ? t("sagi.closeAvatar") : t("sagi.open")}
aria-expanded={open}
title="Sagi - ⌘B · drag to move · right-click for pets"
title={t("sagi.avatarTitle")}
>
{pet.id === "sagi" || !("kind" in pet) ? (
<SagiAvatar mood={brain.mood} reducedMotion={reducedMotion} size={size} />
+27 -17
View File
@@ -63,6 +63,7 @@ import {
Radio,
type LucideIcon,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import type { SagiStatus } from "./brain";
interface SagiPanelProps {
@@ -85,6 +86,7 @@ export function SagiPanel({
onAsk,
onClose,
}: SagiPanelProps) {
const { t } = useTranslation("settings");
const [query, setQuery] = useState("");
const [answer, setAnswer] = useState<string | null>(null);
@@ -97,9 +99,9 @@ export function SagiPanel({
return (
<div
className="w-72 overflow-hidden rounded-2xl border border-border-light bg-surface-2/95 shadow-2xl shadow-black/50 backdrop-blur-md animate-slide-up"
className="w-72 overflow-hidden rounded-2xl border border-border-light bg-surface-2/95 shadow-2xl backdrop-blur-md animate-slide-up"
role="dialog"
aria-label="Sagi companion"
aria-label={t("sagi.panel.label")}
>
{/* header */}
<div className="flex items-center justify-between gap-2 border-b border-border/70 bg-gradient-to-r from-accent/10 to-transparent px-3.5 py-2.5">
@@ -121,13 +123,13 @@ export function SagiPanel({
}`}
aria-hidden
/>
{status.connected ? "Live" : "Offline"}
{status.connected ? t("sagi.panel.live") : t("sagi.panel.offline")}
</span>
</div>
<button
className="rounded-md p-1 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-secondary"
onClick={onClose}
aria-label="Close Sagi"
aria-label={t("sagi.panel.close")}
>
<X size={15} />
</button>
@@ -138,19 +140,19 @@ export function SagiPanel({
<div className="mb-3 grid grid-cols-3 gap-1.5">
<StatChip
icon={Radio}
label="live"
label={t("sagi.panel.statLive")}
value={status.liveCount}
tone={status.liveCount > 0 ? "accent" : "muted"}
/>
<StatChip
icon={Hourglass}
label="waiting"
label={t("sagi.panel.statWaiting")}
value={status.waitingCount}
tone={status.waitingCount > 0 ? "amber" : "muted"}
/>
<StatChip
icon={AlertTriangle}
label="errored"
label={t("sagi.panel.statErrored")}
value={status.errorCount}
tone={status.errorCount > 0 ? "red" : "muted"}
/>
@@ -158,27 +160,35 @@ export function SagiPanel({
{/* quick actions */}
<div className="mb-3 grid grid-cols-2 gap-1.5">
<ActionButton icon={Play} label="Run Claude" onClick={() => onNavigate("/workspace")} />
<ActionButton icon={Activity} label="Activity" onClick={() => onNavigate("/activity")} />
<ActionButton
icon={Play}
label={t("sagi.panel.runClaude")}
onClick={() => onNavigate("/workspace")}
/>
<ActionButton
icon={Activity}
label={t("sagi.panel.activity")}
onClick={() => onNavigate("/activity")}
/>
<ActionButton
icon={LayoutList}
label="Sessions"
label={t("sagi.panel.sessions")}
onClick={() => onNavigate("/sessions")}
/>
<ActionButton
icon={AlertTriangle}
label="Errored"
label={t("sagi.panel.errored")}
disabled={status.errorCount === 0}
onClick={() => onNavigate("/sessions")}
/>
<ActionButton
icon={muted ? BellOff : Bell}
label={muted ? "Unmute" : "Mute"}
label={muted ? t("sagi.panel.unmute") : t("sagi.panel.mute")}
onClick={onToggleMute}
/>
<ActionButton
icon={Trash2}
label="Clear alerts"
label={t("sagi.panel.clearAlerts")}
disabled={status.errorCount === 0}
onClick={onClearAlerts}
/>
@@ -188,15 +198,15 @@ export function SagiPanel({
<form onSubmit={submit} className="flex items-center gap-1.5">
<input
className="flex-1 rounded-lg border border-border bg-surface-1 px-2.5 py-1.5 text-xs text-fg-secondary placeholder-fg-muted transition-colors focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/30"
placeholder="Ask Sagi… (e.g. any errors?)"
placeholder={t("sagi.panel.askPlaceholder")}
value={query}
onChange={(e) => setQuery(e.target.value)}
aria-label="Ask Sagi"
aria-label={t("sagi.panel.ask")}
/>
<button
type="submit"
className="flex items-center justify-center rounded-lg bg-accent px-2.5 py-2 text-white transition-colors hover:bg-accent-hover"
aria-label="Send"
className="btn-primary justify-center px-2.5 py-2"
aria-label={t("sagi.panel.send")}
>
<Send size={14} />
</button>
+4 -1
View File
@@ -51,6 +51,8 @@
*
* ----------------------------------------------------------------------------- */
import { useTranslation } from "react-i18next";
/** Props for {@link SpeechBubble}. */
interface SpeechBubbleProps {
/** Quip text to display inside the bubble. */
@@ -64,13 +66,14 @@ interface SpeechBubbleProps {
* @param props See {@link SpeechBubbleProps}.
*/
export function SpeechBubble({ text, onDismiss }: SpeechBubbleProps) {
const { t } = useTranslation("settings");
return (
<div
className="sagi-bubble sagi-bubble-enter"
role="status"
aria-live="polite"
onClick={onDismiss}
title="Dismiss"
title={t("sagi.bubbleDismiss")}
>
{text}
</div>
@@ -63,6 +63,16 @@ import { PetSprite } from "../PetSprite";
import { PetMenu } from "../PetMenu";
import { PetSettings } from "../../settings/PetSettings";
import { sagiPrefs } from "../prefs";
import { settleConfirm, subscribeConfirm, type PendingConfirm } from "../../../lib/confirm";
import { subscribeToasts, type ToastItem } from "../../../lib/toast";
/** Answer the pending confirmDialog (asserting one is open). */
function answerConfirm(ok: boolean) {
let pending: PendingConfirm | null = null;
subscribeConfirm((p) => (pending = p))();
expect(pending).not.toBeNull();
act(() => settleConfirm(ok));
}
describe("PetSprite", () => {
beforeEach(() => vi.useFakeTimers());
@@ -144,8 +154,13 @@ describe("PetMenu", () => {
renderMenu();
fireEvent.change(screen.getByTestId("pet-size"), { target: { value: "250" } });
expect(sagiPrefs.getSize()).toBe(250);
let toasts: ToastItem[] = [];
const off = subscribeToasts((i) => (toasts = i));
fireEvent.click(screen.getByText("Hide pet"));
expect(sagiPrefs.getEnabled()).toBe(false);
// Says where to bring it back.
expect(toasts.some((x) => /Settings → Pet/.test(x.message))).toBe(true);
off();
});
it("downloads a featured pet on its first pick, then wears it", async () => {
@@ -161,6 +176,9 @@ describe("PetMenu", () => {
renderMenu();
fireEvent.click(screen.getByTestId("pet-choose"));
fireEvent.click(await screen.findByTestId("pet-delete-p-aaaaaaaaaaaa"));
// Asks first: nothing is deleted on one click.
expect(api.pets.remove).not.toHaveBeenCalled();
answerConfirm(true);
await waitFor(() => expect(sagiPrefs.getPet()).toEqual({ id: "sagi" }));
expect(api.pets.remove).toHaveBeenCalledWith("p-aaaaaaaaaaaa");
});
@@ -207,6 +225,7 @@ describe("PetSettings", () => {
fireEvent.click(screen.getByTestId("pet-card-own-p-aaaaaaaaaaaa"));
expect(sagiPrefs.getPet()).toEqual({ id: "p-aaaaaaaaaaaa", kind: "image" });
fireEvent.click(screen.getByTestId("pet-card-own-p-aaaaaaaaaaaa-delete"));
answerConfirm(true);
await waitFor(() => expect(sagiPrefs.getPet()).toEqual({ id: "sagi" }));
expect(api.pets.remove).toHaveBeenCalledWith("p-aaaaaaaaaaaa");
});
@@ -219,4 +238,27 @@ describe("PetSettings", () => {
expect(sagiPrefs.getPet()).toEqual({ id: "p-bbbbbbbbbbbb", kind: "image" })
);
});
it("a cancelled delete keeps the pet", async () => {
render(<PetSettings />);
fireEvent.click(await screen.findByTestId("pet-card-own-p-aaaaaaaaaaaa-delete"));
answerConfirm(false);
await act(async () => {});
expect(api.pets.remove).not.toHaveBeenCalled();
});
it("disables size and bubbles while hidden, and resets to defaults", async () => {
sagiPrefs.setEnabled(false);
sagiPrefs.setSize(200);
sagiPrefs.setMuted(true);
render(<PetSettings />);
expect(screen.getByTestId("pet-settings-size")).toBeDisabled();
expect(screen.getByRole("switch", { name: /Speech bubbles/ })).toBeDisabled();
fireEvent.click(screen.getByRole("button", { name: /Reset to defaults/ }));
answerConfirm(true);
await waitFor(() => expect(sagiPrefs.getEnabled()).toBe(true));
expect(sagiPrefs.getSize()).toBe(68);
expect(sagiPrefs.getMuted()).toBe(false);
expect(sagiPrefs.getPet()).toEqual({ id: "sagi" });
});
});
@@ -147,4 +147,48 @@ describe("Sagi widget", () => {
// Left-docked → inline left equals the edge margin (16px).
expect(btn.style.left).toBe("16px");
});
it("Ctrl/Cmd+B is ignored while typing in a field", () => {
renderSagi();
const ta = document.createElement("textarea");
document.body.appendChild(ta);
ta.focus();
act(() => {
fireEvent.keyDown(ta, { key: "b", ctrlKey: true });
});
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
ta.remove();
});
it("Ctrl/Cmd+B is left to the browser while the mascot is hidden", () => {
localStorage.setItem("agent-dashboard-sagi-enabled", "false");
renderSagi();
const ev = new KeyboardEvent("keydown", { key: "b", ctrlKey: true, cancelable: true });
window.dispatchEvent(ev);
expect(ev.defaultPrevented).toBe(false);
});
it("docks bottom-right above the status bar by default", () => {
renderSagi();
const btn = screen.getByRole("button", { name: /open sagi companion/i }) as HTMLElement;
const top = parseFloat(btn.style.top);
const size = parseFloat(btn.style.width);
// Bottom edge sits above the 24px status bar plus the edge margin.
expect(top + size).toBe(window.innerHeight - 16 - 24);
});
it("arrow keys move the focused avatar and persist it", () => {
renderSagi();
const btn = screen.getByRole("button", { name: /open sagi companion/i }) as HTMLElement;
const before = parseFloat(btn.style.top);
act(() => {
fireEvent.keyDown(btn, { key: "ArrowUp" });
});
expect(parseFloat(btn.style.top)).toBeLessThan(before);
act(() => {
fireEvent.keyDown(btn, { key: "ArrowLeft" });
});
expect(btn.style.left).toBe("16px");
expect(JSON.parse(localStorage.getItem("agent-dashboard-sagi-pos") || "{}").side).toBe("left");
});
});
+14 -4
View File
@@ -3,14 +3,16 @@
* right-click menu (`PetMenu.tsx`) and Settings → Pet (`settings/PetSettings`):
* `GET /api/pets`, wearing a pet (a viewer preference, `prefs.ts`), installing
* a featured pet (downloaded on its first pick), uploading an image or a
* `.codex-pet.zip` (worn at once), and deleting your own (Sagi comes back
* when it was the one worn). Plus `useWornPet`, the live worn pet / size /
* `.codex-pet.zip` (worn at once), and deleting your own after a confirm
* (Sagi comes back when it was the one worn). Plus `useWornPet`, the live worn pet / size /
* shown / speech-bubble prefs.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState } from "react";
import { api, type PetList, type PetView } from "../../lib/api";
import { confirmDialog } from "../../lib/confirm";
import i18n from "../../i18n";
import { sagiPrefs, type PetChoice } from "./prefs";
const message = (err: unknown) => (err instanceof Error ? err.message : String(err));
@@ -57,12 +59,20 @@ export function usePets() {
wear(pet);
await reload();
});
const remove = (p: PetView) =>
run(p.id, async () => {
// One place both callers route through, so neither can delete on one click.
const remove = async (p: PetView) => {
const ok = await confirmDialog({
title: i18n.t("settings:sagi.pet.deleteTitle", { name: p.displayName }),
message: i18n.t("settings:sagi.pet.deleteMessage"),
confirmLabel: i18n.t("settings:sagi.pet.deleteConfirm"),
});
if (!ok) return;
await run(p.id, async () => {
await api.pets.remove(p.id);
if (sagiPrefs.getPet().id === p.id) sagiPrefs.setPet({ id: "sagi" });
await reload();
});
};
const own = list?.pets.filter((p) => p.source === "upload") ?? [];
// Featured id → the installed copy, once downloaded.
+39 -6
View File
@@ -5,7 +5,10 @@
* keeps tracking even if the cursor outruns it), and on release snaps to the
* nearest left/right edge, remembering its vertical offset (persisted as a
* viewport fraction so it survives resizes). A small movement threshold tells
* a drag apart from a tap so dragging never opens the panel.
* a drag apart from a tap so dragging never opens the panel. The arrow keys
* move a focused avatar too (WCAG 2.5.7): ←/→ dock it to an edge, ↑/↓ step it.
* It never rests over the fixed bottom status bar; by default it docks
* bottom-right just above it.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -70,23 +73,28 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { sagiPrefs, type SagiPos } from "./prefs";
import type { PointerEvent as ReactPointerEvent } from "react";
import type { KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent } from "react";
// Avatar footprint + edge gap, in px. SIZE matches SagiAvatar's default size.
export const SAGI_SIZE = 68;
export const SAGI_MARGIN = 16;
const DRAG_THRESHOLD = 5;
// The fixed bottom StatusBar (`h-6`); the avatar never rests over it.
const STATUS_BAR = 24;
// One arrow-key step, in px.
const KEY_STEP = 40;
const vw = () => (typeof window !== "undefined" ? window.innerWidth : 1024);
const vh = () => (typeof window !== "undefined" ? window.innerHeight : 768);
function defaultPos(): SagiPos {
return { side: "right", y: 0.5 }; // right edge, vertically centered
// Bottom-right, above the status bar: a centred dock covered row controls.
return { side: "right", y: 1 };
}
/** Resting top-left screen coords for a docked position. */
function restingScreen(pos: SagiPos, size: number) {
const avail = Math.max(0, vh() - size - 2 * SAGI_MARGIN);
const avail = Math.max(0, vh() - size - 2 * SAGI_MARGIN - STATUS_BAR);
const left = pos.side === "left" ? SAGI_MARGIN : vw() - size - SAGI_MARGIN;
const top = SAGI_MARGIN + pos.y * avail;
return { left, top };
@@ -106,6 +114,8 @@ export interface SagiPlacement {
onPointerUp: (e: ReactPointerEvent) => void;
/** Returns true (once) if a drag just ended, so the click handler can skip. */
consumeDrag: () => boolean;
/** Arrow keys on the focused avatar: ←/→ dock to an edge, ↑/↓ step. */
onKeyDown: (e: ReactKeyboardEvent) => void;
}
/** `size`: the avatar's current footprint (the pet size pref), in px. */
@@ -156,7 +166,10 @@ export function useSagiPosition(size: number = SAGI_SIZE): SagiPlacement {
if (!movedRef.current && Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
movedRef.current = true;
const left = Math.min(vw() - size - SAGI_MARGIN, Math.max(SAGI_MARGIN, start.left + dx));
const top = Math.min(vh() - size - SAGI_MARGIN, Math.max(SAGI_MARGIN, start.top + dy));
const top = Math.min(
vh() - size - SAGI_MARGIN - STATUS_BAR,
Math.max(SAGI_MARGIN, start.top + dy)
);
liveRef.current = { left, top };
setDrag({ left, top });
},
@@ -174,7 +187,7 @@ export function useSagiPosition(size: number = SAGI_SIZE): SagiPlacement {
if (live) {
draggedRef.current = true;
const side: "left" | "right" = live.left + size / 2 < vw() / 2 ? "left" : "right";
const avail = Math.max(1, vh() - size - 2 * SAGI_MARGIN);
const avail = Math.max(1, vh() - size - 2 * SAGI_MARGIN - STATUS_BAR);
const y = Math.min(1, Math.max(0, (live.top - SAGI_MARGIN) / avail));
const next: SagiPos = { side, y };
sagiPrefs.setPos(next);
@@ -188,6 +201,25 @@ export function useSagiPosition(size: number = SAGI_SIZE): SagiPlacement {
[size]
);
const onKeyDown = useCallback(
(e: ReactKeyboardEvent) => {
if (e.altKey || e.ctrlKey || e.metaKey) return;
const avail = Math.max(1, vh() - size - 2 * SAGI_MARGIN - STATUS_BAR);
let next: SagiPos | null = null;
if (e.key === "ArrowLeft") next = { ...pos, side: "left" };
else if (e.key === "ArrowRight") next = { ...pos, side: "right" };
else if (e.key === "ArrowUp" || e.key === "ArrowDown") {
const dy = (e.key === "ArrowUp" ? -KEY_STEP : KEY_STEP) / avail;
next = { ...pos, y: Math.min(1, Math.max(0, pos.y + dy)) };
}
if (!next) return;
e.preventDefault();
sagiPrefs.setPos(next);
setPos(next);
},
[pos, size]
);
const consumeDrag = useCallback(() => {
const was = draggedRef.current;
draggedRef.current = false;
@@ -205,5 +237,6 @@ export function useSagiPosition(size: number = SAGI_SIZE): SagiPlacement {
onPointerMove,
onPointerUp,
consumeDrag,
onKeyDown,
};
}
+47 -25
View File
@@ -16,7 +16,9 @@
* — and the poll waits for the old server to actually go DOWN first, because it
* keeps answering while the chain installs. New skills/slash commands still
* need `/reload-plugins` in an already-open Claude Code session; nothing a
* background process can do for it.
* background process can do for it. Escape, the backdrop and "Not now" close it
* for this page load only (never while applying); "Skip this version" is the
* one persistent, per-version dismiss.
*
* ## State sources
* - Initial fetch via `api.updates.status()` on mount.
@@ -76,11 +78,13 @@
*
* ----------------------------------------------------------------------------- */
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Download, X, Copy, Check, RefreshCw, Zap, ExternalLink } from "lucide-react";
import { api } from "../lib/api";
import { eventBus } from "../lib/eventBus";
import { copyText } from "../lib/clipboard";
import { useDialog } from "../hooks/useDialog";
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
/** `localStorage` key storing the dismissed release tag (or upstream SHA). */
@@ -125,6 +129,10 @@ export function UpdateNotifier() {
const { t } = useTranslation("updates");
const [status, setStatus] = useState<UpdateStatusPayload | null>(null);
const [dismissedSha, setDismissedSha] = useState<string | null>(loadDismissedSha);
// Closed for THIS page load only (Escape, backdrop, ×, "Not now") — the
// persistent per-version dismiss is the explicit "Skip this version".
const [closedKey, setClosedKey] = useState<string | null>(null);
const panelRef = useRef<HTMLDivElement>(null);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [checking, setChecking] = useState(false);
@@ -192,36 +200,37 @@ export function UpdateNotifier() {
status?.latest_release ||
status?.remote_sha ||
(installed ? `installed:${installed.root}` : null);
const show = Boolean(status?.update_available && dismissKey && dismissedSha !== dismissKey);
const show = Boolean(
status?.update_available &&
dismissKey &&
dismissedSha !== dismissKey &&
closedKey !== dismissKey
);
const busy = applying || restarting;
const closeForNow = useCallback(() => {
if (busy || !dismissKey) return;
setClosedKey(dismissKey);
}, [busy, dismissKey]);
const dismiss = useCallback(() => {
if (restarting || !dismissKey) return;
if (busy || !dismissKey) return;
try {
localStorage.setItem(DISMISS_KEY, dismissKey);
} catch {
/* ignore */
}
setDismissedSha(dismissKey);
}, [restarting, dismissKey]);
}, [busy, dismissKey]);
// Escape to dismiss - standard modal affordance.
useEffect(() => {
if (!show) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") dismiss();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [show, dismiss]);
// Escape closes for now — never while an update is applying.
useDialog(show, panelRef, busy ? undefined : closeForNow);
const copyCmd = async () => {
if (!status?.manual_command) return;
try {
await navigator.clipboard.writeText(status.manual_command);
if (await copyText(status.manual_command, true)) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
/* ignore */
}
};
@@ -304,17 +313,20 @@ export function UpdateNotifier() {
return (
<div
className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-fade-in"
className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-surface-0/70 backdrop-blur-sm animate-fade-in"
role="dialog"
aria-modal="true"
aria-labelledby="update-notifier-title"
onClick={(e) => {
if (e.target === e.currentTarget) dismiss();
if (e.target === e.currentTarget) closeForNow();
}}
>
{/* max-w-2xl, not lg: the footer carries up to four buttons and the
longest label is a whole sentence ("restarting the dashboard…"). */}
<div className="w-full max-w-2xl card shadow-2xl animate-slide-up overflow-hidden">
<div
ref={panelRef}
className="w-full max-w-2xl card shadow-2xl animate-slide-up overflow-hidden"
>
{/* Header */}
<div className="flex items-start justify-between gap-3 px-5 py-4 border-b border-border">
<div className="flex items-center gap-3 min-w-0">
@@ -343,8 +355,9 @@ export function UpdateNotifier() {
</div>
<button
type="button"
onClick={dismiss}
aria-label={t("dismiss")}
onClick={closeForNow}
disabled={busy}
aria-label={t("later")}
className="p-1.5 -m-1 rounded-lg text-fg-muted hover:text-fg-secondary hover:bg-surface-4 transition-colors flex-shrink-0"
>
<X className="w-4 h-4" />
@@ -460,10 +473,19 @@ export function UpdateNotifier() {
<button
type="button"
onClick={dismiss}
disabled={restarting}
disabled={busy}
title={t("skipVersionHint")}
className="btn-ghost disabled:opacity-60 disabled:cursor-not-allowed"
>
{t("dismiss")}
{t("skipVersion")}
</button>
<button
type="button"
onClick={closeForNow}
disabled={busy}
className="btn-ghost disabled:opacity-60 disabled:cursor-not-allowed"
>
{t("later")}
</button>
{status.manual_command ? (
<button
+32 -17
View File
@@ -86,6 +86,7 @@ import { Checkbox } from "./Checkbox";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "./ContextMenu";
import { WEBHOOK_DOCS } from "./webhookGuides";
import { timeAgo } from "../lib/format";
import { toast } from "../lib/toast";
import type {
AlertRule,
WebhookProvider,
@@ -94,20 +95,21 @@ import type {
WebhookTestResult,
} from "../lib/types";
// Brand-ish accent per provider type; anything unmapped falls back to neutral.
// Brand-ish accent per provider type, on the BORDER only: brand hues fail
// contrast as text on several themes, so the label stays text-fg-primary.
const TYPE_STYLES: Partial<Record<WebhookType, string>> = {
slack: "text-[#E01E5A] bg-[#E01E5A]/10 border-[#E01E5A]/20",
discord: "text-[#5865F2] bg-[#5865F2]/10 border-[#5865F2]/20",
teams: "text-[#6264A7] bg-[#6264A7]/10 border-[#6264A7]/20",
google_chat: "text-[#1A73E8] bg-[#1A73E8]/10 border-[#1A73E8]/20",
mattermost: "text-[#0058CC] bg-[#0058CC]/10 border-[#0058CC]/20",
rocketchat: "text-[#F5455C] bg-[#F5455C]/10 border-[#F5455C]/20",
telegram: "text-[#26A5E4] bg-[#26A5E4]/10 border-[#26A5E4]/20",
pagerduty: "text-[#06AC38] bg-[#06AC38]/10 border-[#06AC38]/20",
opsgenie: "text-[#2684FF] bg-[#2684FF]/10 border-[#2684FF]/20",
splunk_oncall: "text-[#F99D1C] bg-[#F99D1C]/10 border-[#F99D1C]/20",
slack: "border-[#E01E5A]/60",
discord: "border-[#5865F2]/60",
teams: "border-[#6264A7]/60",
google_chat: "border-[#1A73E8]/60",
mattermost: "border-[#0058CC]/60",
rocketchat: "border-[#F5455C]/60",
telegram: "border-[#26A5E4]/60",
pagerduty: "border-[#06AC38]/60",
opsgenie: "border-[#2684FF]/60",
splunk_oncall: "border-[#F99D1C]/60",
};
const NEUTRAL_STYLE = "text-fg-secondary bg-surface-2 border-border";
const NEUTRAL_STYLE = "border-border";
interface HeaderRow {
key: string;
@@ -168,11 +170,13 @@ export function WebhookSettings() {
const res = await api.webhooks.list();
setTargets(res.targets);
} catch (err) {
console.error("Failed to load webhook targets:", err);
toast.error(
t("webhooks.loadFailed", { error: err instanceof Error ? err.message : String(err) })
);
} finally {
setLoading(false);
}
}, []);
}, [t]);
useEffect(() => {
load();
@@ -252,6 +256,9 @@ export function WebhookSettings() {
const canSubmit = useMemo(() => {
if (!form || !provider) return false;
if (!form.name.trim()) return false;
// An empty rule list means ALL rules server-side — never save that from
// "only selected rules".
if (!form.scopeAll && form.ruleIds.length === 0) return false;
if (isEdit) return true; // server merge - existing values fill the gaps
if (provider.url_required && !form.url.trim()) return false;
for (const f of provider.fields) {
@@ -326,7 +333,9 @@ export function WebhookSettings() {
await api.webhooks.update(target.id, { enabled: !target.enabled });
load();
} catch (err) {
console.error("Failed to toggle webhook:", err);
toast.error(
t("webhooks.toggleFailed", { error: err instanceof Error ? err.message : String(err) })
);
}
};
@@ -336,7 +345,10 @@ export function WebhookSettings() {
setConfirmDelete(null);
load();
} catch (err) {
console.error("Failed to delete webhook:", err);
setConfirmDelete(null);
toast.error(
t("webhooks.deleteFailed", { error: err instanceof Error ? err.message : String(err) })
);
}
};
@@ -402,7 +414,7 @@ export function WebhookSettings() {
>
<div className="flex items-center gap-3 flex-wrap">
<span
className={`inline-flex items-center gap-1 text-[10px] font-semibold uppercase tracking-wide px-2 py-0.5 rounded border ${
className={`inline-flex items-center gap-1 text-[10px] font-semibold uppercase tracking-wide px-2 py-0.5 rounded border bg-surface-2 text-fg-primary ${
TYPE_STYLES[target.type] || NEUTRAL_STYLE
}`}
>
@@ -765,6 +777,9 @@ export function WebhookSettings() {
))}
</div>
)}
{!form.scopeAll && form.ruleIds.length === 0 && (
<p className="mt-1.5 text-[11px] text-status-warning">{t("webhooks.pickRule")}</p>
)}
</div>
)}
@@ -0,0 +1,79 @@
/**
* @file AlertsNotifications.test.tsx
* @description Alert rules are editable through the create form (PATCH, type
* fixed), and an invalid cooldown blocks saving instead of silently becoming 300.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
const update = vi.fn(async () => ({ rule: {} }));
const create = vi.fn(async () => ({ rule: {} }));
vi.mock("../../lib/api", () => ({
api: {
alerts: {
rules: {
list: vi.fn(async () => ({
rules: [
{
id: "r1",
name: "Idle",
rule_type: "inactivity",
config: { minutes: 30 },
enabled: true,
cooldown_seconds: 60,
},
],
})),
update: (...a: unknown[]) => update(...(a as [])),
create: (...a: unknown[]) => create(...(a as [])),
remove: vi.fn(),
},
list: vi.fn(async () => ({ alerts: [], total: 0, unacked: 0 })),
ack: vi.fn(),
ackAll: vi.fn(),
},
webhooks: {
list: vi.fn(async () => ({ targets: [] })),
providers: vi.fn(async () => ({ providers: [] })),
},
},
}));
import { AlertsNotifications } from "../AlertsNotifications";
const renderIt = () =>
render(
<MemoryRouter>
<AlertsNotifications />
</MemoryRouter>
);
describe("AlertsNotifications rules", () => {
it("edits a rule in place and validates the cooldown", async () => {
renderIt();
await userEvent.click(await screen.findByRole("button", { name: /edit rule/i }));
const name = screen.getByDisplayValue("Idle");
const cooldown = screen.getByDisplayValue("60");
await userEvent.clear(cooldown);
await userEvent.type(cooldown, "-5");
const save = screen.getByRole("button", { name: /save rule/i });
expect(save).toBeDisabled();
await userEvent.clear(cooldown);
await userEvent.type(cooldown, "120");
await userEvent.clear(name);
await userEvent.type(name, "Idle long");
await userEvent.click(save);
await waitFor(() =>
expect(update).toHaveBeenCalledWith("r1", {
name: "Idle long",
config: { minutes: 30 },
cooldown_seconds: 120,
})
);
expect(create).not.toHaveBeenCalled();
}, 15_000); // many typed fields: slow under a parallel run
});
@@ -221,3 +221,28 @@ describe("UpdateNotifier — Update now", () => {
expect(screen.queryByText(i18n.t("updates:restarting"))).not.toBeInTheDocument();
});
});
describe("UpdateNotifier — closing vs skipping", () => {
it("Escape only closes for now; Skip this version is the persistent dismiss", async () => {
const { unmount } = render(<UpdateNotifier />);
await screen.findByText(i18n.t("updates:title"));
await userEvent.keyboard("{Escape}");
await waitFor(() =>
expect(screen.queryByText(i18n.t("updates:title"))).not.toBeInTheDocument()
);
expect(localStorage.getItem("agent-monitor-update-dismissed-sha")).toBeNull();
unmount();
render(<UpdateNotifier />);
await userEvent.click(await screen.findByText(i18n.t("updates:skipVersion")));
expect(localStorage.getItem("agent-monitor-update-dismissed-sha")).toBe("bbb");
});
it("ignores Escape while an update is applying", async () => {
applyMock.mockReturnValue(new Promise(() => {}));
render(<UpdateNotifier />);
await userEvent.click(await screen.findByText(i18n.t("updates:updateNow")));
await userEvent.keyboard("{Escape}");
expect(screen.getByText(i18n.t("updates:title"))).toBeInTheDocument();
});
});
@@ -0,0 +1,85 @@
/**
* @file WebhookSettings.test.tsx
* @description The webhook form must not save "only selected rules" with no
* rule ticked (the server reads an empty list as ALL rules), and a failed
* toggle must surface as an error toast instead of only a console line.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
const toastError = vi.fn();
vi.mock("../../lib/toast", () => ({
toast: { error: (m: unknown) => toastError(m), success: vi.fn(), info: vi.fn() },
}));
const update = vi.fn();
vi.mock("../../lib/api", () => ({
api: {
webhooks: {
list: vi.fn(async () => ({
targets: [
{
id: "w1",
name: "Team",
type: "generic",
url_preview: "https://x/…",
enabled: true,
rule_ids: [],
config: {},
},
],
})),
providers: vi.fn(async () => ({
providers: [
{
type: "generic",
label: "Generic",
url_required: true,
has_default_url: false,
supports_secret: false,
supports_headers: false,
fields: [],
},
],
})),
update: (...a: unknown[]) => update(...a),
create: vi.fn(),
remove: vi.fn(),
test: vi.fn(),
},
alerts: { rules: { list: vi.fn(async () => ({ rules: [{ id: "r1", name: "Rule one" }] })) } },
},
}));
import { WebhookSettings } from "../WebhookSettings";
beforeEach(() => {
toastError.mockReset();
update.mockReset();
});
describe("WebhookSettings", () => {
it("blocks 'only selected rules' with none ticked", async () => {
render(<WebhookSettings />);
await userEvent.click(await screen.findByRole("button", { name: /add/i }));
const inputs = screen.getAllByRole("textbox");
await userEvent.type(inputs[0]!, "Hook");
await userEvent.type(inputs[1]!, "https://example.com/h");
const create = screen.getByRole("button", { name: /^create/i });
expect(create).toBeEnabled();
await userEvent.click(screen.getByRole("checkbox", { name: /all/i }));
expect(create).toBeDisabled();
await userEvent.click(screen.getByRole("checkbox", { name: "Rule one" }));
expect(create).toBeEnabled();
});
it("toasts a failed toggle", async () => {
update.mockRejectedValue(new Error("boom"));
render(<WebhookSettings />);
await userEvent.click(await screen.findByRole("switch"));
await waitFor(() => expect(toastError).toHaveBeenCalled());
});
});
+61 -102
View File
@@ -1,19 +1,17 @@
/**
* @file ConfigTabBar.tsx
* @description The tab strip both config explorers wear: one horizontally
* scrollable row with fade edges and chevrons that appear only when there is
* something to scroll to.
* @description The tab strip both config explorers wear: tabs WRAP instead of
* scrolling behind a hidden scrollbar, an optional `group` per tab draws a
* labelled separator between groups (Content · Extensions · Config), and the
* strip is a real ARIA tablist — roving tabindex, ←/→/Home/End move and
* select, each tab `aria-controls` the panel.
*
* Shared rather than copied because the two pages must look the same. Codex
* originally wrapped its tabs onto a second row — fine at eight tabs, but it
* read as a different component sitting next to Claude Code's twelve-tab
* scroller. One row that scrolls is the same shape at any tab count.
* Shared rather than copied because the two pages must look the same.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useRef, useState, type ComponentType } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { Fragment, useRef, type ComponentType, type KeyboardEvent } from "react";
export interface ConfigTab<K extends string> {
key: K;
@@ -21,15 +19,19 @@ export interface ConfigTab<K extends string> {
label: string;
/** Badge number, or null for tabs that count nothing (e.g. Overview). */
count: number | null;
/** Group label; a change of group between neighbours draws a separator. */
group?: string;
}
interface ConfigTabBarProps<K extends string> {
tabs: ConfigTab<K>[];
current: K;
onSelect: (key: K) => void;
/** `data-testid` becomes `<prefix>-<key>`, e.g. "cc-tab-plugins". */
/** `data-testid` (and the tab's id) becomes `<prefix>-<key>`, e.g. "cc-tab-plugins". */
testIdPrefix: string;
ariaLabel: string;
/** The id of the tabpanel element every tab controls. */
panelId?: string;
}
export function ConfigTabBar<K extends string>({
@@ -38,90 +40,63 @@ export function ConfigTabBar<K extends string>({
onSelect,
testIdPrefix,
ariaLabel,
panelId,
}: ConfigTabBarProps<K>) {
const scrollRef = useRef<HTMLDivElement | null>(null);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);
const listRef = useRef<HTMLDivElement | null>(null);
// Update scroll affordances when content size or scroll position changes.
const updateAffordances = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
setCanScrollLeft(el.scrollLeft > 1);
setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
}, []);
useEffect(() => {
updateAffordances();
const el = scrollRef.current;
if (!el) return;
el.addEventListener("scroll", updateAffordances, { passive: true });
const ro = new ResizeObserver(updateAffordances);
ro.observe(el);
return () => {
el.removeEventListener("scroll", updateAffordances);
ro.disconnect();
};
}, [updateAffordances]);
// Scroll the active tab into view when it changes (e.g. user picks a tab
// that's offscreen, or window resize hides the active one).
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
const active = el.querySelector<HTMLElement>('[data-tab-active="true"]');
if (!active) return;
const elRect = el.getBoundingClientRect();
const activeRect = active.getBoundingClientRect();
if (activeRect.left < elRect.left + 8) {
el.scrollBy({ left: activeRect.left - elRect.left - 16, behavior: "smooth" });
} else if (activeRect.right > elRect.right - 8) {
el.scrollBy({ left: activeRect.right - elRect.right + 16, behavior: "smooth" });
}
}, [current]);
const scrollByButton = (dir: 1 | -1) => {
const el = scrollRef.current;
if (!el) return;
el.scrollBy({ left: dir * Math.max(200, el.clientWidth * 0.6), behavior: "smooth" });
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
const i = tabs.findIndex((t) => t.key === current);
const next =
e.key === "ArrowRight"
? (i + 1) % tabs.length
: e.key === "ArrowLeft"
? (i - 1 + tabs.length) % tabs.length
: e.key === "Home"
? 0
: e.key === "End"
? tabs.length - 1
: -1;
if (next < 0) return;
e.preventDefault();
const key = tabs[next]!.key;
onSelect(key);
listRef.current?.querySelector<HTMLElement>(`[data-testid="${testIdPrefix}-${key}"]`)?.focus();
};
return (
<div className="relative rounded-xl border border-border bg-surface-1">
{/* Left edge gradient + chevron */}
<div
className={`pointer-events-none absolute left-0 top-0 bottom-0 w-12 rounded-l-xl bg-gradient-to-r from-surface-1 to-transparent transition-opacity z-10 ${
canScrollLeft ? "opacity-100" : "opacity-0"
}`}
/>
{canScrollLeft && (
<button
onClick={() => scrollByButton(-1)}
aria-label="scroll tabs left"
className="absolute left-1 top-1/2 -translate-y-1/2 z-20 rounded-md w-7 h-7 flex items-center justify-center bg-surface-2 border border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
>
<ChevronLeft className="w-4 h-4" />
</button>
)}
<div
ref={scrollRef}
role="tablist"
aria-label={ariaLabel}
className="flex gap-1 p-1 overflow-x-auto scroll-smooth [&::-webkit-scrollbar]:hidden"
style={{ scrollbarWidth: "none" }}
>
{tabs.map(({ key, icon: Icon, label, count }) => {
const active = current === key;
return (
<div
ref={listRef}
role="tablist"
aria-label={ariaLabel}
onKeyDown={onKeyDown}
className="flex flex-wrap items-center gap-1 rounded-xl border border-border bg-surface-1 p-1"
>
{tabs.map(({ key, icon: Icon, label, count, group }, i) => {
const active = current === key;
const newGroup = group && group !== tabs[i - 1]?.group;
return (
<Fragment key={key}>
{newGroup && (
<span
aria-hidden
className={`flex items-center gap-1 px-1 text-[10px] font-semibold uppercase tracking-wider text-fg-muted ${
i > 0 ? "ml-1 border-l border-border pl-2" : ""
}`}
>
{group}
</span>
)}
<button
key={key}
type="button"
id={`${testIdPrefix}-${key}`}
data-testid={`${testIdPrefix}-${key}`}
data-tab-active={active ? "true" : undefined}
role="tab"
aria-selected={active}
aria-controls={panelId}
tabIndex={active ? 0 : -1}
onClick={() => onSelect(key)}
className={`flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors flex-shrink-0 whitespace-nowrap ${
className={`flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors whitespace-nowrap focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 ${
active
? "bg-accent/15 text-accent border border-accent/30"
: "text-fg-secondary hover:text-fg-primary hover:bg-surface-3 border border-transparent"
@@ -139,25 +114,9 @@ export function ConfigTabBar<K extends string>({
</span>
)}
</button>
);
})}
</div>
{/* Right edge gradient + chevron */}
<div
className={`pointer-events-none absolute right-0 top-0 bottom-0 w-12 rounded-r-xl bg-gradient-to-l from-surface-1 to-transparent transition-opacity z-10 ${
canScrollRight ? "opacity-100" : "opacity-0"
}`}
/>
{canScrollRight && (
<button
onClick={() => scrollByButton(1)}
aria-label="scroll tabs right"
className="absolute right-1 top-1/2 -translate-y-1/2 z-20 rounded-md w-7 h-7 flex items-center justify-center bg-surface-2 border border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
>
<ChevronRight className="w-4 h-4" />
</button>
)}
</Fragment>
);
})}
</div>
);
}
@@ -6,7 +6,8 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState } from "react";
import { useCallback, useRef, useState } from "react";
import { useDialog } from "../../hooks/useDialog";
import { useTranslation } from "react-i18next";
import { ShieldAlert, Trash2 } from "lucide-react";
import type { CcArtifactType } from "../../lib/api";
@@ -41,13 +42,10 @@ export function ConfirmDeleteModal<
const { t } = useTranslation("ccConfig");
const [busy, setBusy] = useState(false);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onCancel();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onCancel]);
const panelRef = useRef<HTMLDivElement>(null);
const cancelRef = useRef<HTMLButtonElement>(null);
// Focus starts on Cancel — the safe answer — and Escape cancels.
useDialog(true, panelRef, busy ? undefined : onCancel, cancelRef);
const handleConfirm = useCallback(async () => {
setBusy(true);
@@ -60,27 +58,36 @@ export function ConfirmDeleteModal<
return (
<div
className="fixed inset-0 bg-black/60 z-40 flex items-center justify-center p-4"
onClick={onCancel}
className="fixed inset-0 bg-surface-0/70 z-50 flex items-center justify-center p-4"
onClick={busy ? undefined : onCancel}
>
<div
ref={panelRef}
role="alertdialog"
aria-modal="true"
aria-labelledby="cc-confirm-delete-title"
aria-describedby="cc-confirm-delete-body"
className="relative w-full max-w-lg rounded-xl border border-status-danger/40 bg-surface-1"
onClick={(e) => e.stopPropagation()}
>
<div className="border-b border-border px-4 py-2.5 flex items-center gap-2">
<ShieldAlert className="w-4 h-4 text-status-danger" />
<span className="text-sm font-medium text-fg-primary flex-1">
<span id="cc-confirm-delete-title" className="text-sm font-medium text-fg-primary flex-1">
{t("edit.confirmDelete")}
</span>
</div>
<div className="p-4 space-y-3">
<p className="text-xs text-fg-secondary leading-relaxed">{t("edit.confirmDeleteBody")}</p>
<p id="cc-confirm-delete-body" className="text-xs text-fg-secondary leading-relaxed">
{t("edit.confirmDeleteBody")}
</p>
<div className="rounded-md border border-border bg-surface-2 px-3 py-2 font-mono text-[11px] text-fg-secondary break-all">
{t("edit.confirmDeletePath", { path: state.path })}
</div>
</div>
<div className="border-t border-border px-4 py-3 flex items-center justify-end gap-2">
<button
ref={cancelRef}
type="button"
onClick={onCancel}
disabled={busy}
className="text-[12px] font-medium px-3 py-1.5 rounded-md border border-border bg-surface-2 hover:bg-surface-3 text-fg-secondary disabled:opacity-60"
@@ -88,6 +95,7 @@ export function ConfirmDeleteModal<
{t("edit.cancel")}
</button>
<button
type="button"
onClick={handleConfirm}
disabled={busy}
className="text-[12px] font-medium px-3 py-1.5 rounded-md border border-status-danger/50 bg-status-danger/15 hover:bg-status-danger/25 text-status-danger inline-flex items-center gap-1.5 disabled:opacity-60"
+144 -38
View File
@@ -3,14 +3,27 @@
* @description Create/edit modal for text-file config artifacts (skills,
* agents, commands, output styles, memory). Tool-agnostic: it takes a
* `fetchContent` callback instead of reaching for any particular tool's API,
* so the same modal serves both the Claude Code and Codex config pages.
* so the same modal serves both the Claude Code and Codex config pages. It
* loads a file ONCE per path (never over the user's typing), refuses to save
* over a failed or truncated read, asks before discarding edits or overwriting
* a file changed on disk, and is a real dialog (focus trap, Escape).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Pencil, X, Save, AlertCircle } from "lucide-react";
import type { CcArtifactType } from "../../lib/api";
import { confirmDialog, confirmDiscard } from "../../lib/confirm";
import { useDialog } from "../../hooks/useDialog";
import { useUnsavedGuard } from "../../hooks/useUnsavedGuard";
/** What a fetcher may answer: the text, or the API's `{text, truncated}`. */
type Fetched = string | { text: string; truncated?: boolean };
const textOf = (r: Fetched) => (typeof r === "string" ? r : r.text);
/** Same rule the server applies to an artifact name. */
const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
/** Claude Code's own scope union, kept as the default so existing callers
* (CcConfig.tsx) don't need to name it. Codex has no `auto-memory` scope —
@@ -48,7 +61,7 @@ interface EditorModalProps<
TScope extends string = DefaultScope,
> {
state: NonNullable<EditorState<TType, TScope>>;
fetchContent: (filePath: string) => Promise<string>;
fetchContent: (filePath: string) => Promise<Fetched>;
onClose: () => void;
onSave: (args: {
type: TType;
@@ -60,6 +73,8 @@ interface EditorModalProps<
/** Which `state.type` values are nameless singleton files. Defaults to
* Claude Code's own `["memory"]` so existing callers are unaffected. */
namelessTypes?: readonly string[];
/** Refuse the Project target (its folder is the plugin cache, wiped on update). */
projectDisabled?: boolean;
}
export function EditorModal<
@@ -71,6 +86,7 @@ export function EditorModal<
onClose,
onSave,
namelessTypes = DEFAULT_NAMELESS_TYPES,
projectDisabled = false,
}: EditorModalProps<TType, TScope>) {
const { t } = useTranslation("ccConfig");
const isCreate = state.mode === "create";
@@ -88,41 +104,110 @@ export function EditorModal<
const [loading, setLoading] = useState(!isCreate);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// "ok" once an edit's text is in; a failed or truncated load never saves —
// Save over a failed load wrote an EMPTY file, over a truncated one a cut file.
const [loadState, setLoadState] = useState<"ok" | "failed" | "truncated">(
isCreate ? "ok" : "failed"
);
// The text as opened; `dirty` and the changed-on-disk check compare to it.
const original = useRef<string>(isCreate ? state.template : "");
const panelRef = useRef<HTMLDivElement>(null);
const titleId = "cc-editor-title";
const dirty = !loading && content !== original.current;
useUnsavedGuard(dirty);
// For edit mode, fetch the actual file content
useEffect(() => {
if (state.mode === "edit") {
setLoading(true);
fetchContent(state.filePath)
.then((text) => {
setContent(text);
setLoading(false);
})
.catch((err: unknown) => {
const msg = err instanceof Error ? err.message : "unknown";
setError(msg);
setLoading(false);
});
}
}, [state, fetchContent]);
// Read through a ref: callers pass an inline fetcher, and a dependency on it
// reloaded the file on every parent render — overwriting the user's typing.
const fetchRef = useRef(fetchContent);
fetchRef.current = fetchContent;
const editPath = state.mode === "edit" ? state.filePath : null;
// Esc to close
// `t` through a ref too: a language switch must not re-run the load below.
const tRef = useRef(t);
tRef.current = t;
// For edit mode, fetch the file once per path.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
if (editPath === null) return;
let live = true;
setLoading(true);
fetchRef
.current(editPath)
.then((r) => {
if (!live) return;
const text = textOf(r);
original.current = text;
setContent(text);
const cut = typeof r !== "string" && !!r.truncated;
setLoadState(cut ? "truncated" : "ok");
if (cut) setError(tRef.current("edit.truncatedReadOnly"));
setLoading(false);
})
.catch((err: unknown) => {
if (!live) return;
const msg = err instanceof Error ? err.message : "unknown";
setLoadState("failed");
setError(tRef.current("edit.loadError", { message: msg }));
setLoading(false);
});
return () => {
live = false;
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
}, [editPath]);
const requestClose = useCallback(async () => {
if (dirty && !(await confirmDiscard())) return;
onClose();
}, [dirty, onClose]);
// Escape closes — but not while typing in the textarea, where it only leaves
// the field (an editor's Escape must never cost a draft).
useDialog(true, panelRef, () => {
const el = document.activeElement;
if (el instanceof HTMLTextAreaElement) {
el.blur();
return;
}
void requestClose();
});
const handleSave = useCallback(async () => {
setSaving(true);
setError(null);
try {
if (!isNameless && state.mode === "create" && !name) {
setError(t("edit.nameLabel"));
setSaving(false);
return;
if (!isNameless && state.mode === "create") {
if (!name.trim()) {
setError(t("edit.nameRequired"));
return;
}
if (!isAutoMemory && !NAME_RE.test(name)) {
setError(t("edit.nameInvalid"));
return;
}
}
// The file changed on disk since it was opened (a running session, a
// terminal): say so instead of silently overwriting that change.
if (state.mode === "edit") {
const fresh = await fetchRef
.current(state.filePath)
.then(textOf)
.catch(() => null);
// Could not re-read it: say so rather than saving blind.
const ask =
fresh === null
? {
title: t("edit.unverifiedTitle"),
message: t("edit.unverifiedBody"),
confirmLabel: t("edit.saveAnyway"),
}
: fresh !== original.current
? {
title: t("edit.changedOnDiskTitle"),
message: t("edit.changedOnDiskBody"),
confirmLabel: t("edit.overwrite"),
}
: null;
if (ask && !(await confirmDialog(ask))) return;
}
// For auto-memory creates, append a .md extension when the user omits it.
const createName = isAutoMemory && !/\.md$/i.test(name) ? `${name}.md` : name;
@@ -140,13 +225,14 @@ export function EditorModal<
content,
project: state.project,
});
original.current = content;
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "unknown";
setError(t("edit.writeError", { message: msg }));
} finally {
setSaving(false);
}
}, [state, targetScope, name, content, isAutoMemory, onSave, t]);
}, [state, targetScope, name, content, isAutoMemory, isNameless, onSave, t]);
const titleText = isCreate
? isAutoMemory
@@ -156,18 +242,25 @@ export function EditorModal<
return (
<div
className="fixed inset-0 bg-black/60 z-40 flex items-center justify-center p-4"
onClick={onClose}
className="fixed inset-0 bg-surface-0/70 z-50 flex items-center justify-center p-4"
onClick={() => void requestClose()}
>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
className="relative w-full max-w-4xl max-h-[90vh] rounded-xl border border-border bg-surface-1 flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
<Pencil className="w-4 h-4 text-fg-muted" />
<span className="text-sm font-medium text-fg-primary flex-1 truncate">{titleText}</span>
<span id={titleId} className="text-sm font-medium text-fg-primary flex-1 truncate">
{titleText}
</span>
<button
onClick={onClose}
type="button"
onClick={() => void requestClose()}
className="text-fg-secondary hover:text-fg-primary p-1 rounded-md hover:bg-surface-3"
aria-label={t("common.close")}
>
@@ -214,8 +307,14 @@ export function EditorModal<
{(["user", "project"] as const).map((s) => (
<button
key={s}
type="button"
aria-pressed={targetScope === s}
disabled={s === "project" && projectDisabled}
title={
s === "project" && projectDisabled ? t("scope.projectInCache") : undefined
}
onClick={() => setTargetScope(s)}
className={`px-3 py-1 text-[11px] font-medium rounded ${
className={`px-3 py-1 text-[11px] font-medium rounded disabled:opacity-40 disabled:cursor-not-allowed ${
targetScope === s
? "bg-accent/20 text-accent border border-accent/30"
: "text-fg-secondary hover:text-fg-primary"
@@ -240,6 +339,8 @@ export function EditorModal<
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
readOnly={loadState !== "ok"}
aria-label={t("edit.contentLabel")}
spellCheck={false}
className="w-full h-72 bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50 resize-y"
/>
@@ -247,7 +348,10 @@ export function EditorModal<
</div>
{error && (
<div className="rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-xs text-status-danger inline-flex items-center gap-2">
<div
role="alert"
className="rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-xs text-status-danger inline-flex items-center gap-2"
>
<AlertCircle className="w-3.5 h-3.5" />
{error}
</div>
@@ -256,14 +360,16 @@ export function EditorModal<
<div className="border-t border-border px-4 py-3 flex items-center justify-end gap-2">
<button
onClick={onClose}
type="button"
onClick={() => void requestClose()}
className="text-[12px] font-medium px-3 py-1.5 rounded-md border border-border bg-surface-2 hover:bg-surface-3 text-fg-secondary"
>
{t("edit.cancel")}
</button>
<button
type="button"
onClick={handleSave}
disabled={saving || loading}
disabled={saving || loading || loadState !== "ok"}
className="text-[12px] font-medium px-3 py-1.5 rounded-md border border-accent/40 bg-accent/15 hover:bg-accent/25 text-accent inline-flex items-center gap-1.5 disabled:opacity-60"
>
<Save className="w-3.5 h-3.5" />
+14 -10
View File
@@ -8,7 +8,8 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useRef, useState } from "react";
import { useDialog } from "../../hooks/useDialog";
import { useTranslation } from "react-i18next";
import { FileText, X, AlertCircle, Eye, Code2 } from "lucide-react";
import type { CcFileResponse } from "../../lib/api";
@@ -25,25 +26,27 @@ export function FileViewer({
const { t } = useTranslation("ccConfig");
const [preview, setPreview] = useState(false);
const canPreview = isMarkdown(state.path) && !!state.data;
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const panelRef = useRef<HTMLDivElement>(null);
useDialog(true, panelRef, onClose);
return (
<div
className="fixed inset-0 bg-black/60 z-40 flex items-center justify-center p-4"
className="fixed inset-0 bg-surface-0/70 z-50 flex items-center justify-center p-4"
onClick={onClose}
>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-labelledby="cc-file-viewer-title"
className="relative w-full max-w-4xl max-h-[85vh] rounded-xl border border-border bg-surface-1 flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
<FileText className="w-4 h-4 text-fg-muted" />
<span className="font-mono text-[12px] text-fg-secondary truncate flex-1">
<span
id="cc-file-viewer-title"
className="font-mono text-[12px] text-fg-secondary truncate flex-1"
>
{state.path}
</span>
{canPreview && (
@@ -59,6 +62,7 @@ export function FileViewer({
)}
<CopyButton value={state.path} />
<button
type="button"
onClick={onClose}
className="text-fg-secondary hover:text-fg-primary p-1 rounded-md hover:bg-surface-3"
aria-label={t("common.close")}
+2 -4
View File
@@ -13,6 +13,7 @@ import { useMemo, type MouseEvent } from "react";
import { useTranslation } from "react-i18next";
import { ExternalLink, Pencil, Trash2 } from "lucide-react";
import type { CcArtifactType, CcMdItem } from "../../lib/api";
import { copyText } from "../../lib/clipboard";
import {
ContextMenu,
MenuItem,
@@ -83,10 +84,7 @@ export function FileItemMenu({
<MenuItem testId="config-item-menu-edit" onSelect={pick(onEdit)}>
{l.edit}
</MenuItem>
<MenuItem
testId="config-item-menu-copy"
onSelect={pick(() => void navigator.clipboard.writeText(path))}
>
<MenuItem testId="config-item-menu-copy" onSelect={pick(() => void copyText(path))}>
{l.copyPath}
</MenuItem>
<MenuSeparator />
@@ -8,15 +8,19 @@
* The directory list is `GET /api/run/cwds` (the dashboard's known projects),
* offered as suggestions on a plain text input rather than a closed dropdown:
* a project you have never run through the dashboard is exactly the case this
* modal exists for, so it must stay typeable.
* modal exists for, so it must stay typeable. Typed text is a draft: closing
* asks first, and it is a real dialog (focus trap, Escape).
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useId, useState } from "react";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { BookOpen, X, Save, AlertCircle } from "lucide-react";
import { api, type CwdSuggestion } from "../../lib/api";
import { confirmDiscard } from "../../lib/confirm";
import { useDialog } from "../../hooks/useDialog";
import { useUnsavedGuard } from "../../hooks/useUnsavedGuard";
interface ProjectMemoryModalProps {
/** i18n namespace of the calling page — "ccConfig" or "codexConfig". */
@@ -55,13 +59,23 @@ export function ProjectMemoryModal({
.catch(() => undefined);
}, []);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
// Typed text is a draft: closing (X, Cancel, backdrop, Escape) asks first,
// and Escape inside the textarea only leaves the field.
const dirty = dir.trim() !== "" || content !== template;
useUnsavedGuard(dirty);
const panelRef = useRef<HTMLDivElement>(null);
const requestClose = useCallback(async () => {
if (dirty && !(await confirmDiscard())) return;
onClose();
}, [dirty, onClose]);
useDialog(true, panelRef, () => {
const el = document.activeElement;
if (el instanceof HTMLTextAreaElement) {
el.blur();
return;
}
void requestClose();
});
const handleCreate = useCallback(async () => {
const target = dir.trim();
@@ -82,22 +96,28 @@ export function ProjectMemoryModal({
return (
<div
className="fixed inset-0 bg-black/60 z-40 flex items-center justify-center p-4"
onClick={onClose}
className="fixed inset-0 bg-surface-0/70 z-50 flex items-center justify-center p-4"
onClick={() => void requestClose()}
>
<div
ref={panelRef}
role="dialog"
aria-label={t("projectMemory.title", { file: filename })}
aria-modal="true"
aria-labelledby={`${listId}-title`}
className="relative w-full max-w-3xl max-h-[90vh] rounded-xl border border-border bg-surface-1 flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
<BookOpen className="w-4 h-4 text-fg-muted" />
<span className="text-sm font-medium text-fg-primary flex-1 truncate">
<span
id={`${listId}-title`}
className="text-sm font-medium text-fg-primary flex-1 truncate"
>
{t("projectMemory.title", { file: filename })}
</span>
<button
onClick={onClose}
type="button"
onClick={() => void requestClose()}
className="text-fg-secondary hover:text-fg-primary p-1 rounded-md hover:bg-surface-3"
aria-label={t("projectMemory.close")}
>
@@ -151,7 +171,10 @@ export function ProjectMemoryModal({
</div>
{error && (
<div className="rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-xs text-status-danger inline-flex items-center gap-2">
<div
role="alert"
className="rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-xs text-status-danger inline-flex items-center gap-2"
>
<AlertCircle className="w-3.5 h-3.5" />
{error}
</div>
@@ -160,12 +183,14 @@ export function ProjectMemoryModal({
<div className="border-t border-border px-4 py-3 flex items-center justify-end gap-2">
<button
onClick={onClose}
type="button"
onClick={() => void requestClose()}
className="text-[12px] font-medium px-3 py-1.5 rounded-md border border-border bg-surface-2 hover:bg-surface-3 text-fg-secondary"
>
{t("projectMemory.cancel")}
</button>
<button
type="button"
onClick={handleCreate}
disabled={saving}
className="text-[12px] font-medium px-3 py-1.5 rounded-md border border-accent/40 bg-accent/15 hover:bg-accent/25 text-accent inline-flex items-center gap-1.5 disabled:opacity-60"
+9 -37
View File
@@ -1,46 +1,18 @@
/**
* @file ToastNotice.tsx
* @description Bottom-right success/error toast for the config explorer.
* Tool-agnostic — the page owns the 5s auto-dismiss timer and passes the
* message down.
* @description The config explorers' `{kind, message}` toast shape and
* `showToast`, which hands it to the app-wide toast queue (`lib/toast.ts`): a
* success fades on its own, an error stays until dismissed. The panels keep
* taking an `onToast(Toast)` prop, so this is the one place it is rendered.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { AlertCircle, Check, X } from "lucide-react";
import { toast } from "../../lib/toast";
export type Toast = { kind: "success" | "error"; message: string } | null;
export function ToastNotice({
toast,
onDismiss,
}: {
toast: NonNullable<Toast>;
onDismiss: () => void;
}) {
const isErr = toast.kind === "error";
return (
<div className="fixed bottom-6 right-6 z-50 max-w-md">
<div
className={`rounded-lg border px-3 py-2 shadow-lg flex items-start gap-2 ${
isErr
? "border-status-danger/50 bg-status-danger/15 text-status-danger"
: "border-status-success/40 bg-status-success/10 text-status-success"
}`}
>
{isErr ? (
<AlertCircle className="w-4 h-4 flex-shrink-0 mt-0.5" />
) : (
<Check className="w-4 h-4 flex-shrink-0 mt-0.5" />
)}
<span className="text-xs leading-relaxed flex-1 break-all">{toast.message}</span>
<button
onClick={onDismiss}
className="text-current/70 hover:text-current p-0.5"
aria-label="dismiss"
>
<X className="w-3 h-3" />
</button>
</div>
</div>
);
/** Show a config-page toast through the global queue. */
export function showToast(t: NonNullable<Toast>): void {
if (t.kind === "error") toast.error(t.message);
else toast.success(t.message);
}
@@ -7,9 +7,11 @@
*/
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { render, screen, act } from "@testing-library/react";
import i18n from "../../../i18n";
import userEvent from "@testing-library/user-event";
import { EditorModal } from "../EditorModal";
import { settleConfirm, subscribeConfirm, type PendingConfirm } from "../../../lib/confirm";
describe("EditorModal", () => {
it("loads the existing body through the injected fetcher, not a hardcoded API", async () => {
@@ -46,3 +48,156 @@ describe("EditorModal", () => {
expect(onSave).not.toHaveBeenCalled();
});
});
describe("EditorModal — data safety", () => {
const edit = {
mode: "edit" as const,
type: "agents" as const,
scope: "user" as const,
name: "a",
filePath: "/r/a.md",
};
it("loads once per file: a parent re-render with a new inline fetcher never overwrites typing", async () => {
let calls = 0;
const mk = () => async () => {
calls++;
return "disk";
};
const { rerender } = render(
<EditorModal
state={{ ...edit }}
fetchContent={mk()}
onClose={() => {}}
onSave={async () => {}}
/>
);
const box = await screen.findByDisplayValue("disk");
await userEvent.type(box, " mine");
rerender(
<EditorModal
state={{ ...edit }}
fetchContent={mk()}
onClose={() => {}}
onSave={async () => {}}
/>
);
await new Promise((r) => setTimeout(r, 20));
expect(calls).toBe(1);
expect(screen.getByDisplayValue("disk mine")).toBeInTheDocument();
});
it("disables Save after a failed load, so it can never write an empty file", async () => {
const onSave = vi.fn();
render(
<EditorModal
state={edit}
fetchContent={async () => {
throw new Error("nope");
}}
onClose={() => {}}
onSave={onSave}
/>
);
expect(await screen.findByText(/nope/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: /save/i })).toBeDisabled();
});
it("opens a truncated file read-only with Save disabled", async () => {
render(
<EditorModal
state={edit}
fetchContent={async () => ({ text: "partial", truncated: true })}
onClose={() => {}}
onSave={async () => {}}
/>
);
const box = await screen.findByDisplayValue("partial");
expect(box).toHaveAttribute("readonly");
expect(screen.getByRole("button", { name: /save/i })).toBeDisabled();
});
it("Escape inside the textarea does not close the editor", async () => {
const onClose = vi.fn();
render(
<EditorModal
state={edit}
fetchContent={async () => "x"}
onClose={onClose}
onSave={async () => {}}
/>
);
const box = await screen.findByDisplayValue("x");
box.focus();
await userEvent.keyboard("{Escape}");
expect(onClose).not.toHaveBeenCalled();
});
it("closing with unsaved edits asks first", async () => {
const onClose = vi.fn();
render(
<EditorModal
state={edit}
fetchContent={async () => "x"}
onClose={onClose}
onSave={async () => {}}
/>
);
await userEvent.type(await screen.findByDisplayValue("x"), "y");
await userEvent.click(screen.getByRole("button", { name: /cancel/i }));
expect(onClose).not.toHaveBeenCalled();
let pending: PendingConfirm | null = null;
const off = subscribeConfirm((p) => (pending = p));
expect(pending).not.toBeNull();
settleConfirm(true);
off();
await vi.waitFor(() => expect(onClose).toHaveBeenCalled());
});
it("asks before saving when the on-disk check cannot be read", async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
let n = 0;
render(
<EditorModal
state={edit}
fetchContent={async () => {
n++;
if (n > 1) throw new Error("offline");
return "x";
}}
onClose={() => {}}
onSave={onSave}
/>
);
await userEvent.type(await screen.findByDisplayValue("x"), "y");
let pending: PendingConfirm | null = null;
const off = subscribeConfirm((p) => (pending = p));
await userEvent.click(screen.getByRole("button", { name: /save/i }));
await vi.waitFor(() => expect(pending).not.toBeNull());
expect(onSave).not.toHaveBeenCalled();
settleConfirm(true);
off();
await vi.waitFor(() => expect(onSave).toHaveBeenCalled());
});
it("a language switch mid-edit does not reload the file over typing", async () => {
const fetchContent = vi.fn().mockResolvedValue("disk");
render(
<EditorModal
state={edit}
fetchContent={fetchContent}
onClose={() => {}}
onSave={async () => {}}
/>
);
await userEvent.type(await screen.findByDisplayValue("disk"), " mine");
await act(async () => {
await i18n.changeLanguage("vi");
});
await act(async () => {
await i18n.changeLanguage("en");
});
expect(fetchContent).toHaveBeenCalledTimes(1);
expect(screen.getByDisplayValue("disk mine")).toBeInTheDocument();
});
});
@@ -10,23 +10,23 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Copy, Check } from "lucide-react";
import { copyText } from "../../lib/clipboard";
export function CopyButton({ value }: { value: string }) {
const { t } = useTranslation("ccConfig");
const [copied, setCopied] = useState(false);
return (
<button
type="button"
onClick={async () => {
try {
await navigator.clipboard.writeText(value);
if (await copyText(value, true)) {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
/* clipboard unavailable */
}
}}
className="text-[10px] font-medium px-1.5 py-1 rounded border border-border bg-surface-1 hover:bg-surface-3 text-fg-secondary hover:text-fg-primary inline-flex items-center gap-1 flex-shrink-0"
title={t("common.copyPath")}
aria-label={t("common.copyPath")}
>
{copied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
</button>
@@ -5,13 +5,16 @@
* `index.css`). Each card's swatch re-scopes the theme's CSS variables on its
* own element (`dark` class or `data-mode`, plus `data-theme`), so the preview
* is painted by the same tokens as the app — no hex is duplicated here.
* **Reset to defaults** (after a confirm) returns to Dark with both stock themes.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useTranslation } from "react-i18next";
import { Monitor, Moon, Sun } from "lucide-react";
import { Monitor, Moon, RotateCcw, Sun } from "lucide-react";
import { SettingsRow, SettingsSegmented } from "./SettingsRow";
import { THEMES, useTheme, type Theme, type ThemeMode } from "../../hooks/useTheme";
import { confirmDialog } from "../../lib/confirm";
import { toast } from "../../lib/toast";
const THEME_LABELS: Record<string, string> = {
darcula: "Darcula",
@@ -61,6 +64,19 @@ export function AppearanceSettings() {
const { t } = useTranslation("settings");
const { mode, theme, themeFor, setMode, setThemeFor } = useTheme();
const reset = async () => {
const ok = await confirmDialog({
title: t("appearance.resetTitle"),
message: t("appearance.resetBody"),
confirmLabel: t("appearance.reset"),
});
if (!ok) return;
setMode("dark");
setThemeFor("dark", "default");
setThemeFor("light", "default");
toast.success(t("appearance.resetDone"));
};
return (
<div className="card p-5 space-y-5">
<SettingsRow
@@ -116,6 +132,13 @@ export function AppearanceSettings() {
</div>
</div>
))}
<div className="flex justify-end border-t border-border pt-3">
<button type="button" onClick={() => void reset()} className="btn-ghost text-xs">
<RotateCcw className="w-3.5 h-3.5" />
{t("appearance.reset")}
</button>
</div>
</div>
);
}
@@ -4,7 +4,7 @@
* under Settings → Claude Code → Overview: the CLAUDE_HOME the server reads
* transcripts and config from, the AgentHub hooks installed into Claude Code
* with a Reinstall button, and Model Pricing for Claude models (the shared
* `PricingSettings` table, with Reset Defaults).
* `PricingSettings` table, with Reset Defaults). Changing CLAUDE_HOME asks first.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -128,8 +128,9 @@ export function ClaudeAgentHubSettings({ onHomeChanged }: { onHomeChanged?: () =
id="claude-home"
title={t("claudeHome.title")}
description={t("claudeHome.description")}
note={t("cursorPathsNote")}
note={t("claudeHome.note")}
placeholder={t("claudeHome.placeholder")}
confirm={{ title: t("claudeHome.confirmTitle"), message: t("claudeHome.confirmBody") }}
load={() =>
api.settings.claudeHome
.get()
@@ -5,14 +5,18 @@
* server's refusal inline, the saved value while the field differs from it,
* and a warning when the real
* environment sets the variable (the environment wins at the next start, so a
* saved value would not survive a restart). The server validates and persists;
* this component only loads, submits and reports.
* saved value would not survive a restart). Enter saves; a caller may ask for
* a confirmation first (`confirm`). The server validates and persists; this
* component only loads, submits and reports.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { AlertTriangle, FolderOpen } from "lucide-react";
import { confirmDialog } from "../../lib/confirm";
import { isSubmitEnter } from "../../lib/keys";
import { SettingsSection } from "./SettingsRow";
export interface HomeDirValue {
value: string;
@@ -29,6 +33,8 @@ interface Props {
/** Optional second line under the description. */
note?: string;
placeholder: string;
/** Ask this before saving (a change with wide effects, like CLAUDE_HOME). */
confirm?: { title: string; message?: string };
load: () => Promise<HomeDirValue>;
save: (path: string) => Promise<HomeDirValue>;
}
@@ -37,7 +43,16 @@ interface Props {
* An editable home directory with its own load/save cycle.
* @returns The section, or its skeleton-free shell while loading.
*/
export function HomeDirSetting({ id, title, description, note, placeholder, load, save }: Props) {
export function HomeDirSetting({
id,
title,
description,
note,
placeholder,
confirm,
load,
save,
}: Props) {
const { t } = useTranslation("settings");
const [current, setCurrent] = useState<HomeDirValue | null>(null);
const [input, setInput] = useState("");
@@ -61,7 +76,8 @@ export function HomeDirSetting({ id, title, description, note, placeholder, load
}, []);
const handleSave = async () => {
if (!current || input === current.value) return;
if (!current || input === current.value || saving) return;
if (confirm && !(await confirmDialog({ ...confirm, confirmLabel: t("homeDir.save") }))) return;
setSaving(true);
setError(null);
try {
@@ -76,15 +92,18 @@ export function HomeDirSetting({ id, title, description, note, placeholder, load
};
return (
<section id={id} className="scroll-mt-24">
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
<FolderOpen className="w-4 h-4 text-fg-muted" />
{title}
</h3>
<p className="text-xs text-fg-muted mb-1">{description}</p>
{note && <p className="text-[11px] text-fg-muted italic mb-4 leading-snug">{note}</p>}
<div className={`card p-5 space-y-4 ${note ? "" : "mt-3"}`}>
<SettingsSection
id={id}
title={
<span className="flex items-center gap-2">
<FolderOpen className="w-4 h-4 text-fg-muted" />
{title}
</span>
}
description={description}
>
<div className="space-y-4">
{note && <p className="text-[11px] text-fg-muted italic leading-snug">{note}</p>}
<div className="flex items-center gap-3">
<input
type="text"
@@ -94,10 +113,17 @@ export function HomeDirSetting({ id, title, description, note, placeholder, load
setInput(e.target.value);
setError(null);
}}
className="flex-1 bg-surface-4 border border-surface-3 rounded-lg px-3 py-2 text-sm text-fg-secondary font-mono focus:outline-none focus:border-violet-500/50"
onKeyDown={(e) => {
if (isSubmitEnter(e)) {
e.preventDefault();
void handleSave();
}
}}
className="input flex-1 font-mono"
placeholder={placeholder}
/>
<button
type="button"
onClick={handleSave}
disabled={saving || !current || input === current.value}
className="btn-primary px-4 py-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
@@ -125,6 +151,6 @@ export function HomeDirSetting({ id, title, description, note, placeholder, load
</p>
)}
</div>
</section>
</SettingsSection>
);
}
+55 -6
View File
@@ -6,17 +6,31 @@
* uploading an image or importing a `.codex-pet.zip`. Everything here is the
* same state and calls as the mascot's right-click menu (`Sagi/usePets.ts`,
* `Sagi/prefs.ts`): the choice and size are per browser, the pets live on the
* server (`/api/pets`).
* server (`/api/pets`). Previews follow the OS reduced-motion preference; size
* and bubbles are disabled while the pet is hidden; a delete asks first; and
* **Reset to defaults** puts show / bubbles / size / pet back.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useRef, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Check, Download, ExternalLink, Loader2, PackageOpen, Trash2, Upload } from "lucide-react";
import {
Check,
Download,
ExternalLink,
Loader2,
PackageOpen,
RotateCcw,
Trash2,
Upload,
} from "lucide-react";
import { SettingsRow, SettingsSwitchRow } from "./SettingsRow";
import { SagiAvatar } from "../Sagi/SagiAvatar";
import { PetSprite } from "../Sagi/PetSprite";
import { PET_SIZE, sagiPrefs } from "../Sagi/prefs";
import { usePrefersReducedMotion } from "../Sagi/Sagi";
import { confirmDialog } from "../../lib/confirm";
import { toast } from "../../lib/toast";
import { usePets, useWornPet } from "../Sagi/usePets";
import type { PetView } from "../../lib/api";
@@ -78,7 +92,9 @@ function PetCard({
disabled={disabled}
aria-label={deleteLabel}
title={deleteLabel}
className="absolute right-1.5 top-1.5 rounded p-1 text-fg-muted opacity-0 transition-opacity hover:bg-surface-4 hover:text-status-danger focus:opacity-100 group-hover:opacity-100"
className={`absolute right-1.5 top-1.5 rounded p-1 text-fg-muted transition-opacity hover:bg-surface-4 hover:text-status-danger focus:opacity-100 group-hover:opacity-100 group-focus-within:opacity-100 ${
selected ? "opacity-100" : "opacity-0"
}`}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
@@ -88,13 +104,14 @@ function PetCard({
}
function Preview({ pet, size }: { pet: PetView; size: number }) {
const reducedMotion = usePrefersReducedMotion();
return (
<PetSprite
id={pet.id}
kind={pet.kind}
mood="idle"
size={size}
reducedMotion={false}
reducedMotion={reducedMotion}
onBroken={() => {}}
/>
);
@@ -108,6 +125,22 @@ export function PetSettings() {
const bundleInput = useRef<HTMLInputElement>(null);
const wornPet = list?.pets.find((p) => p.id === worn.pet.id);
const sizePreview = Math.min(worn.size, 120);
const reducedMotion = usePrefersReducedMotion();
const resetDefaults = async () => {
const ok = await confirmDialog({
title: t("sagi.pet.reset"),
message: t("sagi.pet.resetMessage"),
confirmLabel: t("sagi.pet.reset"),
destructive: false,
});
if (!ok) return;
sagiPrefs.setEnabled(true);
sagiPrefs.setMuted(false);
sagiPrefs.setSize(PET_SIZE.default);
sagiPrefs.setPet({ id: "sagi" });
toast.success(t("sagi.pet.resetDone"));
};
return (
<div className="space-y-6">
@@ -123,6 +156,7 @@ export function PetSettings() {
onChange={(v) => sagiPrefs.setMuted(!v)}
label={t("sagi.bubbles")}
description={t("sagi.bubblesDesc")}
disabled={!worn.shown}
/>
<SettingsRow
label={t("sagi.pet.size")}
@@ -136,8 +170,9 @@ export function PetSettings() {
min={PET_SIZE.min}
max={PET_SIZE.max}
value={worn.size}
disabled={!worn.shown}
onChange={(e) => sagiPrefs.setSize(Number(e.target.value))}
className="w-40 accent-[rgb(var(--accent))]"
className="w-40 accent-[rgb(var(--accent))] disabled:opacity-50"
/>
<span className="w-12 text-right text-xs tabular-nums text-fg-muted">
{worn.size}px
@@ -152,9 +187,23 @@ export function PetSettings() {
{wornPet ? (
<Preview pet={wornPet} size={sizePreview} />
) : (
<SagiAvatar mood="idle" reducedMotion={false} size={sizePreview} />
<SagiAvatar mood="idle" reducedMotion={reducedMotion} size={sizePreview} />
)}
</div>
{worn.size > 120 && (
<p className="mt-1.5 text-[11px] text-fg-muted">
{t("sagi.pet.previewCapped", { size: worn.size })}
</p>
)}
{!worn.shown && (
<p className="mt-1.5 text-[11px] text-fg-muted">{t("sagi.pet.hiddenNote")}</p>
)}
<div className="mt-3 flex justify-end">
<button type="button" className="btn-ghost text-xs" onClick={() => void resetDefaults()}>
<RotateCcw className="h-3.5 w-3.5" />
{t("sagi.pet.reset")}
</button>
</div>
</div>
<div className="card p-5">
@@ -9,7 +9,10 @@
* filter turns it on rather than vanishing. Reset Defaults reseeds Claude's
* rates, so only the Claude tab offers it; the server re-fetches any row the
* automatic sync owns (OpenAI's, Gemini's) straight after. A right-click on a
* rule row opens its Edit / Delete (plus Copy model pattern) as a menu.
* rule row opens its Edit / Delete (plus Copy model pattern) as a menu. A
* delete asks first (the sync never re-adds a deleted pattern). Every mounted
* table shares ONE list request and reloads together after any change, so the
* Pricing page's per-vendor tables never go stale against each other.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -23,6 +26,9 @@ import { isRemoteDataRefreshMessage } from "../../lib/remoteDataEvents";
import { getCurrentLocale } from "../../lib/format";
import { Skeleton } from "../Skeleton";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
import { confirmDialog } from "../../lib/confirm";
import { copyText } from "../../lib/clipboard";
import { useArmed } from "../../hooks/useArmed";
import type { ModelPricing, WSMessage } from "../../lib/types";
interface EditRow {
@@ -185,6 +191,20 @@ function PricingInfoTooltip({ vendor }: { vendor: PricingVendor }) {
export type PricingVendor = "claude" | "openai" | "google";
// Every mounted table's `load`: a change in one reloads them all, and loads
// fired together share one request.
const tables = new Set<() => void>();
let inflight: ReturnType<typeof api.pricing.list> | null = null;
function listPricing() {
inflight ??= api.pricing.list().finally(() => {
inflight = null;
});
return inflight;
}
function reloadAllTables() {
for (const load of tables) load();
}
const VENDOR_PATTERN: Record<PricingVendor, RegExp> = {
claude: /^claude-/,
openai: /^(gpt-|o\d|codex-)/,
@@ -216,7 +236,9 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing", he
const [error, setError] = useState<string | null>(null);
const [showAll, setShowAll] = useState(false);
const [resetting, setResetting] = useState(false);
const [confirmReset, setConfirmReset] = useState(false);
// Reset Defaults is a two-click that disarms itself after 3 s.
const resetArm = useArmed();
const confirmReset = resetArm.isArmed("reset");
const ctx = useContextMenu<ModelPricing>();
// A menu item: close the menu, then act on the rule it was opened on.
const pick = (fn: (rule: ModelPricing) => unknown) => () => {
@@ -232,7 +254,7 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing", he
const load = useCallback(async () => {
try {
const res = await api.pricing.list();
const res = await listPricing();
setPricing(res.pricing);
setError(null);
} catch (err) {
@@ -244,6 +266,11 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing", he
useEffect(() => {
load();
const reload = () => void load();
tables.add(reload);
return () => {
tables.delete(reload);
};
}, [load]);
// A remote source can bring its own pricing rules; reload when one syncs.
@@ -332,7 +359,7 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing", he
if (!ours(editRow.model_pattern.trim())) setShowAll(true);
setEditingPattern(null);
setAdding(false);
await load();
reloadAllTables();
} catch (err) {
setError(err instanceof Error ? err.message : t("messages.failedSave"));
} finally {
@@ -341,9 +368,17 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing", he
};
const deleteRule = async (pattern: string) => {
if (
!(await confirmDialog({
title: t("pricing.deleteConfirmTitle", { pattern }),
message: t("pricing.deleteConfirmBody"),
confirmLabel: t("common:delete"),
}))
)
return;
try {
await api.pricing.delete(pattern);
await load();
reloadAllTables();
} catch (err) {
setError(err instanceof Error ? err.message : t("messages.failedDelete"));
}
@@ -352,7 +387,7 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing", he
const handleResetPricing = async () => {
setResetting(true);
setActionResult(null);
setConfirmReset(false);
resetArm.disarm();
try {
// Only the Claude tab offers the button, and it resets only Claude's rows.
const res = await api.settings.resetPricing("claude");
@@ -363,7 +398,7 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing", he
}),
isError: false,
});
await load();
reloadAllTables();
} catch (err) {
setActionResult({
key: "reset-pricing",
@@ -591,7 +626,7 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing", he
)}
{allowReset && (
<button
onClick={() => (confirmReset ? handleResetPricing() : setConfirmReset(true))}
onClick={() => (confirmReset ? handleResetPricing() : resetArm.arm("reset"))}
disabled={isEditing || resetting}
className={`text-xs px-3 py-1.5 rounded-md transition-colors disabled:opacity-50 inline-flex items-center gap-1.5 ${
confirmReset
@@ -749,7 +784,7 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing", he
<button
onClick={() => startEdit(rule)}
disabled={isEditing}
className="p-1.5 rounded-md text-fg-secondary hover:text-blue-500 hover:bg-blue-600/10 transition-colors disabled:opacity-30"
className="p-1.5 rounded-md text-fg-secondary hover:text-accent hover:bg-accent/10 transition-colors disabled:opacity-30"
title={t("common:edit")}
>
<Pencil className="w-3.5 h-3.5" />
@@ -782,9 +817,7 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing", he
<MenuItem disabled={isEditing} onSelect={pick(startEdit)}>
{t("common:edit")}
</MenuItem>
<MenuItem
onSelect={pick((rule) => void navigator.clipboard.writeText(rule.model_pattern))}
>
<MenuItem onSelect={pick((rule) => void copyText(rule.model_pattern))}>
{t("rowMenu.copyPattern")}
</MenuItem>
<MenuSeparator />
+27 -5
View File
@@ -2,25 +2,29 @@
* @file SettingsRow — the settings page's row primitives: a titled card
* (`SettingsSection`), a label/description | control row (`SettingsRow`), a
* switch row (`SettingsSwitchRow`) and a segmented radio group
* (`SettingsSegmented`). Rows divide themselves with a hairline, none after the
* last, so a section is just its rows in order.
* (`SettingsSegmented`, arrow keys + one roving tab stop). Rows divide
* themselves with a hairline, none after the last, so a section is just its
* rows in order.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { Switch } from "../Switch";
import type { ReactNode } from "react";
import type { KeyboardEvent, ReactNode } from "react";
export function SettingsSection({
id,
title,
description,
children,
}: {
/** `<section id>` — a deep-link target. */
id?: string;
title: ReactNode;
description?: ReactNode;
children: ReactNode;
}) {
return (
<section className="card p-5">
<section id={id} className={id ? "card p-5 scroll-mt-24" : "card p-5"}>
<h3 className="text-sm font-semibold text-fg-primary">{title}</h3>
{description && <p className="mt-0.5 text-xs text-fg-muted">{description}</p>}
<div className="mt-2">{children}</div>
@@ -87,18 +91,36 @@ export function SettingsSegmented<T extends string>({
onChange: (v: T) => void;
ariaLabel?: string;
}) {
// A radio group's keyboard contract: one tab stop (the checked option), and
// the arrow keys move the selection — wrapping — and the focus with it.
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
const step = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[e.key];
if (!step || options.length === 0) return;
e.preventDefault();
const at = Math.max(
0,
options.findIndex((o) => o.value === value)
);
const next = (at + step + options.length) % options.length;
onChange(options[next]!.value);
(e.currentTarget.children[next] as HTMLElement | undefined)?.focus();
};
return (
<div
role="radiogroup"
aria-label={ariaLabel}
onKeyDown={onKeyDown}
className="inline-flex rounded-lg border border-border bg-surface-2 p-0.5"
>
{options.map((o) => (
{options.map((o, i) => (
<button
key={o.value}
type="button"
role="radio"
aria-checked={value === o.value}
tabIndex={
value === o.value || (i === 0 && !options.some((x) => x.value === value)) ? 0 : -1
}
onClick={() => onChange(o.value)}
className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
value === o.value
@@ -13,6 +13,12 @@ import i18n from "i18next";
import { ClaudeAgentHubSettings } from "../ClaudeAgentHubSettings";
import { api } from "../../../lib/api";
import type { ModelPricing } from "../../../lib/types";
import { settleConfirm, subscribeConfirm } from "../../../lib/confirm";
/** Say yes to every confirm (a CLAUDE_HOME change now asks first). */
function confirmYes() {
return subscribeConfirm((p) => p && queueMicrotask(() => settleConfirm(true)));
}
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
@@ -89,8 +95,10 @@ describe("ClaudeAgentHubSettings", () => {
await user.clear(input);
await user.type(input, "/srv/claude");
const home = container.querySelector("section#claude-home") as HTMLElement;
const off = confirmYes();
await user.click(within(home).getByRole("button"));
expect(api.settings.claudeHome.set).toHaveBeenCalledWith("/srv/claude");
await waitFor(() => expect(api.settings.claudeHome.set).toHaveBeenCalledWith("/srv/claude"));
off();
const hooks = container.querySelector("section#hooks") as HTMLElement;
await user.click(within(hooks).getByRole("button"));
@@ -106,8 +114,10 @@ describe("ClaudeAgentHubSettings", () => {
await user.type(input, "/srv/claude");
const home = container.querySelector("section#claude-home") as HTMLElement;
const reads = vi.mocked(api.settings.info).mock.calls.length;
const off = confirmYes();
await user.click(within(home).getByRole("button"));
await waitFor(() => expect(onHomeChanged).toHaveBeenCalledTimes(1));
off();
expect(vi.mocked(api.settings.info).mock.calls.length).toBeGreaterThan(reads);
});
@@ -12,6 +12,7 @@ import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "i18next";
import { HomeDirSetting, type HomeDirValue } from "../HomeDirSetting";
import { settleConfirm, subscribeConfirm, type PendingConfirm } from "../../../lib/confirm";
beforeEach(() => {
i18n.changeLanguage("en");
@@ -80,4 +81,42 @@ describe("HomeDirSetting", () => {
await user.click(screen.getByRole("button", { name: "Save" }));
expect(await screen.findByText("lane 3 not updated")).toBeInTheDocument();
});
it("saves on Enter", async () => {
const user = userEvent.setup();
const { saveFn } = setup({ value: "/a", envOwned: false }, (p) =>
Promise.resolve({ value: p, envOwned: false })
);
const input = await screen.findByDisplayValue("/a");
await user.clear(input);
await user.type(input, "/b{Enter}");
expect(saveFn).toHaveBeenCalledWith("/b");
});
it("asks first when given a confirm, and saves nothing on cancel", async () => {
let pending: PendingConfirm | null = null;
const off = subscribeConfirm((p) => (pending = p));
const user = userEvent.setup();
const saveFn = vi.fn((p: string) => Promise.resolve({ value: p, envOwned: false }));
render(
<HomeDirSetting
id="x-home"
title="X_HOME"
description="desc"
placeholder="/path"
confirm={{ title: "Change X_HOME?", message: "sure?" }}
load={() => Promise.resolve({ value: "/a", envOwned: false })}
save={saveFn}
/>
);
const input = await screen.findByDisplayValue("/a");
await user.clear(input);
await user.type(input, "/b");
await user.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(pending?.title).toBe("Change X_HOME?"));
settleConfirm(false);
await waitFor(() => expect(pending).toBeNull());
expect(saveFn).not.toHaveBeenCalled();
off();
});
});
@@ -15,6 +15,18 @@ import i18n from "i18next";
import { PricingSettings } from "../PricingSettings";
import { api } from "../../../lib/api";
import type { ModelPricing } from "../../../lib/types";
import { settleConfirm, subscribeConfirm, type PendingConfirm } from "../../../lib/confirm";
/** Answer every confirm dialog with `ok`; returns the asked titles + unsubscribe. */
function answerConfirms(ok: boolean) {
const asked: PendingConfirm[] = [];
const off = subscribeConfirm((p) => {
if (!p) return;
asked.push(p);
queueMicrotask(() => settleConfirm(ok));
});
return { asked, off };
}
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
@@ -113,16 +125,56 @@ describe("PricingSettings", () => {
vi.mocked(api.pricing.delete)
.mockReset()
.mockResolvedValue({} as never);
const confirm = answerConfirms(true);
const user = userEvent.setup();
render(<PricingSettings vendor="openai" />);
const row = (await screen.findByText("gpt-5.6-sol%")).closest("tr")!;
await user.pointer({ keys: "[MouseRight]", target: row });
await user.click(screen.getByRole("menuitem", { name: "Delete" }));
expect(api.pricing.delete).toHaveBeenCalledWith("gpt-5.6-sol%");
await vi.waitFor(() => expect(api.pricing.delete).toHaveBeenCalledWith("gpt-5.6-sol%"));
confirm.off();
await user.pointer({ keys: "[MouseRight]", target: row });
await user.click(screen.getByRole("menuitem", { name: "Edit" }));
expect(screen.getByDisplayValue("GPT-5.6 Sol")).toBeInTheDocument();
});
it("asks before deleting a rule, saying the sync will not bring it back", async () => {
vi.mocked(api.pricing.delete)
.mockReset()
.mockResolvedValue({} as never);
const confirm = answerConfirms(false);
const user = userEvent.setup();
render(<PricingSettings vendor="openai" />);
await screen.findByText("gpt-5.6-sol%");
await user.click(screen.getByTitle("Delete"));
await vi.waitFor(() => expect(confirm.asked).toHaveLength(1));
expect(confirm.asked[0]!.message).toMatch(/sync/i);
expect(api.pricing.delete).not.toHaveBeenCalled();
confirm.off();
});
it("refreshes every pricing table on the page after a change in one", async () => {
vi.mocked(api.pricing.delete)
.mockReset()
.mockResolvedValue({} as never);
const confirm = answerConfirms(true);
const user = userEvent.setup();
render(
<>
<PricingSettings vendor="claude" id="a" />
<PricingSettings vendor="openai" id="b" />
</>
);
await screen.findByText("gpt-5.6-sol%");
await screen.findByText("claude-opus-5-5%");
vi.mocked(api.pricing.list).mockResolvedValue({
pricing: [...ROWS, rule("claude-fable-5%", "Claude Fable 5")],
});
// The second table (openai) deletes its row; the first must reload too.
await user.click(screen.getAllByTitle("Delete")[1]!);
expect(await screen.findByText("claude-fable-5%")).toBeInTheDocument();
confirm.off();
});
});
@@ -0,0 +1,45 @@
/**
* @file SettingsRow.test.tsx
* @description `SettingsSegmented` is a real radio group: only the checked
* option is in the tab order, and the arrow keys move (and select) within it.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { describe, expect, it } from "vitest";
import { SettingsSegmented } from "../SettingsRow";
function Group() {
const [v, setV] = useState<"a" | "b" | "c">("a");
return (
<SettingsSegmented
ariaLabel="mode"
value={v}
onChange={setV}
options={[
{ value: "a", label: "A" },
{ value: "b", label: "B" },
{ value: "c", label: "C" },
]}
/>
);
}
describe("SettingsSegmented", () => {
it("roves the tab stop and moves with the arrow keys", async () => {
const user = userEvent.setup();
render(<Group />);
const [a, b, c] = screen.getAllByRole("radio");
expect(a).toHaveAttribute("tabindex", "0");
expect(b).toHaveAttribute("tabindex", "-1");
a!.focus();
await user.keyboard("{ArrowRight}");
expect(b).toHaveAttribute("aria-checked", "true");
expect(b).toHaveFocus();
await user.keyboard("{ArrowLeft}{ArrowLeft}");
expect(c).toHaveAttribute("aria-checked", "true");
expect(c).toHaveFocus();
});
});