/** * @file Settings.tsx * @description The install-wide settings, as two pages of the Settings nav (`SettingsHub.tsx`): `section="general"` — total cost, workspaces housekeeping, import, remote sources, Sagi, data management and system information — and `section="notifications"` — browser notifications and alerts. Appearance is its own page (`components/settings/AppearanceSettings.tsx`); per-tool settings live in that tool's page under `/settings/agents/`. * @author Nguyễn Ngọc Trí Vĩ */ /* ============================================================================= * MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed) * ============================================================================= * **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode. * * ## Design constraints * - Local-first: no telemetry leaves the machine unless the user configures webhooks. * - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that * philosophy by degrading gracefully (empty states, stale badges, reconnect loops). * - Destructive flows stay behind explicit confirmation modals and server-side gates. * - Internationalization: user-visible strings belong in i18n JSON, not literals here. * * ## Remote data & SSH * Remote Data Sources let operators aggregate multiple machines. SSH entries describe * how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every * scoped GET via `?sources=`. Health checks and import history surface in Settings. * * ## Internal dependencies * - `../lib/api` * - `../lib/eventBus` * - `../components/Sagi/prefs` * - `../lib/format` * - `../lib/push` * - `../components/Tip` * - `../components/ImportHistory` * - `../components/RemoteSources` * - `../components/Skeleton` * - `../components/AlertsNotifications` * - `../lib/types` * * ## Public surface * - `Settings` — exported API; see TSDoc on the symbol for behavior. * * ## Testing pointers * - Prefer colocated `__tests__` with Vitest + Testing Library for UI. * - Server contract changes require `npm run test:server` and OpenAPI sync. * * ## Related docs * - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline. * - `docs/API.md` — REST reference. * - `skills/file-headers/` — mandatory `@author` header policy. * ============================================================================= */ /* ----------------------------------------------------------------------------- * EXPORT CATALOG — quick index of symbols defined below (documentation only). * ----------------------------------------------------------------------------- * **Settings** * Part of this module's public contract. Downstream imports should treat * the signature and return type as stable unless release notes say otherwise. * When behavior changes, update the `@file` overview and relevant tests. * * ----------------------------------------------------------------------------- */ import { useEffect, useState, useCallback } from "react"; import { useTranslation } from "react-i18next"; import { DollarSign, RefreshCw, Database, HardDrive, AlertTriangle, RotateCcw, Server, Bell, BellOff, BellRing, FileDown, Eraser, Zap, Play, GitBranch, ShieldCheck, ShieldAlert, ShieldX, Clock, Cpu, Globe, Wifi, Activity, Users, Layers, Coins, BarChart3, } from "lucide-react"; import { api } from "../lib/api"; import { eventBus } from "../lib/eventBus"; import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents"; import { sagiPrefs } from "../components/Sagi/prefs"; import { fmt, fmtCost } from "../lib/format"; import { subscribeToPush, unsubscribeFromPush } from "../lib/push"; import { loadNotifPrefs, saveNotifPrefs, NOTIF_SOUNDS, type NotifPrefs, type NotifSound, type NotifType, } from "../lib/notifPrefs"; import { notify, playSound } from "../lib/notify"; import { Tip } from "../components/Tip"; import { ImportHistory } from "../components/ImportHistory"; import { RemoteSources } from "../components/RemoteSources"; import { Skeleton } from "../components/Skeleton"; import { AlertsNotifications } from "../components/AlertsNotifications"; import { SettingsRow, SettingsSegmented, SettingsSwitchRow, } from "../components/settings/SettingsRow"; import type { WSMessage } from "../lib/types"; // The per-type notification switches, in the order Settings lists them. const NOTIF_TYPES: [NotifType, string][] = [ ["onAwaitingInput", "awaitingInput"], ["onNewSession", "newSession"], ["onTurnComplete", "turnComplete"], ["onSessionComplete", "sessionComplete"], ["onSessionError", "sessionError"], ["onSubagentSpawn", "subagentSpawned"], ]; // ─── Helpers ─── interface SystemInfo { db: { path: string; size: number; counts: Record }; hooks: { installed: boolean; path: string; hooks: Record }; server: { version: string; uptime: number; node_version: string; platform: string; ws_connections: number; }; } function formatBytes(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } function formatUptime(seconds: number): string { const d = Math.floor(seconds / 86400); const h = Math.floor((seconds % 86400) / 3600); const m = Math.floor((seconds % 3600) / 60); if (d > 0) return `${d}d ${h}h ${m}m`; if (h > 0) return `${h}h ${m}m`; return `${m}m`; } function useCountUp(end: number | null, durationMs = 1000) { const [count, setCount] = useState(0); useEffect(() => { if (end === null) { setCount(0); return; } let startTimestamp: number | null = null; let animationFrameId: number; const startValue = count; const step = (timestamp: number) => { if (!startTimestamp) startTimestamp = timestamp; const progress = Math.min((timestamp - startTimestamp) / durationMs, 1); // easeOutQuart const easeProgress = 1 - Math.pow(1 - progress, 4); setCount(startValue + (end - startValue) * easeProgress); if (progress < 1) { animationFrameId = window.requestAnimationFrame(step); } else { setCount(end); } }; animationFrameId = window.requestAnimationFrame(step); return () => window.cancelAnimationFrame(animationFrameId); }, [end, durationMs]); return count; } // ─── Main component ─── export function Settings({ section = "general", }: { /** Which of the two Settings pages this renders (`/settings/general` or `/settings/notifications`). */ section?: "general" | "notifications"; } = {}) { const { t } = useTranslation("settings"); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [totalCost, setTotalCost] = useState(null); const [sysInfo, setSysInfo] = useState(null); const [actionLoading, setActionLoading] = useState(null); const [actionResult, setActionResult] = useState<{ key: string; message: string; isError: boolean; } | null>(null); const [confirmAction, setConfirmAction] = useState(null); const [notifPrefs, setNotifPrefs] = useState(loadNotifPrefs); const [sagiEnabled, setSagiEnabled] = useState(() => sagiPrefs.getEnabled()); const setSagi = useCallback((v: boolean) => { sagiPrefs.setEnabled(v); setSagiEnabled(v); }, []); const [abandonHours, setAbandonHours] = useState("24"); const [purgeDays, setPurgeDays] = useState("90"); const [gcDryRun, setGcDryRun] = useState(false); const animatedTotalCost = useCountUp(totalCost); const load = useCallback(async () => { try { const [costRes, infoRes] = await Promise.all([api.pricing.totalCost(), api.settings.info()]); setTotalCost(costRes.total_cost); setSysInfo(infoRes); setError(null); } catch (err) { setError(err instanceof Error ? err.message : t("messages.failedLoad")); } finally { setLoading(false); } }, [t]); useEffect(() => { load(); }, [load]); useEffect(() => { const refreshInfo = () => api.settings .info() .then(setSysInfo) .catch(() => {}); const interval = setInterval(refreshInfo, 10000); return () => clearInterval(interval); }, []); useEffect(() => { return eventBus.subscribe((msg: WSMessage) => { if ( msg.type === "session_created" || msg.type === "session_updated" || msg.type === "agent_created" || msg.type === "agent_updated" || msg.type === "new_event" ) { api.settings .info() .then(setSysInfo) .catch(() => {}); } if (isRemoteDataRefreshMessage(msg)) { load(); } }); }, [load]); useEffect(() => { if (!actionResult) return; const timeout = setTimeout(() => setActionResult(null), 5000); return () => clearTimeout(timeout); }, [actionResult]); const updateNotifPrefs = (patch: Partial) => { setNotifPrefs((prev) => { const next = { ...prev, ...patch }; saveNotifPrefs(next); return next; }); }; const requestNotifPermission = async () => { if (!("Notification" in window)) return; const perm = await Notification.requestPermission(); if (perm === "granted") { updateNotifPrefs({ enabled: true }); // A browser with no service worker / on a non-secure origin rejects here; // notifications still work through the local fallback in useNotifications, // so this must not blow up the enable flow. await subscribeToPush().catch(() => {}); } }; const runAction = async (key: string, fn: () => Promise) => { setActionLoading(key); setActionResult(null); setConfirmAction(null); try { const message = await fn(); setActionResult({ key, message, isError: false }); await load(); } catch (err) { setActionResult({ key, message: t("messages.actionFailed", { message: err instanceof Error ? err.message : t("messages.unknownError"), }), isError: true, }); } finally { setActionLoading(null); } }; const handleClearData = () => runAction("clear", async () => { const res = await api.settings.clearData(); const total = Object.values(res.cleared).reduce((s, n) => s + n, 0); return t("danger.clearedResult", { count: total }); }); const handleGc = () => runAction("gc", async () => { const res = await api.settings.gc({ dryRun: gcDryRun }); return t("lanesSection.gcResult", { reaped: res.reaped.length, capped: res.capped.length }); }); const handleCleanup = () => runAction("cleanup", async () => { const params: { abandon_hours?: number; purge_days?: number } = {}; const ah = parseFloat(abandonHours); const pd = parseFloat(purgeDays); if (ah > 0) params.abandon_hours = ah; if (pd > 0) params.purge_days = pd; const res = await api.settings.cleanup(params); const parts = []; if (res.abandoned > 0) parts.push(`${res.abandoned}${t("data.abandonedResult")}`); if (res.purged_sessions > 0) parts.push( `${res.purged_sessions}${t("data.purgedResult", { events: res.purged_events, agents: res.purged_agents })}` ); return parts.length > 0 ? parts.join(". ") : t("data.nothingToClean"); }); const actionBanner = (keys: string[]) => { const match = actionResult && keys.includes(actionResult.key) ? actionResult : null; if (!match) return null; return (
{match.message}
); }; if (loading) { return (
{Array.from({ length: 3 }).map((_, i) => (
))}
); } return (
{section === "general" && (
{t("exportData")}
)} {error && (
{error}
)} {section === "general" && ( <> {/* Cost summary card */}

{t("common:cost.totalEstimatedCost")}

{totalCost !== null ? fmtCost(animatedTotalCost) : "$-.--"}

{t("acrossSessions")}

{t("basedOnUsage")}

{/* ─── LANES ─── */}

{t("lanesSection.title")}

{t("lanesSection.description")}

{actionBanner(["gc"])}
{/* ─── IMPORT HISTORY ─── */}
{/* ─── REMOTE DATA SOURCES ─── */}
{/* ─── SAGI COMPANION ─── */}

🦸 {t("sagi.title", "Sagi companion")}

{t("sagi.description", "A floating mascot that reacts to your live sessions.")}

🦸
)} {section === "notifications" && ( <> {/* ─── NOTIFICATIONS ─── */}

{t("notifications.title")}

{t("notifications.description")}

{notifPrefs.enabled ? ( ) : ( )}
{ if (v) { if ("Notification" in window && Notification.permission !== "granted") { requestNotifPermission(); } else { updateNotifPrefs({ enabled: true }); await subscribeToPush(); } } else { updateNotifPrefs({ enabled: false }); await unsubscribeFromPush(); } }} label={t("notifications.enable")} />
{"Notification" in window && ( {Notification.permission === "granted" ? ( ) : Notification.permission === "denied" ? ( ) : ( )} {Notification.permission === "granted" ? t("notifications.granted") : Notification.permission === "denied" ? t("notifications.blocked") : t("notifications.required")} )}
{notifPrefs.enabled && (
} />

{t("notifications.notifyWhen")}

{NOTIF_TYPES.map(([type, labelKey]) => (
updateNotifPrefs({ [type]: v })} label={t(`notifications.${labelKey}`)} /> {notifPrefs[type] && (
{ const next = { ...notifPrefs.soundByType }; if (v === "default") delete next[type]; else next[type] = v; updateNotifPrefs({ soundByType: next }); if (v !== "default") playSound(v); }} options={[ { value: "default" as const, label: t("notifications.sound.useDefault"), }, ...NOTIF_SOUNDS.map((k) => ({ value: k, label: t(`notifications.sound.${k}`), })), ]} />
)}
))}
{actionBanner(["notif-test"])}
)} {!notifPrefs.enabled && (
{t("notifications.disabledInfo")}
)}
{/* ─── ALERTS ─── */}

