"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. */}