diff --git a/components/settings/settings-dialog.tsx b/components/settings/settings-dialog.tsx new file mode 100644 index 0000000..b61f179 --- /dev/null +++ b/components/settings/settings-dialog.tsx @@ -0,0 +1,331 @@ +"use client"; +/** + * THE SETTINGS DIALOG (settings-app wave, 2026-09-20): the thin settings + * page graduated into the designed surface — a centered modal over + * everything, left section nav + one scrolling content column. + * + * Owner decisions this wave: + * - Dialog, not a page: opens over whatever screen you're on (the payoff: + * appearance edits restyle the chat LIVE behind the dialog). Escape, + * backdrop, and ✕ all close. Modal-only — /settings redirects, so + * nothing links into a dead surface. + * - Left section nav in the app's mono chrome (text-only, session-list + * language — active = bg-accent), no icons, matching the minimal-color + * doctrine. + * - The dialog is surface-agnostic by construction: the sections talk to + * /api/settings* and localStorage only, so this shell can be re-mounted + * as a true OS window in the Electron round with near-zero churn. + * + * The record + the single `write` seam moved here from the thin page: + * one record instance for the whole dialog, sections write through it, + * switching sections never refetches. + */ + +import { + createContext, + useCallback, + useContext, + useEffect, + useState, +} from "react"; +import { createPortal } from "react-dom"; +import { + CURRENT_SETTINGS_VERSION, + type AgentSettings, + type AppearanceSettings, + type GraphSettings, + type UserSettings, +} from "@/src/core"; +import { useAppearance } from "./appearance.tsx"; +import { SettingsSection, SettingsRow, Toggle } from "./controls.tsx"; +import { AgentSection } from "./agent-section.tsx"; +import { AppearanceSection, GraphSection } from "./appearance-section.tsx"; +import { KeyboardSection } from "./keyboard-section.tsx"; + +const SECTIONS = [ + { id: "general", label: "General", hint: "App-level behavior" }, + { + id: "agent", + label: "Agent", + hint: "Background-role models, tools, and prompt overrides", + }, + { + id: "appearance", + label: "Appearance", + hint: "Applied live as CSS variables", + }, + { + id: "graph", + label: "Graph", + hint: "Dependency ladder — a disabled rung grays everything below it", + }, + { + id: "keyboard", + label: "Keyboard", + hint: "Rebinds apply instantly — stored in this browser", + }, +] as const; +type SectionId = (typeof SECTIONS)[number]["id"]; + +const DialogContext = createContext<{ open: () => void }>({ open: () => {} }); + +/** AppShell-level mount: the sidebar row and the mod+, chord call this. */ +export function SettingsDialogProvider({ + children, +}: { + children: React.ReactNode; +}) { + const [show, setShow] = useState(false); + const open = useCallback(() => setShow(true), []); + return ( + + {children} + {show && setShow(false)} />} + + ); +} + +export function useSettingsDialog() { + return useContext(DialogContext); +} + +function SettingsDialog({ onClose }: { onClose: () => void }) { + // Plain dim backdrop (no blur — nothing decorative); the click + // closes, the dialog body stops propagation. + return createPortal( +
+ +
, + document.body, + ); +} + +/** The dialog without the portal/backdrop — the SSR-testable frame + * (portals render nothing under renderToStaticMarkup). */ +export function SettingsDialogBody({ onClose }: { onClose: () => void }) { + const { onAppearanceChange } = useAppearance(); + const [record, setRecord] = useState( + undefined, + ); + const [error, setError] = useState(null); + const [active, setActive] = useState("general"); + + // Fetch once per dialog mount. 200 {settings: null} is FIRST BOOT + // (no record = factory everything), not a failure — the error state is + // a non-200 or a network throw only. + useEffect(() => { + fetch("/api/settings") + .then((r) => + r.ok ? r.json() : Promise.reject(new Error(`status ${r.status}`)), + ) + .then((body: { settings: UserSettings | null }) => + setRecord(body.settings ?? { version: CURRENT_SETTINGS_VERSION }), + ) + .catch(() => setRecord(null)); + }, []); + + // Escape closes — the layered-dismiss convention (Esc is dead for the + // sections' own editors while they hold focus; this listener is the + // dialog's outer layer). + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [onClose]); + + /** ONE writer for the whole dialog: PUT the whole section, keep the + * record mirror in sync, surface route errors inline (a 400 is a + * schema mistake caught by the validation surface). */ + const write = useCallback( + async ( + section: "general" | "agent" | "appearance" | "graph", + body: object, + ) => { + setError(null); + const res = await fetch(`/api/settings/${section}`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const detail = (await res.json().catch(() => ({}))) as { + error?: string; + }; + setError(`${section}: ${detail.error ?? res.status}`); + return; + } + setRecord( + (prev) => ({ ...(prev ?? {}), [section]: body }) as UserSettings, + ); + if (section === "appearance") + onAppearanceChange(body as AppearanceSettings); + }, + [onAppearanceChange], + ); + + return ( +
e.stopPropagation()} + > + {/* Header — the app's quiet CMU title language. */} +
+

Settings

+ +
+ +
+ {/* Left nav — text-only, the session-list mono language, + active = bg-accent. */} + + + {/* Content column — one section at a time, this scrolls. */} +
+ {error && ( +

+ {error} +

+ )} + {record === undefined ? ( +

+ Loading settings… +

+ ) : record === null ? ( +

+ Settings could not be loaded — refresh to retry. +

+ ) : ( +
+ {active === "general" && ( + + + + void write("general", { autoUpdate: on }) + } + /> + + + )} + {active === "agent" && ( + + + void write("agent", next) + } + /> + + )} + {active === "appearance" && ( + + + void write("appearance", next) + } + /> + + )} + {active === "graph" && ( + + + void write("graph", next) + } + /> + + )} + {active === "keyboard" && ( + + + + )} +
+ )} +
+
+
+ ); +}