{t("alertsHub.title")}

{t("alertsHub.description")}

)} {section === "general" && ( <> {/* ─── DATA MANAGEMENT ─── */}

{t("data.title")}

{t("data.description")}

{t("data.dbOverview")}

{sysInfo && (
{sysInfo.db.path}
)}
{sysInfo ? (
{(() => { const tableIcons: Record = { sessions: , agents: , events: ( ), token_usage: , model_pricing: ( ), }; const tableLabels: Record = { sessions: t("tables.sessions"), agents: t("tables.agents"), events: t("tables.events"), token_usage: t("tables.sessionsWithCost"), model_pricing: t("tables.pricingRules"), }; const tableColors: Record = { sessions: "border-blue-600/20", agents: "border-status-success/20", events: "border-violet-500/20", token_usage: "border-status-warning/20", model_pricing: "border-cyan-500/20", }; return Object.entries(sysInfo.db.counts).map(([table, count]) => (
{tableIcons[table] || }

{tableLabels[table] || table.replace(/_/g, " ")}

{fmt(count)}

)); })()}

{t("data.dbSize")}

{formatBytes(sysInfo.db.size)}

) : (

{t("data.loadingDb")}

)}
{/* Session Cleanup */}

{t("data.sessionCleanup")}

{t("data.cleanupDesc")}

setAbandonHours(e.target.value)} className="input w-20 text-sm text-right font-mono" /> {t("common:hours")}
setPurgeDays(e.target.value)} className="input w-20 text-sm text-right font-mono" /> {t("common:days")}
{actionBanner(["cleanup"])}
{/* Danger zone */}

{t("danger.title")}

{t("danger.description")}

{confirmAction === "clear" ? (
{t("danger.warning")}
) : ( )} {actionBanner(["clear"])}
{/* ─── ABOUT ─── */}

{t("about.title")}

{t("about.description")}

{sysInfo ? (

{t("about.release")}

v{sysInfo.server.version}

{sysInfo.server.version !== __APP_VERSION__ && (

{t("about.uiBuild", { version: __APP_VERSION__ })}

)}

{t("about.uptime")}

{formatUptime(sysInfo.server.uptime)}

{t("about.nodejs")}

{sysInfo.server.node_version}

{t("about.platform")}

{sysInfo.server.platform}

{t("about.wsClients")}

{sysInfo.server.ws_connections}

) : (

{t("about.loadingInfo")}

)}
)} ); }