Over Platform's 66dbea9 key-presence seam, the five owner-locked tasks: 1. Zero-keys pill — components/setup-cta.tsx: pure hasProviderKey (classifyKeys reuse — tool keys like Deepgram never count), useZeroKeys (GET /api/keys, re-read on the new lh:settings-saved 'keys' signal), ZeroKeysPill (testid setup-cta-keys) taking PRECEDENCE over the models CTA on chat/home/new-session. SettingsSection gains 'keys'; keys-section dispatches after save AND delete. 2. Setup stage 1 is self-contained: provider picker (MenuShell + ProviderMenuList) + key paste + Save via PUT /api/keys/:id — the dialog STAYS MOUNTED; its own save dispatches the keys signal, the fetch effect re-runs on it, and the stage flips to the pickers on its own. onOpenKeys + setup-open-keys die. SetupKeysBody exported for SSR pins. 3. parseProviderKeyRefusal (chat.tsx, envelope-verbatim twin of parseModelsRefusal) joins the revert-draft-and-open-setup interception; activity-dock reads 'needs a provider key'; dispatch-card shows 'Add a key' — both deep-link setup, which lands on stage 1 by itself. 4. Dictation trio: the token route's only 503 (missing Deepgram key) becomes the client-authored DICTATION_KEYLESS short line; the errored idle click clears the error and deep-links Settings -> Keys -> DEEPGRAM_API_KEY via the new open(section, row) seam (dialog landOnRow -> SettingsDialogBody initialRow -> KeysSection openRow follow-effect). Live-verified on :3001 under LH_IGNORE_ENV_KEYS=1 (the fresh-user switch): pill on all three surfaces + precedence over the CTA, stage-1 save -> auto-advance with dialog mounted, real 503 send refusal reverting the draft and opening setup, dictation 503 -> short line -> Settings lands on the unfolded Deepgram row with the error cleared, keys-section save and delete both flipping the pill live, and a fabricated refused research job rendering the 'Add a key' dispatch card. 926/926, tsc/lint/build green.
421 lines
14 KiB
TypeScript
421 lines
14 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,
|
|
useRef,
|
|
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 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 { 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: "keys", label: "Keys" },
|
|
{ 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"];
|
|
|
|
/** `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). */
|
|
export function SettingsDialogProvider({
|
|
children,
|
|
}: {
|
|
children: React.ReactNode;
|
|
}) {
|
|
const [show, setShow] = useState(false);
|
|
const [landOn, setLandOn] = useState<SectionId>("general");
|
|
const [landOnRow, setLandOnRow] = useState<string | null>(null);
|
|
const open = useCallback((section?: string, row?: string) => {
|
|
if (section) setLandOn(section as SectionId);
|
|
setLandOnRow(row ?? null);
|
|
setShow(true);
|
|
}, []);
|
|
return (
|
|
<DialogContext.Provider value={{ open }}>
|
|
{children}
|
|
{show && (
|
|
<SettingsDialog onClose={() => setShow(false)} initialSection={landOn} initialRow={landOnRow} />
|
|
)}
|
|
</DialogContext.Provider>
|
|
);
|
|
}
|
|
|
|
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;
|
|
}) {
|
|
// 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} initialSection={initialSection} initialRow={initialRow} />
|
|
</div>,
|
|
document.body,
|
|
);
|
|
}
|
|
|
|
/** The dialog without the portal/backdrop — the SSR-testable frame
|
|
* (portals render nothing under renderToStaticMarkup). */
|
|
export function SettingsDialogBody({
|
|
onClose,
|
|
initialSection = "general",
|
|
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 { onAppearanceChange } = useAppearance();
|
|
const [record, setRecord] = useState<UserSettings | null | undefined>(
|
|
undefined,
|
|
);
|
|
const [error, setError] = useState<string | null>(null);
|
|
// MenuShell open-count — drives the content column's scroll lock.
|
|
const [openMenus, setOpenMenus] = useState(0);
|
|
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]);
|
|
|
|
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<SectionId>(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") 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" | "models" | "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;
|
|
}
|
|
// 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],
|
|
);
|
|
|
|
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 — 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. */}
|
|
<div
|
|
ref={contentRef}
|
|
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 === "keys" && (
|
|
<SettingsSection title="Keys" testid="section-keys">
|
|
<KeysSection initialRow={initialRow ?? undefined} />
|
|
</SettingsSection>
|
|
)}
|
|
{active === "agent" && (
|
|
<SettingsSection
|
|
title="Agent"
|
|
testid="section-agent"
|
|
>
|
|
<AgentSection
|
|
agent={record.agent}
|
|
models={record.models}
|
|
graph={record.graph}
|
|
onModelsChange={(next: ModelsSettings) =>
|
|
void write("models", next)
|
|
}
|
|
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>
|
|
);
|
|
}
|