"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 { useBackdropDismiss } from "@/components/backdrop-dismiss"; import { CURRENT_SETTINGS_VERSION, type AgentSettings, type AppearanceSettings, type CompactionSettings, type GraphSettings, type UserSettings, } from "@/src/core"; import { LanguageSelect, SttPreferredLanguages, useI18n } from "@/components/i18n"; 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 { Hint } from "@/components/ui/hint"; import { AppearanceSection, GraphSection } from "./appearance-section.tsx"; import { KeyboardSection } from "./keyboard-section.tsx"; import { ChatGPTUIProvider } from "./chatgpt-connection.tsx"; import { NativeSubscriptionUIProvider } from "./subscription-connections.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: "keys", label: "Providers" }, { id: "agent", label: "Agent" }, { id: "appearance", label: "Appearance" }, { id: "graph", label: "Graph" }, { id: "keyboard", label: "Keyboard", hint: "Stored on this device" }, { id: "general", label: "General" }, ] as const; type SectionId = (typeof SECTIONS)[number]["id"]; /** `open(section?, row?)` — the optional ROW deep-links into the Keys * section with that row unfolded (zero-keys wave: the dictation error's * click lands on the Deepgram row). `row` is the KEY ID (the env-var * name, e.g. DEEPGRAM_API_KEY — KeysSection's openRow state), not the * row's testid. */ const DialogContext = createContext<{ open: (section?: string, row?: 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). */ /** The auto-update row's hint (tooltip wave): honest user language — no * stream vocabulary. Exported for the copy pin (tooltip-mechanism test). */ export const AUTO_UPDATE_HINT = "Automatic updates arrive in an upcoming release"; export function SettingsDialogProvider({ children, }: { children: React.ReactNode; }) { const [show, setShow] = useState(false); const [landOn, setLandOn] = useState("keys"); const [landOnRow, setLandOnRow] = useState(null); const open = useCallback((section?: string, row?: string) => { setLandOn(section ? section as SectionId : "keys"); setLandOnRow(row ?? null); setShow(true); }, []); return ( {children} {show && ( setShow(false)} initialSection={landOn} initialRow={landOnRow} /> )} ); } export function useSettingsDialog() { return useContext(DialogContext); } function SettingsDialog({ onClose, initialSection, initialRow, }: { onClose: () => void; initialSection: SectionId; /** The keys-section row to unfold (see DialogContext above). */ initialRow: string | null; }) { const backdropDismiss = useBackdropDismiss(onClose); // Plain dim backdrop (no blur — nothing decorative); a genuine // backdrop 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 = "keys", initialRow = null, }: { onClose: () => void; initialSection?: SectionId; /** The keys row to unfold on arrival (null = none). While-open * retargets follow it exactly like initialSection below. */ initialRow?: string | null; }) { const { t, general, saveGeneral } = useI18n(); 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); // A deep-link fired while the dialog is ALREADY open (first exercised by // the wave-6 embedding picker's "Add a provider key first…" link — the // picker itself died with wave 7, the mechanism is general) changes // initialSection under our feet — follow it, or the link silently does // nothing. useEffect(() => { setActive(initialSection); }, [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") { // THE ONE-ESC LAW (owner, keyboard round 2026-10-06): claim // at the document level — the window chain (dictation → // abort) must not also act. One Esc, one action. e.preventDefault(); e.stopPropagation(); onClose(); } }; document.addEventListener("keydown", onKey); return () => document.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" | "compaction", body: object, ) => { setError(null); if (section === "general") { try { await saveGeneral(body); } catch (error) { setError(error instanceof Error ? error.message : "Could not save language settings"); } return; } 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, saveGeneral, t], ); return (
e.stopPropagation()} > {/* Header — the app's quiet CMU title language. */}

{t("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 && (

{t(error)}

)} {record === undefined ? (

{t("Loading settings…")}

) : record === null ? (

{t("Settings could not be loaded — refresh to retry.")}

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