2026-09-19 21:00:23 +03:00
"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 ,
2026-09-22 22:45:11 +03:00
useRef ,
2026-09-19 21:00:23 +03:00
useState ,
} from "react" ;
import { createPortal } from "react-dom" ;
2026-10-17 00:38:57 +03:00
import { useBackdropDismiss } from "@/components/backdrop-dismiss" ;
2026-09-19 21:00:23 +03:00
import {
CURRENT_SETTINGS_VERSION ,
type AgentSettings ,
type AppearanceSettings ,
2026-10-05 22:51:08 +03:00
type CompactionSettings ,
2026-09-19 21:00:23 +03:00
type GraphSettings ,
type UserSettings ,
} from "@/src/core" ;
2026-10-26 02:14:27 +03:00
import { LanguageSelect , SttPreferredLanguages , useI18n } from "@/components/i18n" ;
2026-09-19 21:00:23 +03:00
import { useAppearance } from "./appearance.tsx" ;
import { SettingsSection , SettingsRow , Toggle } from "./controls.tsx" ;
import { AgentSection } from "./agent-section.tsx" ;
2026-09-22 13:54:28 +03:00
import type { ModelsSettings } from "@/src/core" ;
2026-09-22 21:55:18 +03:00
import { MENU_CLOSE_EVENT , MENU_OPEN_EVENT } from "@/components/menu-shell" ;
import { notifySettingsSaved } from "@/components/settings-signal" ;
2026-09-19 22:49:52 +03:00
import { KeysSection } from "./keys-section.tsx" ;
2026-09-25 01:52:20 +03:00
import { Hint } from "@/components/ui/hint" ;
2026-09-19 21:00:23 +03:00
import { AppearanceSection , GraphSection } from "./appearance-section.tsx" ;
import { KeyboardSection } from "./keyboard-section.tsx" ;
2026-10-24 14:02:54 +03:00
import { ChatGPTUIProvider } from "./chatgpt-connection.tsx" ;
2026-10-25 00:27:52 +03:00
import { NativeSubscriptionUIProvider } from "./subscription-connections.tsx" ;
2026-09-19 21:00:23 +03:00
2026-09-19 21:59:30 +03:00
/** 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). */
2026-09-19 21:00:23 +03:00
const SECTIONS = [
2026-10-24 15:28:26 +03:00
{ id : "keys" , label : "Providers" },
2026-09-19 21:59:30 +03:00
{ id : "agent" , label : "Agent" },
{ id : "appearance" , label : "Appearance" },
{ id : "graph" , label : "Graph" },
{ id : "keyboard" , label : "Keyboard" , hint : "Stored on this device" },
2026-10-24 15:28:26 +03:00
{ id : "general" , label : "General" },
2026-09-19 21:00:23 +03:00
] as const ;
type SectionId = ( typeof SECTIONS )[ number ][ "id" ];
2026-09-24 23:20:59 +03:00
/** `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 : () => {} });
2026-09-19 21:00:23 +03:00
2026-09-22 13:54:28 +03:00
/** 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). */
2026-09-25 01:52:20 +03:00
/** 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" ;
2026-09-19 21:00:23 +03:00
export function SettingsDialogProvider ({
children ,
} : {
children : React.ReactNode ;
}) {
const [ show , setShow ] = useState ( false );
2026-10-24 14:02:54 +03:00
const [ landOn , setLandOn ] = useState < SectionId >( "keys" );
2026-09-24 23:20:59 +03:00
const [ landOnRow , setLandOnRow ] = useState < string | null >( null );
const open = useCallback (( section? : string , row? : string ) => {
2026-10-24 15:28:26 +03:00
setLandOn ( section ? section as SectionId : "keys" );
2026-09-24 23:20:59 +03:00
setLandOnRow ( row ?? null );
2026-09-22 13:54:28 +03:00
setShow ( true );
}, []);
2026-09-19 21:00:23 +03:00
return (
2026-10-25 00:27:52 +03:00
< ChatGPTUIProvider >< NativeSubscriptionUIProvider >< DialogContext.Provider value = {{ open }}>
2026-09-19 21:00:23 +03:00
{ children }
2026-09-24 23:20:59 +03:00
{ show && (
< SettingsDialog onClose = {() => setShow ( false )} initialSection = { landOn } initialRow = { landOnRow } />
)}
2026-10-25 00:27:52 +03:00
</ DialogContext.Provider ></ NativeSubscriptionUIProvider ></ ChatGPTUIProvider >
2026-09-19 21:00:23 +03:00
);
}
export function useSettingsDialog() {
return useContext ( DialogContext );
}
2026-09-24 23:20:59 +03:00
function SettingsDialog ({
onClose ,
initialSection ,
initialRow ,
} : {
onClose : () => void ;
initialSection : SectionId ;
/** The keys-section row to unfold (see DialogContext above). */
initialRow : string | null ;
}) {
2026-10-17 00:38:57 +03:00
const backdropDismiss = useBackdropDismiss ( onClose );
// Plain dim backdrop (no blur — nothing decorative); a genuine
// backdrop click closes, the dialog body stops propagation.
2026-09-19 21:00:23 +03:00
return createPortal (
< div
className = "fixed inset-0 z-50 flex items-center justify-center bg-background/80"
data-testid = "settings-backdrop"
2026-10-17 00:38:57 +03:00
{ ...backdropDismiss }
2026-09-19 21:00:23 +03:00
>
2026-09-24 23:20:59 +03:00
< SettingsDialogBody onClose = { onClose } initialSection = { initialSection } initialRow = { initialRow } />
2026-09-19 21:00:23 +03:00
</ div >,
document . body ,
);
}
/** The dialog without the portal/backdrop — the SSR-testable frame
* (portals render nothing under renderToStaticMarkup). */
2026-09-22 13:54:28 +03:00
export function SettingsDialogBody ({
onClose ,
2026-10-24 14:02:54 +03:00
initialSection = "keys" ,
2026-09-24 23:20:59 +03:00
initialRow = null ,
2026-09-22 13:54:28 +03:00
} : {
onClose : () => void ;
initialSection? : SectionId ;
2026-09-24 23:20:59 +03:00
/** The keys row to unfold on arrival (null = none). While-open
* retargets follow it exactly like initialSection below. */
initialRow? : string | null ;
2026-09-22 13:54:28 +03:00
}) {
2026-10-25 17:18:44 +03:00
const { t , general , saveGeneral } = useI18n ();
2026-09-19 21:00:23 +03:00
const { onAppearanceChange } = useAppearance ();
const [ record , setRecord ] = useState < UserSettings | null | undefined >(
undefined ,
);
const [ error , setError ] = useState < string | null >( null );
2026-09-22 21:55:18 +03:00
// MenuShell open-count — drives the content column's scroll lock.
const [ openMenus , setOpenMenus ] = useState ( 0 );
2026-09-22 22:45:11 +03:00
const contentRef = useRef < HTMLDivElement | null >( 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 ]);
2026-09-22 21:55:18 +03:00
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 );
};
}, []);
2026-09-22 13:54:28 +03:00
const [ active , setActive ] = useState < SectionId >( initialSection );
2026-09-23 15:02:55 +03:00
// 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.
2026-09-23 12:50:59 +03:00
useEffect (() => {
setActive ( initialSection );
}, [ initialSection ]);
2026-09-19 21:00:23 +03:00
// 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 ) => {
2026-10-07 00:08:49 +03:00
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 ();
}
2026-09-19 21:00:23 +03:00
};
2026-10-07 00:08:49 +03:00
document . addEventListener ( "keydown" , onKey );
return () => document . removeEventListener ( "keydown" , onKey );
2026-09-19 21:00:23 +03:00
}, [ 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 (
2026-10-05 22:51:08 +03:00
section : "general" | "agent" | "models" | "appearance" | "graph" | "compaction" ,
2026-09-19 21:00:23 +03:00
body : object ,
) => {
setError ( null );
2026-10-25 17:18:44 +03:00
if ( section === "general" ) {
try { await saveGeneral ( body ); } catch ( error ) { setError ( error instanceof Error ? error . message : "Could not save language settings" ); }
return ;
}
2026-09-19 21:00:23 +03:00
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 ;
}
2026-09-22 22:45:11 +03:00
// 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 ;
2026-09-19 21:00:23 +03:00
setRecord (
2026-09-22 22:45:11 +03:00
( prev ) =>
stored ?? ({ ...( prev ?? {}), [ section ] : body }) as UserSettings ,
2026-09-19 21:00:23 +03:00
);
2026-09-22 21:55:18 +03:00
notifySettingsSaved ( section );
2026-09-19 21:00:23 +03:00
if ( section === "appearance" )
onAppearanceChange ( body as AppearanceSettings );
},
2026-10-25 17:18:44 +03:00
[ onAppearanceChange , saveGeneral , t ],
2026-09-19 21:00:23 +03:00
);
return (
< div
role = "dialog"
aria-modal = "true"
2026-10-25 17:18:44 +03:00
aria-label = { t ( "Settings" )}
2026-09-19 21:00:23 +03:00
data-testid = "settings-dialog"
className = "settings-dialog-pop flex h-[620px] max-h-[85vh] w-[920px] max-w-[90vw] flex-col overflow-hidden rounded-md border border-border bg-card shadow-xl"
onClick = {( e ) => e . stopPropagation ()}
>
{ /* Header — the app's quiet CMU title language. */ }
< header className = "flex h-15 shrink-0 items-center gap-3 border-b border-border px-5" >
2026-10-25 17:18:44 +03:00
< h2 className = "font-title text-2xl tracking-tight" >{ t ( "Settings" )}</ h2 >
2026-09-25 01:52:20 +03:00
< Hint
// Esc closes the dialog (its own listener) — surface it.
2026-10-25 17:18:44 +03:00
label = { t ( "Close" )}
2026-09-25 01:52:20 +03:00
binding = "escape"
side = "left"
>
< button
2026-09-19 21:00:23 +03:00
type = "button"
2026-10-25 17:18:44 +03:00
aria-label = { t ( "Close settings" )}
2026-09-19 21:00:23 +03:00
data-testid = "settings-close"
className = "ml-auto size-7 rounded-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick = { onClose }
>
< svg
viewBox = "0 0 16 16"
className = "mx-auto size-3.5"
fill = "none"
stroke = "currentColor"
strokeWidth = "1.5"
>
< path d = "M4 4l8 8M12 4l-8 8" />
</ svg >
</ button >
2026-09-25 01:52:20 +03:00
</ Hint >
2026-09-19 21:00:23 +03:00
</ header >
< div className = "flex min-h-0 flex-1" >
{ /* Left nav — text-only, the session-list mono language,
active = bg-accent. */ }
< nav
2026-10-25 17:18:44 +03:00
aria-label = { t ( "Settings sections" )}
2026-09-19 21:00:23 +03:00
data-testid = "settings-nav"
2026-09-19 22:28:57 +03:00
className = "flex w-44 shrink-0 flex-col gap-1 border-r border-border p-2"
2026-09-19 21:00:23 +03:00
>
{ SECTIONS . map (( s ) => (
< button
key = { s . id }
type = "button"
data-testid = { `settings-nav- ${ s . id } ` }
aria-current = { active === s . id }
className = { `rounded-sm px-3 py-1.5 text-left font-ui text-sm transition-colors ${
active === s . id
? "bg-accent text-foreground"
: "text-muted-foreground hover:bg-accent hover:text-foreground"
} ` }
onClick = {() => setActive ( s . id )}
>
2026-10-25 17:18:44 +03:00
{ t ( s . label )}
2026-09-19 21:00:23 +03:00
</ button >
))}
</ nav >
2026-09-22 21:55:18 +03:00
{ /* 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
2026-09-22 22:45:11 +03:00
open/close, the count keeps overlap honest). The lock
intercepts wheel/touch instead of hiding the scrollbar —
nothing may shift when the lock engages. */ }
2026-09-19 21:00:23 +03:00
< div
2026-09-22 22:45:11 +03:00
ref = { contentRef }
className = "min-w-0 flex-1 overflow-y-auto"
2026-09-19 21:00:23 +03:00
data-testid = "settings-content"
>
{ error && (
< p
role = "alert"
className = "m-4 rounded-sm border border-red-500/40 px-3 py-2 text-xs text-red-500"
data-testid = "settings-error"
>
2026-10-25 17:18:44 +03:00
{ t ( error )}
2026-09-19 21:00:23 +03:00
</ p >
)}
{ record === undefined ? (
< p className = "px-6 py-5 text-xs text-muted-foreground/70" >
2026-10-25 17:18:44 +03:00
{ t ( "Loading settings…" )}</ p >
2026-09-19 21:00:23 +03:00
) : record === null ? (
< p className = "px-6 py-5 text-xs text-muted-foreground/70" >
2026-10-25 17:18:44 +03:00
{ t ( "Settings could not be loaded — refresh to retry." )}</ p >
2026-09-19 21:00:23 +03:00
) : (
< div >
{ active === "general" && (
< SettingsSection
2026-10-25 17:18:44 +03:00
title = { t ( "General" )}
2026-09-19 21:00:23 +03:00
testid = "section-general"
>
2026-10-25 17:18:44 +03:00
< SettingsRow label = { t ( "Interface language" )} testid = "row-language" >< LanguageSelect field = "language" /></ SettingsRow >
< SettingsRow label = { t ( "Speech recognition language" )} testid = "row-stt-language" >< LanguageSelect field = "sttLanguage" /></ SettingsRow >
2026-10-26 02:14:27 +03:00
< SettingsRow label = { t ( "Preferred languages" )} hint = { t ( "Used with Multilingual. Favors selected languages; other languages may still be recognized." )} testid = "row-stt-preferred-languages" className = "flex-col items-start gap-2" >
< SttPreferredLanguages />
</ SettingsRow >
2026-09-19 21:00:23 +03:00
< SettingsRow
2026-10-25 17:18:44 +03:00
label = { t ( "Auto-update" )}
2026-09-25 01:52:20 +03:00
hint = { AUTO_UPDATE_HINT }
2026-09-19 21:00:23 +03:00
testid = "row-auto-update"
>
< Toggle
2026-10-25 17:18:44 +03:00
checked = { general . autoUpdate ?? true }
label = { t ( "auto update" )}
2026-09-19 21:00:23 +03:00
onChange = {( on ) =>
void write ( "general" , { autoUpdate : on })
}
/>
</ SettingsRow >
</ SettingsSection >
)}
2026-09-19 22:49:52 +03:00
{ active === "keys" && (
2026-10-25 17:18:44 +03:00
< SettingsSection title = { t ( "Providers" )} testid = "section-keys" >
2026-09-24 23:20:59 +03:00
< KeysSection initialRow = { initialRow ?? undefined } />
2026-09-19 22:49:52 +03:00
</ SettingsSection >
)}
2026-09-19 21:00:23 +03:00
{ active === "agent" && (
< SettingsSection
2026-10-25 17:18:44 +03:00
title = { t ( "Agent" )}
2026-09-19 21:00:23 +03:00
testid = "section-agent"
>
< AgentSection
agent = { record . agent }
2026-09-22 13:54:28 +03:00
models = { record . models }
2026-09-23 15:57:59 +03:00
graph = { record . graph }
2026-10-05 22:51:08 +03:00
compaction = { record . compaction }
2026-09-22 13:54:28 +03:00
onModelsChange = {( next : ModelsSettings ) =>
2026-10-25 21:19:48 +03:00
write ( "models" , next )
2026-09-22 13:54:28 +03:00
}
2026-09-19 21:00:23 +03:00
onSectionChange = {( next : AgentSettings ) =>
void write ( "agent" , next )
}
2026-10-05 22:51:08 +03:00
onCompactionChange = {( next : CompactionSettings ) =>
void write ( "compaction" , next )
}
2026-09-19 21:00:23 +03:00
/>
</ SettingsSection >
)}
{ active === "appearance" && (
< SettingsSection
2026-10-25 17:18:44 +03:00
title = { t ( "Appearance" )}
2026-09-19 21:00:23 +03:00
testid = "section-appearance"
>
< AppearanceSection
appearance = { record . appearance }
onSectionChange = {( next : AppearanceSettings ) =>
void write ( "appearance" , next )
}
/>
</ SettingsSection >
)}
{ active === "graph" && (
< SettingsSection
2026-10-25 17:18:44 +03:00
title = { t ( "Graph" )}
2026-09-19 21:00:23 +03:00
testid = "section-graph"
>
< GraphSection
graph = { record . graph }
onSectionChange = {( next : GraphSettings ) =>
void write ( "graph" , next )
}
/>
</ SettingsSection >
)}
{ active === "keyboard" && (
< SettingsSection
2026-10-25 17:18:44 +03:00
title = { t ( "Keyboard" )}
hint = { t ( "Stored on this device" )}
2026-09-19 21:00:23 +03:00
testid = "section-keyboard"
>
< KeyboardSection />
</ SettingsSection >
)}
</ div >
)}
</ div >
</ div >
</ div >
);
}