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:
@@ -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")}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 }))}
|
||||
/>
|
||||
|
||||
@@ -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();
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user