From 6715fbd971f9ab45475fd5250c11ff06bb8a9c0b Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Mon, 5 Oct 2026 13:22:08 +0700 Subject: [PATCH] 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. --- client/src/components/AlertsNotifications.tsx | 119 +- client/src/components/ImportHistory.tsx | 42 +- client/src/components/RemoteSources.tsx | 45 +- client/src/components/Sagi/PetMenu.tsx | 2 + client/src/components/Sagi/Sagi.tsx | 45 +- client/src/components/Sagi/SagiPanel.tsx | 44 +- client/src/components/Sagi/SpeechBubble.tsx | 5 +- .../components/Sagi/__tests__/Pets.test.tsx | 42 + .../components/Sagi/__tests__/Sagi.test.tsx | 44 + client/src/components/Sagi/usePets.ts | 18 +- client/src/components/Sagi/useSagiPosition.ts | 45 +- client/src/components/UpdateNotifier.tsx | 72 +- client/src/components/WebhookSettings.tsx | 49 +- .../__tests__/AlertsNotifications.test.tsx | 79 ++ .../__tests__/UpdateNotifier.test.tsx | 25 + .../__tests__/WebhookSettings.test.tsx | 85 ++ client/src/components/config/ConfigTabBar.tsx | 163 +-- .../components/config/ConfirmDeleteModal.tsx | 32 +- client/src/components/config/EditorModal.tsx | 182 ++- client/src/components/config/FileViewer.tsx | 24 +- client/src/components/config/MdItemList.tsx | 6 +- .../components/config/ProjectMemoryModal.tsx | 57 +- client/src/components/config/ToastNotice.tsx | 46 +- .../config/__tests__/EditorModal.test.tsx | 157 ++- .../components/config/configPrimitives.tsx | 8 +- .../settings/AppearanceSettings.tsx | 25 +- .../settings/ClaudeAgentHubSettings.tsx | 5 +- .../components/settings/HomeDirSetting.tsx | 56 +- .../src/components/settings/PetSettings.tsx | 61 +- .../components/settings/PricingSettings.tsx | 57 +- .../src/components/settings/SettingsRow.tsx | 32 +- .../__tests__/ClaudeAgentHubSettings.test.tsx | 12 +- .../__tests__/HomeDirSetting.test.tsx | 39 + .../__tests__/PricingSettings.test.tsx | 54 +- .../settings/__tests__/SettingsRow.test.tsx | 45 + client/src/i18n/locales/en/alerts.json | 13 +- client/src/i18n/locales/en/ccConfig.json | 64 +- client/src/i18n/locales/en/codexConfig.json | 2 +- client/src/i18n/locales/en/settings.json | 136 +- client/src/i18n/locales/en/updates.json | 5 +- client/src/i18n/locales/vi/alerts.json | 13 +- client/src/i18n/locales/vi/ccConfig.json | 64 +- client/src/i18n/locales/vi/codexConfig.json | 2 +- client/src/i18n/locales/vi/settings.json | 140 +- client/src/i18n/locales/vi/updates.json | 5 +- client/src/lib/__tests__/prefsBackup.test.ts | 43 + .../src/lib/__tests__/settingsSearch.test.ts | 50 +- client/src/lib/prefsBackup.ts | 59 + client/src/lib/settingsSearch.ts | 213 ++- client/src/pages/AiRouterConfig.tsx | 48 +- client/src/pages/AiToolConfig.tsx | 64 +- client/src/pages/CcConfig.tsx | 845 ++++++++---- client/src/pages/CodexConfig.tsx | 274 ++-- client/src/pages/GitCredentialsConfig.tsx | 48 +- client/src/pages/Settings.tsx | 1221 ++++++++++------- client/src/pages/SettingsHub.tsx | 173 ++- .../pages/__tests__/AiRouterConfig.test.tsx | 24 +- .../src/pages/__tests__/AiToolConfig.test.tsx | 52 + .../__tests__/CcConfig.contextMenu.test.tsx | 24 +- .../__tests__/CcConfig.dataSafety.test.tsx | 285 ++++ .../__tests__/CcConfig.profilesUsage.test.tsx | 7 +- .../__tests__/CcConfig.updateAll.test.tsx | 10 +- .../src/pages/__tests__/CodexConfig.test.tsx | 37 +- .../__tests__/GitCredentialsConfig.test.tsx | 58 +- client/src/pages/__tests__/Settings.test.tsx | 125 ++ .../src/pages/__tests__/SettingsHub.test.tsx | 75 +- server/__tests__/settings-cleanup.test.js | 121 ++ server/routes/settings.js | 69 +- 68 files changed, 4817 insertions(+), 1374 deletions(-) create mode 100644 client/src/components/__tests__/AlertsNotifications.test.tsx create mode 100644 client/src/components/__tests__/WebhookSettings.test.tsx create mode 100644 client/src/components/settings/__tests__/SettingsRow.test.tsx create mode 100644 client/src/lib/__tests__/prefsBackup.test.ts create mode 100644 client/src/lib/prefsBackup.ts create mode 100644 client/src/pages/__tests__/CcConfig.dataSafety.test.tsx create mode 100644 client/src/pages/__tests__/Settings.test.tsx create mode 100644 server/__tests__/settings-cleanup.test.js diff --git a/client/src/components/AlertsNotifications.tsx b/client/src/components/AlertsNotifications.tsx index e7303368..53e232e5 100644 --- a/client/src/components/AlertsNotifications.tsx +++ b/client/src/components/AlertsNotifications.tsx @@ -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(EMPTY_FORM); + // The rule being edited, or null while the form creates a new one. + const [editingId, setEditingId] = useState(null); const [formError, setFormError] = useState(null); const [saving, setSaving] = useState(false); const [confirmRule, setConfirmRule] = useState(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) => 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() { {formError &&

