"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, useRef, 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 type { ModelsSettings } from "@/src/core"; import { MENU_CLOSE_EVENT, MENU_OPEN_EVENT } from "@/components/menu-shell"; import { notifySettingsSaved } from "@/components/settings-signal"; import { KeysSection } from "./keys-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: "keys", label: "Keys" }, { 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: (section?: string) => void }>({ open: () => {} }); /** AppShell-level mount: the sidebar row and the mod+, chord call this. * `open(section)` lands the dialog on a specific section (the first-run * setup flow deep-links to Keys when no provider key exists yet). */ export function SettingsDialogProvider({ children, }: { children: React.ReactNode; }) { const [show, setShow] = useState(false); const [landOn, setLandOn] = useState("general"); const open = useCallback((section?: string) => { if (section) setLandOn(section as SectionId); setShow(true); }, []); return ( {children} {show && setShow(false)} initialSection={landOn} />} ); } export function useSettingsDialog() { return useContext(DialogContext); } function SettingsDialog({ onClose, initialSection }: { onClose: () => void; initialSection: SectionId }) { // 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, initialSection = "general", }: { onClose: () => void; initialSection?: SectionId; }) { const { onAppearanceChange } = useAppearance(); const [record, setRecord] = useState( undefined, ); const [error, setError] = useState(null); // MenuShell open-count — drives the content column's scroll lock. const [openMenus, setOpenMenus] = useState(0); const contentRef = useRef(null); // Scroll-lock (fix wave, owner ruling: the scrollbar MAY stay, // scrolling must NOT happen, nothing may move): the content column // keeps overflow-y-auto — the scrollbar and every width stay exactly // as they are — and while any MenuShell is open, non-passive wheel + // touchmove listeners on the column preventDefault. React attaches // synthetic wheel/touch passively at the root, so this needs the // native-listener ref seam. useEffect(() => { const el = contentRef.current; if (!el || openMenus === 0) return; const block = (e: Event) => e.preventDefault(); el.addEventListener("wheel", block, { passive: false }); el.addEventListener("touchmove", block, { passive: false }); return () => { el.removeEventListener("wheel", block); el.removeEventListener("touchmove", block); }; }, [openMenus]); useEffect(() => { const onOpen = () => setOpenMenus((n) => n + 1); const onClose = () => setOpenMenus((n) => Math.max(0, n - 1)); window.addEventListener(MENU_OPEN_EVENT, onOpen); window.addEventListener(MENU_CLOSE_EVENT, onClose); return () => { window.removeEventListener(MENU_OPEN_EVENT, onOpen); window.removeEventListener(MENU_CLOSE_EVENT, onClose); }; }, []); const [active, setActive] = useState(initialSection); // 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" | "models" | "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; } // Server truth, never the request body (fix wave, owner repro): // the models section clamps pinned thinking levels on write // (bd68486) — a swap that rides an unsupported level lands // CLAMPED in the record, and the writing surface must show the // stored value in this same interaction, not the optimistic one. const stored = ( (await res.json().catch(() => null)) as { settings?: UserSettings } | null )?.settings; setRecord( (prev) => stored ?? ({ ...(prev ?? {}), [section]: body }) as UserSettings, ); notifySettingsSaved(section); 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 — and LOCKS while any MenuShell is open (owner ruling: no scrolling with a dropdown down; MenuShell announces open/close, the count keeps overlap honest). The lock intercepts wheel/touch instead of hiding the scrollbar — nothing may shift when the lock engages. */}
{error && (

{error}

)} {record === undefined ? (

Loading settings…

) : record === null ? (

Settings could not be loaded — refresh to retry.

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