"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"; /** Owner (2026-09-20): hints earn their place — mechanics-explaining * prose is out; only the keyboard note stays (storage location is a * real question users ask). */ const SECTIONS = [ { id: "general", label: "General" }, { id: "agent", label: "Agent" }, { id: "appearance", label: "Appearance" }, { id: "graph", label: "Graph" }, { id: "keyboard", label: "Keyboard", hint: "Stored on this device" }, ] 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" && ( )}
)}
); }