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
+164 -110
View File
@@ -20,6 +20,7 @@
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import { useSearchParams } from "react-router-dom";
import { eventBus } from "../lib/eventBus";
import {
Boxes,
@@ -53,7 +54,10 @@ import {
ConfirmDeleteModal,
type ConfirmDeleteState,
} from "../components/config/ConfirmDeleteModal";
import { ToastNotice, type Toast } from "../components/config/ToastNotice";
import { showToast, type Toast } from "../components/config/ToastNotice";
import { copyText } from "../lib/clipboard";
import { useDialog } from "../hooks/useDialog";
import { isPluginCachePath, restoreCommand } from "./CcConfig";
import { ScopeBadge } from "../components/config/ScopeBadge";
import { FileItemMenu } from "../components/config/MdItemList";
import {
@@ -98,17 +102,19 @@ interface TabDef {
key: TabKey;
icon: typeof Sparkles;
i18nKey: string;
/** i18n key (in ccConfig) of the tab's group label. */
group?: string;
}
const TABS: TabDef[] = [
{ key: "overview", icon: Boxes, i18nKey: "tabs.overview" },
{ key: "skills", icon: Sparkles, i18nKey: "tabs.skills" },
{ key: "agents", icon: UserRound, i18nKey: "tabs.agents" },
{ key: "rules", icon: FileText, i18nKey: "tabs.rules" },
{ key: "memory", icon: BookOpen, i18nKey: "tabs.memory" },
{ key: "config", icon: SettingsIcon, i18nKey: "tabs.config" },
{ key: "mcp", icon: Server, i18nKey: "tabs.mcp" },
{ key: "plugins", icon: PlugZap, i18nKey: "tabs.plugins" },
{ key: "skills", icon: Sparkles, i18nKey: "tabs.skills", group: "tabGroups.content" },
{ key: "agents", icon: UserRound, i18nKey: "tabs.agents", group: "tabGroups.content" },
{ key: "rules", icon: FileText, i18nKey: "tabs.rules", group: "tabGroups.content" },
{ key: "memory", icon: BookOpen, i18nKey: "tabs.memory", group: "tabGroups.content" },
{ key: "mcp", icon: Server, i18nKey: "tabs.mcp", group: "tabGroups.extensions" },
{ key: "plugins", icon: PlugZap, i18nKey: "tabs.plugins", group: "tabGroups.extensions" },
{ key: "config", icon: SettingsIcon, i18nKey: "tabs.config", group: "tabGroups.config" },
];
function isNamedArtifact(tab: TabKey): tab is "skills" | "agents" | "rules" {
@@ -159,7 +165,24 @@ interface NamedArtifactItem {
export function CodexConfig() {
const { t } = useTranslation("codexConfig");
const { t: tSettings } = useTranslation("settings");
const [tab, setTab] = useState<TabKey>("overview");
// The tab lives in the URL (`?tab=`, replaced) so search results, deep links
// and Back land on it.
const [params, setParams] = useSearchParams();
const rawTab = params.get("tab");
const tab: TabKey = TABS.some((d) => d.key === rawTab) ? (rawTab as TabKey) : "overview";
const setTab = useCallback(
(k: TabKey) =>
setParams(
(prev) => {
const next = new URLSearchParams(prev);
if (k === "overview") next.delete("tab");
else next.set("tab", k);
return next;
},
{ replace: true }
),
[setParams]
);
// Bumped by Refresh: the AgentHub cards load on mount, so a new key reloads them.
const [agenthubKey, setAgenthubKey] = useState(0);
// Bumped after a CODEX_HOME save: the hooks card reads the new home's hooks.json.
@@ -175,7 +198,10 @@ export function CodexConfig() {
const [backupsOpen, setBackupsOpen] = useState(false);
const [projectMemoryOpen, setProjectMemoryOpen] = useState(false);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const [search, setSearch] = useState("");
// One search box per tab: a filter typed on Skills must not hide Plugins.
const [searches, setSearches] = useState<Partial<Record<TabKey, string>>>({});
const search = searches[tab] ?? "";
const setSearch = (v: string) => setSearches((s) => ({ ...s, [tab]: v }));
const [viewer, setViewer] = useState<{
path: string;
data: CodexFileResponse | null;
@@ -184,13 +210,7 @@ export function CodexConfig() {
const [editor, setEditor] = useState<EditorState<CodexArtifactType, "user" | "project">>(null);
const [confirmDelete, setConfirmDelete] =
useState<ConfirmDeleteState<CodexArtifactType, "user" | "project">>(null);
const [toast, setToast] = useState<Toast>(null);
useEffect(() => {
if (!toast) return;
const id = setTimeout(() => setToast(null), 5000);
return () => clearTimeout(id);
}, [toast]);
const setToast = showToast;
const fetchAll = useCallback(async () => {
setLoading(true);
@@ -269,6 +289,12 @@ export function CodexConfig() {
}, []);
const wsConnected = useSyncExternalStore(eventBus.onConnection, () => eventBus.connected);
const projectRoot = data.overview?.roots.projectRoot ?? null;
// Project files under the plugin cache are wiped by the next plugin update.
const projectDisabled = isPluginCachePath(projectRoot);
useEffect(() => {
if (projectDisabled && scope === "project") setScope("all");
}, [projectDisabled, scope]);
const openViewer = useCallback(async (path: string) => {
setViewer({ path, data: null, error: null });
@@ -285,10 +311,10 @@ export function CodexConfig() {
(type: CodexArtifactType, overrideScope?: "user" | "project") => {
const template = t(`edit.templates.${type}`);
const defaultScope: "user" | "project" =
overrideScope ?? (scope === "project" ? "project" : "user");
overrideScope ?? (scope === "project" && !projectDisabled ? "project" : "user");
setEditor({ mode: "create", type, defaultScope, template });
},
[scope, t]
[scope, t, projectDisabled]
);
const openEdit = useCallback(
@@ -381,8 +407,6 @@ export function CodexConfig() {
<Header
loading={loading}
lastUpdated={lastUpdated}
scope={scope}
onScopeChange={setScope}
onRefresh={() => {
void fetchAll();
setAgenthubKey((k) => k + 1);
@@ -400,29 +424,46 @@ export function CodexConfig() {
<Tabs current={tab} onSelect={setTab} counts={data.overview?.counts} />
<div className="rounded-xl border border-border bg-surface-1">
<div
id="codex-tabpanel"
role="tabpanel"
aria-labelledby={`codex-tab-${tab}`}
className="rounded-xl border border-border bg-surface-1"
>
{tab !== "overview" && tab !== "memory" && tab !== "config" && (
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
<Search className="w-4 h-4 text-fg-muted" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("common.search")}
className="h-7 bg-transparent text-sm text-fg-primary placeholder:text-fg-muted focus:outline-none flex-1"
/>
{search && (
<button
type="button"
onClick={() => setSearch("")}
title={t("common.clearSearch")}
aria-label={t("common.clearSearch")}
className="h-7 w-7 flex-shrink-0 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-fg-secondary hover:bg-surface-3 focus:outline-none focus:ring-1 focus:ring-accent/40"
>
<X className="w-3.5 h-3.5" />
</button>
<div className="flex flex-wrap items-center gap-2 border-b border-border px-4 py-2.5">
<div className="flex flex-1 min-w-[12rem] items-center gap-2 rounded-md px-1 focus-within:ring-2 focus-within:ring-accent/40">
<Search className="w-4 h-4 text-fg-muted" aria-hidden />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("common.search")}
aria-label={t("common.search")}
className="h-7 bg-transparent text-sm text-fg-primary placeholder:text-fg-muted focus:outline-none flex-1"
/>
{search && (
<button
type="button"
onClick={() => setSearch("")}
title={t("common.clearSearch")}
aria-label={t("common.clearSearch")}
className="h-7 w-7 flex-shrink-0 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-fg-secondary hover:bg-surface-3 focus:outline-none focus:ring-1 focus:ring-accent/40"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
{isNamedArtifact(tab) && (
<ScopeToggle
value={scope}
onChange={setScope}
projectRoot={projectRoot}
projectDisabled={projectDisabled}
/>
)}
{isNamedArtifact(tab) && (
<button
type="button"
onClick={() => openCreate(tab)}
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5"
>
@@ -500,10 +541,11 @@ export function CodexConfig() {
{editor && (
<EditorModal<CodexArtifactType, "user" | "project">
state={editor}
fetchContent={(p) => api.codexConfig.file(p).then((r) => r.text)}
fetchContent={(p) => api.codexConfig.file(p)}
onClose={() => setEditor(null)}
onSave={handleSave}
namelessTypes={NAMELESS_TYPES}
projectDisabled={projectDisabled}
/>
)}
{confirmDelete && (
@@ -522,7 +564,6 @@ export function CodexConfig() {
onCreate={handleCreateProjectMemory}
/>
)}
{toast && <ToastNotice toast={toast} onDismiss={() => setToast(null)} />}
</div>
);
}
@@ -532,22 +573,12 @@ export function CodexConfig() {
interface HeaderProps {
loading: boolean;
lastUpdated: Date | null;
scope: CodexScope;
onScopeChange: (s: CodexScope) => void;
onRefresh: () => void;
onOpenBackups: () => void;
wsConnected: boolean;
}
function Header({
loading,
lastUpdated,
scope,
onScopeChange,
onRefresh,
onOpenBackups,
wsConnected,
}: HeaderProps) {
function Header({ loading, lastUpdated, onRefresh, onOpenBackups, wsConnected }: HeaderProps) {
const { t } = useTranslation("codexConfig");
const { t: tCommon } = useTranslation("common");
const formatted = lastUpdated
@@ -559,32 +590,25 @@ function Header({
: "-";
return (
<header className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div className="flex items-start gap-3 min-w-0 flex-1">
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center flex-shrink-0">
<Boxes className="w-4.5 h-4.5 text-accent" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
{wsConnected ? (
<span className="flex items-center gap-1.5 text-[11px] text-status-success bg-status-success/10 border border-status-success/20 px-2 py-0.5 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse-dot" />
{tCommon("live")}
</span>
) : (
<span className="flex items-center gap-1.5 text-[11px] text-fg-secondary bg-surface-4/10 border border-border-light/20 px-2 py-0.5 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-surface-4" />
{tCommon("offline")}
</span>
)}
</div>
<p className="text-xs text-fg-muted max-w-2xl">{t("subtitle")}</p>
</div>
{/* No title of its own: the Settings hub already draws one. */}
<div className="flex items-start gap-2 min-w-0 flex-1">
{wsConnected ? (
<span className="flex items-center gap-1.5 text-[11px] text-status-success bg-status-success/10 border border-status-success/20 px-2 py-0.5 rounded-full flex-shrink-0">
<span className="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse-dot" />
{tCommon("live")}
</span>
) : (
<span className="flex items-center gap-1.5 text-[11px] text-fg-secondary bg-surface-4/10 border border-border-light/20 px-2 py-0.5 rounded-full flex-shrink-0">
<span className="w-1.5 h-1.5 rounded-full bg-surface-4" />
{tCommon("offline")}
</span>
)}
<p className="text-xs text-fg-muted max-w-2xl">{t("subtitle")}</p>
</div>
<div className="flex flex-col items-stretch lg:items-end gap-2 flex-shrink-0">
<div className="flex items-center gap-2 justify-end flex-wrap">
<ScopeToggle value={scope} onChange={onScopeChange} />
<button
type="button"
onClick={onOpenBackups}
className="inline-flex items-center gap-2 rounded-lg border border-border bg-surface-2 px-3 py-1.5 text-xs font-medium text-fg-secondary hover:bg-surface-3 transition-colors"
>
@@ -592,6 +616,7 @@ function Header({
{t("backups.openButton")}
</button>
<button
type="button"
onClick={onRefresh}
disabled={loading}
className="inline-flex items-center gap-2 rounded-lg border border-border bg-surface-2 px-3 py-1.5 text-xs font-medium text-fg-secondary hover:bg-surface-3 disabled:opacity-60 transition-colors"
@@ -617,34 +642,62 @@ function Header({
);
}
/** All / User / Project for the three scoped lists, naming the folder
* "Project" resolves to — and refusing it when that is the plugin cache. */
function ScopeToggle({
value,
onChange,
projectRoot,
projectDisabled,
}: {
value: CodexScope;
onChange: (s: CodexScope) => void;
projectRoot: string | null;
projectDisabled: boolean;
}) {
const { t } = useTranslation("codexConfig");
const { t: tCc } = useTranslation("ccConfig");
const opts: { v: CodexScope; label: string }[] = [
{ v: "all", label: t("scope.all") },
{ v: "user", label: t("scope.user") },
{ v: "project", label: t("scope.project") },
];
return (
<div className="inline-flex rounded-lg border border-border bg-surface-2 p-0.5">
{opts.map((o) => (
<button
key={o.v}
onClick={() => onChange(o.v)}
className={`px-2.5 py-1 text-[11px] font-medium rounded-md transition-colors ${
value === o.v
? "bg-accent/20 text-accent border border-accent/30"
: "text-fg-secondary hover:text-fg-primary"
}`}
<div className="flex items-center gap-2 min-w-0">
<div
role="group"
aria-label={tCc("common.scopeLabel")}
className="inline-flex rounded-lg border border-border bg-surface-2 p-0.5 flex-shrink-0"
>
{opts.map((o) => {
const disabled = o.v === "project" && projectDisabled;
return (
<button
key={o.v}
type="button"
aria-pressed={value === o.v}
disabled={disabled}
title={disabled ? tCc("scope.projectInCache") : undefined}
onClick={() => onChange(o.v)}
className={`px-2.5 py-1 text-[11px] font-medium rounded-md transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${
value === o.v
? "bg-accent/20 text-accent border border-accent/30"
: "text-fg-secondary hover:text-fg-primary"
}`}
>
{o.label}
</button>
);
})}
</div>
{projectRoot && (
<span
className={`truncate font-mono text-[10px] ${projectDisabled ? "text-status-warning" : "text-fg-muted"}`}
title={projectDisabled ? tCc("scope.projectInCache") : projectRoot}
>
{o.label}
</button>
))}
{tCc("scope.projectIs", { path: projectRoot })}
</span>
)}
</div>
);
}
@@ -661,6 +714,7 @@ function Tabs({
counts?: CodexOverview["counts"];
}) {
const { t } = useTranslation("codexConfig");
const { t: tCc } = useTranslation("ccConfig");
const countFor = (key: TabKey): number | null => {
if (!counts) return null;
switch (key) {
@@ -680,15 +734,17 @@ function Tabs({
};
return (
<ConfigTabBar
tabs={TABS.map(({ key, icon, i18nKey }) => ({
tabs={TABS.map(({ key, icon, i18nKey, group }) => ({
key,
icon,
label: t(i18nKey),
count: countFor(key),
group: group ? tCc(group) : undefined,
}))}
current={current}
onSelect={onSelect}
testIdPrefix="codex-tab"
panelId="codex-tabpanel"
ariaLabel={t("title")}
/>
);
@@ -1300,7 +1356,7 @@ function CliRowMenu({
<MenuItem
onSelect={() => {
onClose();
void navigator.clipboard.writeText(item.label);
void copyText(item.label);
}}
>
{t("list.copyName")}
@@ -1756,27 +1812,29 @@ function BackupsModal({
.catch((err: unknown) => setError(err instanceof Error ? err.message : "unknown"));
}, []);
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="codex-backups-title"
className="relative w-full max-w-3xl 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">
<History className="w-4 h-4 text-fg-muted" />
<span className="text-sm font-medium text-fg-primary flex-1">{t("backups.title")}</span>
<span id="codex-backups-title" className="text-sm font-medium text-fg-primary flex-1">
{t("backups.title")}
</span>
<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")}
@@ -1818,7 +1876,11 @@ function BackupRow({
}) {
const { t } = useTranslation("codexConfig");
const scope = deriveScope(backup.file, roots);
const restoreCmd = `mv ${shellEscape(backup.file)} ${shellEscape(deriveActivePath(backup, roots))}`;
const restoreCmd = restoreCommand(
backup.file,
deriveActivePath(backup, roots),
backup.type === "skills"
);
return (
<div className="rounded-lg border border-border bg-surface-2 px-3 py-2.5">
<div className="flex items-center gap-2 flex-wrap">
@@ -1851,13 +1913,11 @@ function CommandSnippet({ command }: { command: string }) {
<Terminal className="w-3 h-3 text-fg-muted flex-shrink-0" />
<code className="font-mono text-[11px] text-fg-secondary truncate flex-1">{command}</code>
<button
type="button"
onClick={async () => {
try {
await navigator.clipboard.writeText(command);
if (await copyText(command, 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"
@@ -1905,9 +1965,3 @@ function deriveActivePath(b: CodexBackup, roots: CodexOverview["roots"] | undefi
const root = scope === "user" ? roots.codexHome : roots.projectCodexDir;
return `${root}/${b.type}/${baseName}`;
}
// Quote for POSIX shells: wrap in single quotes, escape any embedded single quotes.
function shellEscape(s: string): string {
if (/^[A-Za-z0-9_/.@:=+,-]+$/.test(s)) return s;
return `'${s.replace(/'/g, `'\\''`)}'`;
}