- Toggle (owner bug report): the ON disc sat flush against the pill's right edge — translate-x-4.5 left only a 1px sliver; now translate-x-4 for a 2px inner inset both states (off: 3px left inset; on: 3px right inset — measured live, symmetric). - Toggle colors (owner ask): ON is a gray track (muted-foreground) with a WHITE disc (foreground) — the full-white track was too loud; OFF stays the muted track with the gray disc. - Nav spacing (owner ask): gap-1 between the section nav buttons so the hover highlights no longer touch. - Role dropdowns (owner ask): the menus were clipped by the dialog's overflow frame — they now portal to document.body with fixed positioning against their trigger (AnchoredMenu), right-aligned, repositioned on scroll/resize, so they flow over the whole screen outside the settings dialog. Verified: a bottom-row menu extends 137px past the dialog's bottom edge; search + pick + Default-clear work through the portal; Esc and inside-dialog clicks close the menu but not the dialog. - Thinking trigger (owner ask): fixed w-20 — the width the Default state needs — so picking levels no longer shifts the row; the model trigger takes the same width as a minimum (its values vary in length). - Role hints (owner ask): ALWAYS visible and naming the actual default — 'Default: <model>'. The resolved per-role pins are not client-side yet: master routed rolePins to the Platform stream (GET /api/models extension, standalone commit); until that merge the rows read the 'config pin' fallback and light up on rebase. rolePins also lets the thinking list use the pinned model's real supportedLevels when it's in the catalog. Also: staging repair commit 270db53 — the round-1 dialog rewiring (sidebar row, chord, redirect, keyframes) was listed in 27d4f33's message but never staged; committed as catch-up. Live-verified on the worktree dev server. Gates: typecheck, lint, 701/701.
315 lines
9.6 KiB
TypeScript
315 lines
9.6 KiB
TypeScript
"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 (
|
|
<DialogContext.Provider value={{ open }}>
|
|
{children}
|
|
{show && <SettingsDialog onClose={() => setShow(false)} />}
|
|
</DialogContext.Provider>
|
|
);
|
|
}
|
|
|
|
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(
|
|
<div
|
|
className="fixed inset-0 z-50 flex items-center justify-center bg-background/80"
|
|
data-testid="settings-backdrop"
|
|
onClick={onClose}
|
|
>
|
|
<SettingsDialogBody onClose={onClose} />
|
|
</div>,
|
|
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<UserSettings | null | undefined>(
|
|
undefined,
|
|
);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [active, setActive] = useState<SectionId>("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 (
|
|
<div
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label="Settings"
|
|
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">
|
|
<h2 className="font-title text-2xl tracking-tight">Settings</h2>
|
|
<button
|
|
type="button"
|
|
aria-label="Close settings"
|
|
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>
|
|
</header>
|
|
|
|
<div className="flex min-h-0 flex-1">
|
|
{/* Left nav — text-only, the session-list mono language,
|
|
active = bg-accent. */}
|
|
<nav
|
|
aria-label="Settings sections"
|
|
data-testid="settings-nav"
|
|
className="flex w-44 shrink-0 flex-col gap-1 border-r border-border p-2"
|
|
>
|
|
{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)}
|
|
>
|
|
{s.label}
|
|
</button>
|
|
))}
|
|
</nav>
|
|
|
|
{/* Content column — one section at a time, this scrolls. */}
|
|
<div
|
|
className="min-w-0 flex-1 overflow-y-auto"
|
|
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"
|
|
>
|
|
{error}
|
|
</p>
|
|
)}
|
|
{record === undefined ? (
|
|
<p className="px-6 py-5 text-xs text-muted-foreground/70">
|
|
Loading settings…
|
|
</p>
|
|
) : record === null ? (
|
|
<p className="px-6 py-5 text-xs text-muted-foreground/70">
|
|
Settings could not be loaded — refresh to retry.
|
|
</p>
|
|
) : (
|
|
<div>
|
|
{active === "general" && (
|
|
<SettingsSection
|
|
title="General"
|
|
testid="section-general"
|
|
>
|
|
<SettingsRow
|
|
label="Auto-update"
|
|
hint="The update mechanism ships in a later wave"
|
|
testid="row-auto-update"
|
|
>
|
|
<Toggle
|
|
checked={record.general?.autoUpdate ?? true}
|
|
label="auto update"
|
|
onChange={(on) =>
|
|
void write("general", { autoUpdate: on })
|
|
}
|
|
/>
|
|
</SettingsRow>
|
|
</SettingsSection>
|
|
)}
|
|
{active === "agent" && (
|
|
<SettingsSection
|
|
title="Agent"
|
|
testid="section-agent"
|
|
>
|
|
<AgentSection
|
|
agent={record.agent}
|
|
onSectionChange={(next: AgentSettings) =>
|
|
void write("agent", next)
|
|
}
|
|
/>
|
|
</SettingsSection>
|
|
)}
|
|
{active === "appearance" && (
|
|
<SettingsSection
|
|
title="Appearance"
|
|
testid="section-appearance"
|
|
>
|
|
<AppearanceSection
|
|
appearance={record.appearance}
|
|
onSectionChange={(next: AppearanceSettings) =>
|
|
void write("appearance", next)
|
|
}
|
|
/>
|
|
</SettingsSection>
|
|
)}
|
|
{active === "graph" && (
|
|
<SettingsSection
|
|
title="Graph"
|
|
testid="section-graph"
|
|
>
|
|
<GraphSection
|
|
graph={record.graph}
|
|
onSectionChange={(next: GraphSettings) =>
|
|
void write("graph", next)
|
|
}
|
|
/>
|
|
</SettingsSection>
|
|
)}
|
|
{active === "keyboard" && (
|
|
<SettingsSection
|
|
title="Keyboard"
|
|
hint="Stored on this device"
|
|
testid="section-keyboard"
|
|
>
|
|
<KeyboardSection />
|
|
</SettingsSection>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|