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

- EditorModal loads once per path (a parent re-render no longer reloads the
  file over the user's typing), refuses Save after a failed load, opens a
  truncated (>256 KB) file read-only, and asks before overwriting a file
  that changed on disk or could not be re-read.
- Settings/Hooks editors unset only keys from the snapshot taken at Edit,
  warn when the file changed underneath, keep a renamed key's value, and
  refuse a new key named like a redacted one (env) or a read-only hook
  event, which used to replace the secret/hooks.
- Project scope shows its resolved folder and is disabled inside the
  plugin cache. Account-profile, pricing-row and pet deletes confirm;
  two-click actions disarm after 3 s; unsaved drafts ask before closing.
- Search indexes every Claude Code / Codex sub-tab, opens it via ?tab= and
  scrolls to + highlights the row; the active config tab is in the URL.
- General splits into General / Data / About; Pricing moves to a Cost
  group with one shared load; config tabs group as Content / Extensions /
  Config with arrow-key roving; Promise.allSettled per tab.
- Data: browser-preferences export/import (allowlisted keys), per-section
  reset, housekeeping dry-run first, clear-all behind a typed phrase.
  Server: purge days must be a whole number >= 1 (INVALID_PURGE_DAYS) and
  dry_run returns exactly what the real run would delete.
- Alerts can be edited; webhooks/alerts/remote-source failures toast;
  copies go through copyText; modals get dialog roles and useDialog.
- Sagi: Ctrl/Cmd+B only when shown and not typing, default spot clear of
  row controls, arrow keys move it, reduced motion honoured; the dead
  /run launch link goes to the workspace.
This commit is contained in:
2026-10-05 13:22:08 +07:00
parent 03f1673fce
commit 6715fbd971
68 changed files with 4817 additions and 1374 deletions
+90 -29
View File
@@ -70,8 +70,8 @@ import {
BellOff,
Check,
CheckCheck,
ChevronDown,
ListChecks,
Pencil,
Plus,
RefreshCw,
Trash2,
@@ -88,6 +88,7 @@ import { Checkbox } from "./Checkbox";
import { FieldHelp } from "./FieldHelp";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "./ContextMenu";
import { timeAgo } from "../lib/format";
import { toast } from "../lib/toast";
import type { AlertEvent, AlertRule, AlertRuleType, WSMessage } from "../lib/types";
const PAGE_SIZE = 25;
@@ -155,6 +156,27 @@ const EMPTY_FORM: RuleFormState = {
cooldown_seconds: "300",
};
/** A rule's stored values as form fields, for editing it. */
function formFromRule(rule: AlertRule): RuleFormState {
const c = rule.config;
const str = (v: unknown, fallback: string) => (v == null ? fallback : String(v));
return {
name: rule.name,
rule_type: rule.rule_type,
event_type: str(c.event_type, ""),
tool_name: str(c.tool_name, ""),
summary_contains: str(c.summary_contains, ""),
count: str(c.count, EMPTY_FORM.count),
window_minutes: str(c.window_minutes, EMPTY_FORM.window_minutes),
minutes: str(c.minutes, EMPTY_FORM.minutes),
status: c.status === "waiting" ? "waiting" : "working",
total_tokens: str(c.total_tokens, EMPTY_FORM.total_tokens),
cooldown_seconds: String(rule.cooldown_seconds),
};
}
const errText = (err: unknown) => (err instanceof Error ? err.message : String(err));
function buildConfig(form: RuleFormState): AlertRule["config"] {
switch (form.rule_type) {
case "event_pattern": {
@@ -217,6 +239,8 @@ export function AlertsNotifications() {
const [loadingRules, setLoadingRules] = useState(true);
const [formOpen, setFormOpen] = useState(false);
const [form, setForm] = useState<RuleFormState>(EMPTY_FORM);
// The rule being edited, or null while the form creates a new one.
const [editingId, setEditingId] = useState<string | null>(null);
const [formError, setFormError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [confirmRule, setConfirmRule] = useState<AlertRule | null>(null);
@@ -241,11 +265,11 @@ export function AlertsNotifications() {
const res = await api.alerts.rules.list();
setRules(res.rules);
} catch (err) {
console.error("Failed to load alert rules:", err);
toast.error(t("errors.loadRules", { error: errText(err) }));
} finally {
setLoadingRules(false);
}
}, []);
}, [t]);
const loadAlerts = useCallback(async () => {
setLoadingAlerts(true);
@@ -259,11 +283,11 @@ export function AlertsNotifications() {
setTotal(res.total);
setUnacked(res.unacked);
} catch (err) {
console.error("Failed to load alerts:", err);
toast.error(t("errors.loadAlerts", { error: errText(err) }));
} finally {
setLoadingAlerts(false);
}
}, [unackedOnly]);
}, [unackedOnly, t]);
const loadMore = useCallback(async () => {
try {
@@ -276,9 +300,9 @@ export function AlertsNotifications() {
setTotal(res.total);
setUnacked(res.unacked);
} catch (err) {
console.error("Failed to load more alerts:", err);
toast.error(t("errors.loadAlerts", { error: errText(err) }));
}
}, [unackedOnly, alerts.length]);
}, [unackedOnly, alerts.length, t]);
useEffect(() => {
loadRules();
@@ -300,20 +324,33 @@ export function AlertsNotifications() {
const set = (patch: Partial<RuleFormState>) => setForm((prev) => ({ ...prev, ...patch }));
const onCreateRule = async () => {
if (saving) return;
const closeForm = () => {
setForm(EMPTY_FORM);
setEditingId(null);
setFormOpen(false);
setFormError(null);
};
const openEditRule = (rule: AlertRule) => {
setForm(formFromRule(rule));
setEditingId(rule.id);
setFormError(null);
setFormOpen(true);
};
const onSaveRule = async () => {
if (saving || !canSubmit) return;
setSaving(true);
setFormError(null);
try {
const cooldown = parseInt(form.cooldown_seconds, 10);
await api.alerts.rules.create({
const body = {
name: form.name.trim(),
rule_type: form.rule_type,
config: buildConfig(form),
cooldown_seconds: Number.isFinite(cooldown) && cooldown >= 0 ? cooldown : 300,
});
setForm(EMPTY_FORM);
setFormOpen(false);
cooldown_seconds: cooldownVal,
};
if (editingId) await api.alerts.rules.update(editingId, body);
else await api.alerts.rules.create({ ...body, rule_type: form.rule_type });
closeForm();
loadRules();
} catch (err) {
setFormError(err instanceof Error ? err.message : String(err));
@@ -327,7 +364,7 @@ export function AlertsNotifications() {
await api.alerts.rules.update(rule.id, { enabled: !rule.enabled });
loadRules();
} catch (err) {
console.error("Failed to toggle alert rule:", err);
toast.error(t("errors.toggleRule", { error: errText(err) }));
}
};
@@ -338,7 +375,8 @@ export function AlertsNotifications() {
loadRules();
loadAlerts();
} catch (err) {
console.error("Failed to delete alert rule:", err);
setConfirmRule(null);
toast.error(t("errors.deleteRule", { error: errText(err) }));
}
};
@@ -347,7 +385,7 @@ export function AlertsNotifications() {
await api.alerts.ack(id);
loadAlerts();
} catch (err) {
console.error("Failed to acknowledge alert:", err);
toast.error(t("errors.ack", { error: errText(err) }));
}
};
@@ -356,7 +394,7 @@ export function AlertsNotifications() {
await api.alerts.ackAll();
loadAlerts();
} catch (err) {
console.error("Failed to acknowledge alerts:", err);
toast.error(t("errors.ack", { error: errText(err) }));
}
};
@@ -366,8 +404,12 @@ export function AlertsNotifications() {
const tokensVal = parseInt(form.total_tokens, 10);
const countVal = parseInt(form.count, 10);
const windowVal = parseFloat(form.window_minutes);
// Cooldown is a whole number of seconds ≥ 0 — never silently replaced by 300.
const cooldownOk = /^\d+$/.test(form.cooldown_seconds.trim());
const cooldownVal = cooldownOk ? parseInt(form.cooldown_seconds, 10) : 0;
const canSubmit =
form.name.trim().length > 0 &&
cooldownOk &&
(form.rule_type !== "event_pattern" ||
(Boolean(form.event_type.trim() || form.tool_name.trim() || form.summary_contains.trim()) &&
Number.isFinite(countVal) &&
@@ -440,8 +482,11 @@ export function AlertsNotifications() {
</div>
<button
onClick={() => {
setFormOpen((open) => !open);
setFormError(null);
if (formOpen) closeForm();
else {
setFormOpen(true);
setFormError(null);
}
}}
className="btn-ghost border border-border inline-flex items-center gap-1.5 text-xs flex-shrink-0"
>
@@ -475,7 +520,9 @@ export function AlertsNotifications() {
<select
value={form.rule_type}
onChange={(e) => set({ rule_type: e.target.value as AlertRuleType })}
className="input w-full appearance-none pr-8"
disabled={!!editingId}
title={editingId ? t("rules.typeFixed") : undefined}
className="select w-full"
>
{RULE_TYPES.map((type) => (
<option key={type} value={type}>
@@ -483,7 +530,6 @@ export function AlertsNotifications() {
</option>
))}
</select>
<ChevronDown className="w-3.5 h-3.5 absolute right-2.5 top-1/2 -translate-y-1/2 text-fg-muted pointer-events-none" />
</div>
</label>
</div>
@@ -586,12 +632,11 @@ export function AlertsNotifications() {
<select
value={form.status}
onChange={(e) => set({ status: e.target.value as "working" | "waiting" })}
className="input w-full appearance-none pr-8"
className="select w-full"
>
<option value="working">working</option>
<option value="waiting">waiting</option>
</select>
<ChevronDown className="w-3.5 h-3.5 absolute right-2.5 top-1/2 -translate-y-1/2 text-fg-muted pointer-events-none" />
</div>
</label>
)}
@@ -644,18 +689,25 @@ export function AlertsNotifications() {
<input
type="number"
min={0}
step={1}
value={form.cooldown_seconds}
onChange={(e) => set({ cooldown_seconds: e.target.value })}
aria-invalid={!cooldownOk}
className="input w-40"
/>
{!cooldownOk && (
<span className="mt-1 block text-[11px] text-status-danger">
{t("rules.cooldownInvalid")}
</span>
)}
</label>
<button
onClick={onCreateRule}
onClick={onSaveRule}
disabled={!canSubmit || saving}
className="btn-primary inline-flex items-center gap-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
<Plus className="w-3.5 h-3.5" />
{saving ? t("rules.saving") : t("rules.create")}
{editingId ? <Check className="w-3.5 h-3.5" /> : <Plus className="w-3.5 h-3.5" />}
{saving ? t("rules.saving") : editingId ? t("rules.save") : t("rules.create")}
</button>
</div>
{formError && <p className="text-xs text-status-danger">{formError}</p>}
@@ -710,6 +762,14 @@ export function AlertsNotifications() {
>
{rule.enabled ? t("rules.enabled") : t("rules.disabled")}
</button>
<button
onClick={() => openEditRule(rule)}
className="p-1.5 rounded-md text-fg-muted hover:text-fg-primary hover:bg-surface-3 transition-colors"
title={t("rules.edit")}
aria-label={t("rules.edit")}
>
<Pencil className="w-3.5 h-3.5" />
</button>
<button
onClick={() => setConfirmRule(rule)}
className="p-1.5 rounded-md text-fg-muted hover:text-status-danger hover:bg-status-danger/10 transition-colors"
@@ -733,6 +793,7 @@ export function AlertsNotifications() {
<MenuItem onSelect={pickRule(onToggleRule)}>
{ruleCtx.menu.item.enabled ? t("rules.disable") : t("rules.enable")}
</MenuItem>
<MenuItem onSelect={pickRule(openEditRule)}>{t("rules.edit")}</MenuItem>
<MenuSeparator />
<MenuItem danger onSelect={pickRule(setConfirmRule)}>
{t("rules.delete")}