Files
learnharness/components/settings/settings-dialog.tsx
T
amosblomqvist fddbacd330 Settings-app refinement round 3 (owner review): toggle polish, nav spacing, portal dropdowns, Default-named hints
- 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.
2026-09-19 22:29:17 +03:00

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>
);
}