{formError}

} @@ -710,6 +762,14 @@ export function AlertsNotifications() { > {rule.enabled ? t("rules.enabled") : t("rules.disabled")} + @@ -138,19 +140,19 @@ export function SagiPanel({
0 ? "accent" : "muted"} /> 0 ? "amber" : "muted"} /> 0 ? "red" : "muted"} /> @@ -158,27 +160,35 @@ export function SagiPanel({ {/* quick actions */}
- onNavigate("/workspace")} /> - onNavigate("/activity")} /> + onNavigate("/workspace")} + /> + onNavigate("/activity")} + /> onNavigate("/sessions")} /> onNavigate("/sessions")} /> @@ -188,15 +198,15 @@ export function SagiPanel({
setQuery(e.target.value)} - aria-label="Ask Sagi" + aria-label={t("sagi.panel.ask")} /> diff --git a/client/src/components/Sagi/SpeechBubble.tsx b/client/src/components/Sagi/SpeechBubble.tsx index 4ed41488..01b6d7c5 100644 --- a/client/src/components/Sagi/SpeechBubble.tsx +++ b/client/src/components/Sagi/SpeechBubble.tsx @@ -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 (
{text}
diff --git a/client/src/components/Sagi/__tests__/Pets.test.tsx b/client/src/components/Sagi/__tests__/Pets.test.tsx index bddbe0cd..b0cdca70 100644 --- a/client/src/components/Sagi/__tests__/Pets.test.tsx +++ b/client/src/components/Sagi/__tests__/Pets.test.tsx @@ -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(); + 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(); + 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" }); + }); }); diff --git a/client/src/components/Sagi/__tests__/Sagi.test.tsx b/client/src/components/Sagi/__tests__/Sagi.test.tsx index b4752b14..a661cffd 100644 --- a/client/src/components/Sagi/__tests__/Sagi.test.tsx +++ b/client/src/components/Sagi/__tests__/Sagi.test.tsx @@ -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"); + }); }); diff --git a/client/src/components/Sagi/usePets.ts b/client/src/components/Sagi/usePets.ts index cc4ae2f3..09397fe6 100644 --- a/client/src/components/Sagi/usePets.ts +++ b/client/src/components/Sagi/usePets.ts @@ -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ĩ */ 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. diff --git a/client/src/components/Sagi/useSagiPosition.ts b/client/src/components/Sagi/useSagiPosition.ts index aa3dfe2d..6a5ddecd 100644 --- a/client/src/components/Sagi/useSagiPosition.ts +++ b/client/src/components/Sagi/useSagiPosition.ts @@ -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ĩ */ /* ============================================================================= @@ -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, }; } diff --git a/client/src/components/UpdateNotifier.tsx b/client/src/components/UpdateNotifier.tsx index 33974003..b68cd127 100644 --- a/client/src/components/UpdateNotifier.tsx +++ b/client/src/components/UpdateNotifier.tsx @@ -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(null); const [dismissedSha, setDismissedSha] = useState(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(null); + const panelRef = useRef(null); const [error, setError] = useState(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 (
{ - 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…"). */} -
+
{/* Header */}
@@ -343,8 +355,9 @@ export function UpdateNotifier() {
+ {status.manual_command ? (
)} diff --git a/client/src/components/__tests__/AlertsNotifications.test.tsx b/client/src/components/__tests__/AlertsNotifications.test.tsx new file mode 100644 index 00000000..8543cbe4 --- /dev/null +++ b/client/src/components/__tests__/AlertsNotifications.test.tsx @@ -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ĩ + */ + +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( + + + + ); + +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 +}); diff --git a/client/src/components/__tests__/UpdateNotifier.test.tsx b/client/src/components/__tests__/UpdateNotifier.test.tsx index 3dac8876..a537601e 100644 --- a/client/src/components/__tests__/UpdateNotifier.test.tsx +++ b/client/src/components/__tests__/UpdateNotifier.test.tsx @@ -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(); + 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(); + 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(); + await userEvent.click(await screen.findByText(i18n.t("updates:updateNow"))); + await userEvent.keyboard("{Escape}"); + expect(screen.getByText(i18n.t("updates:title"))).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/__tests__/WebhookSettings.test.tsx b/client/src/components/__tests__/WebhookSettings.test.tsx new file mode 100644 index 00000000..9ae03670 --- /dev/null +++ b/client/src/components/__tests__/WebhookSettings.test.tsx @@ -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ĩ + */ + +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(); + 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(); + await userEvent.click(await screen.findByRole("switch")); + await waitFor(() => expect(toastError).toHaveBeenCalled()); + }); +}); diff --git a/client/src/components/config/ConfigTabBar.tsx b/client/src/components/config/ConfigTabBar.tsx index cf09d95b..1779d1b9 100644 --- a/client/src/components/config/ConfigTabBar.tsx +++ b/client/src/components/config/ConfigTabBar.tsx @@ -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ĩ */ -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 { key: K; @@ -21,15 +19,19 @@ export interface ConfigTab { 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 { tabs: ConfigTab[]; current: K; onSelect: (key: K) => void; - /** `data-testid` becomes `-`, e.g. "cc-tab-plugins". */ + /** `data-testid` (and the tab's id) becomes `-`, e.g. "cc-tab-plugins". */ testIdPrefix: string; ariaLabel: string; + /** The id of the tabpanel element every tab controls. */ + panelId?: string; } export function ConfigTabBar({ @@ -38,90 +40,63 @@ export function ConfigTabBar({ onSelect, testIdPrefix, ariaLabel, + panelId, }: ConfigTabBarProps) { - const scrollRef = useRef(null); - const [canScrollLeft, setCanScrollLeft] = useState(false); - const [canScrollRight, setCanScrollRight] = useState(false); + const listRef = useRef(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('[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) => { + 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(`[data-testid="${testIdPrefix}-${key}"]`)?.focus(); }; return ( -
- {/* Left edge gradient + chevron */} -
- {canScrollLeft && ( - - )} - -
- {tabs.map(({ key, icon: Icon, label, count }) => { - const active = current === key; - return ( +
+ {tabs.map(({ key, icon: Icon, label, count, group }, i) => { + const active = current === key; + const newGroup = group && group !== tabs[i - 1]?.group; + return ( + + {newGroup && ( + 0 ? "ml-1 border-l border-border pl-2" : "" + }`} + > + {group} + + )} - ); - })} -
- - {/* Right edge gradient + chevron */} -
- {canScrollRight && ( - - )} + + ); + })}
); } diff --git a/client/src/components/config/ConfirmDeleteModal.tsx b/client/src/components/config/ConfirmDeleteModal.tsx index 3ee617b4..e468e47c 100644 --- a/client/src/components/config/ConfirmDeleteModal.tsx +++ b/client/src/components/config/ConfirmDeleteModal.tsx @@ -6,7 +6,8 @@ * @author Nguyễn Ngọc Trí Vĩ */ -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(null); + const cancelRef = useRef(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 (
e.stopPropagation()} >
- + {t("edit.confirmDelete")}
-

{t("edit.confirmDeleteBody")}

+

+ {t("edit.confirmDeleteBody")} +

{t("edit.confirmDeletePath", { path: state.path })}