Thin working settings UI (owner-approved plan): /settings + live appearance + keyboard rebinding
The validation surface for the settings foundation — deliberately thin,
real read/write against the delta record, polish is a later wave.
Mount (owner decisions): dedicated page, single scroll; entry = sidebar
bottom row + ⌘/Ctrl+, (open-settings in the registry); chat defaults NOT
shown (they live on /chat/new); mode dark-only (light palette = design
wave); final tool list = research/demo/visual/pdf (master-adjudicated).
Sections (PUT /api/settings/[section], replace-whole per section):
- General: auto-update toggle.
- Agent: six background-role model+thinking selects (Default = config
pin; a stored id stays VISIBLE even when the catalog hasn't loaded or
dropped it — a select showing Default over a live override would lie),
the four tool toggles, prompt override switches (file-gated) with
source/override text editors over the segment routes.
- Appearance: applied LIVE as runtime CSS variables by an
AppearanceProvider at the root layout — proseFontSize → the one
reading-size var, fontFamily → --font-prose, background → --background
+ the derived raised/accent/edge palette (dark curve, today's exact
spacing: +8/+22/+47 per channel, clamped), sessionNameSerif →
--font-title (titles flip with ZERO chat.tsx edits), headingWeight →
new --lh-heading-weight token, bold/italic colors → the lh-ink vars.
Mode renders honestly DISABLED.
- Graph: the ladder grays a rung when any ANCESTOR is effectively off
(stored values persist, resolve inert — switch shows stored, graying
shows effective).
- Keyboard: view/rebind/reset over the registry; overrides persist in
lh:keymap and apply INSTANTLY (dispatcher reads per keydown); bare
modifier presses are ignored while armed; displayOnly actions list but
never rebind.
First-boot fix: {settings: null} is no-record, not fetch-failure.
CROSS-LANE (flagged for master): src/core/keyboard.ts gained the
open-settings action/binding and exports saveOverrides/loadOverrides
(one-liners; no existing pins); app/layout.tsx mounts the provider.
Live-verified: structure, record writes (general/tools/graph/appearance),
prose-size applying live (16→20px), rebind + chord navigation from
another page, ladder graying, no console errors. Gates: typecheck,
lint, 689/689 (28 new), build.
This commit is contained in:
@@ -0,0 +1,309 @@
|
||||
"use client";
|
||||
/**
|
||||
* The Agent section (thin settings wave): background-role model overrides,
|
||||
* the tool toggles, and the prompt override switches — three blocks, ONE
|
||||
* section (PUT /api/settings/agent replaces the whole agent object, so
|
||||
* every change composes models+tools+prompts together from the record).
|
||||
*
|
||||
* Tools — the owner's final list (2026-09-19): research, demo, visual, pdf.
|
||||
* Absent key = enabled in the schema; the UI writes explicit booleans for
|
||||
* all four (simpler to reason about, sanitize-equivalent).
|
||||
*
|
||||
* Models: each of the six background roles gets a Default option (absent
|
||||
* override = config.ts's pin) plus the /api/models catalog. The thinking
|
||||
* select shows the CHOSEN model's supported levels when a model override
|
||||
* exists; without one the factory model is unknown client-side, so the
|
||||
* standard levels list (the server sanitizer accepts any valid level).
|
||||
*
|
||||
* Prompts: GET /api/settings/prompts segments; the switch requires an
|
||||
* override FILE (route-enforced), the text writes through the segment
|
||||
* route. Switch state lives in this section's PUT; text does not.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
BACKGROUND_MODEL_ROLES,
|
||||
type AgentSettings,
|
||||
type BackgroundModelRole,
|
||||
} from "@/src/core";
|
||||
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
||||
import type { ModelCatalogEntry } from "@/src/core";
|
||||
import { SettingsRow, ThinSelect, Toggle } from "./controls.tsx";
|
||||
|
||||
/** The role's display label — schema ids are camelCase, the label is human. */
|
||||
const ROLE_LABEL: Record<BackgroundModelRole, string> = {
|
||||
researcher: "Researcher",
|
||||
demoBuilder: "Demo builder",
|
||||
visualMaker: "Visual maker",
|
||||
extractor: "Extractor",
|
||||
auditor: "Auditor",
|
||||
labeler: "Labeler",
|
||||
};
|
||||
|
||||
/** The owner's final toggleable-tool list (confirm with the user before
|
||||
* changing — the Teacher stream settled this 2026-09-19). */
|
||||
export const TOGGLE_TOOLS = ["research", "demo", "visual", "pdf"] as const;
|
||||
|
||||
const THINKING_LEVELS: readonly ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
|
||||
|
||||
/** SegmentInfo as GET /api/settings/prompts returns it. */
|
||||
interface SegmentInfo {
|
||||
path: string;
|
||||
sourceText: string;
|
||||
overrideText: string | null;
|
||||
enabled: boolean;
|
||||
shared: boolean;
|
||||
}
|
||||
|
||||
/** Compose: one tool toggled, the rest of the section carried verbatim.
|
||||
* Pure — exported for tests. */
|
||||
export function composeTool(agent: AgentSettings | undefined, id: string, on: boolean): AgentSettings {
|
||||
return { ...agent, tools: { ...agent?.tools, [id]: on } };
|
||||
}
|
||||
|
||||
/** Compose: one role's model/thinking override. Empty model = absent
|
||||
* override (Default); empty thinking = thinking falls back. Pure — tests. */
|
||||
export function composeRoleModel(
|
||||
agent: AgentSettings | undefined,
|
||||
role: BackgroundModelRole,
|
||||
modelId: string,
|
||||
thinking: string,
|
||||
): AgentSettings {
|
||||
const prev = agent?.models?.[role];
|
||||
const models = { ...agent?.models };
|
||||
if (!modelId && !thinking) delete models[role];
|
||||
else {
|
||||
models[role] = {
|
||||
...(modelId ? { model: { provider: "openrouter", id: modelId } } : {}),
|
||||
...(thinking ? { thinkingLevel: thinking as ThinkingLevel } : {}),
|
||||
};
|
||||
}
|
||||
return { ...agent, models };
|
||||
}
|
||||
|
||||
/** Compose: one prompt switch. on = explicit true; off = key REMOVED
|
||||
* (absent = off is the schema's resting state; explicit false is the
|
||||
* same thing but noisier). Pure — tests. */
|
||||
export function composePromptSwitch(agent: AgentSettings | undefined, path: string, on: boolean): AgentSettings {
|
||||
const prompts = { ...agent?.prompts };
|
||||
if (on) prompts[path] = true;
|
||||
else delete prompts[path];
|
||||
return { ...agent, prompts };
|
||||
}
|
||||
|
||||
export function AgentSection({
|
||||
agent,
|
||||
onSectionChange,
|
||||
}: {
|
||||
agent: AgentSettings | undefined;
|
||||
/** Writes the composed WHOLE agent section (replace-PUT discipline). */
|
||||
onSectionChange: (next: AgentSettings) => void;
|
||||
}) {
|
||||
const [catalog, setCatalog] = useState<ModelCatalogEntry[] | null>(null);
|
||||
const [prompts, setPrompts] = useState<{ prober: SegmentInfo[]; teacher: SegmentInfo[] } | null>(null);
|
||||
const [openSegment, setOpenSegment] = useState<string | null>(null);
|
||||
const [segmentDraft, setSegmentDraft] = useState<Record<string, string>>({});
|
||||
const [segmentStatus, setSegmentStatus] = useState<string | null>(null);
|
||||
|
||||
// The catalog + the prompt segments — one fetch each per page load.
|
||||
useEffect(() => {
|
||||
fetch("/api/models")
|
||||
.then((r) => r.json())
|
||||
.then((body: { models: ModelCatalogEntry[] }) => setCatalog(body.models))
|
||||
.catch(() => setCatalog(null));
|
||||
fetch("/api/settings/prompts")
|
||||
.then((r) => r.json())
|
||||
.then((body: { prober: SegmentInfo[]; teacher: SegmentInfo[] }) => setPrompts(body))
|
||||
.catch(() => setPrompts(null));
|
||||
}, []);
|
||||
|
||||
async function saveSegment(path: string, text: string) {
|
||||
setSegmentStatus(null);
|
||||
const res = await fetch(`/api/settings/prompts/${path}`, {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ text }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
setSegmentStatus(`Save failed: ${(await res.json().catch(() => ({}))).error ?? res.status}`);
|
||||
return;
|
||||
}
|
||||
// Refetch: file presence gates the switch, so the view must track it.
|
||||
fetch("/api/settings/prompts")
|
||||
.then((r) => r.json())
|
||||
.then((body: { prober: SegmentInfo[]; teacher: SegmentInfo[] }) => setPrompts(body));
|
||||
setSegmentStatus(`Saved ${path}`);
|
||||
}
|
||||
|
||||
async function deleteSegment(path: string) {
|
||||
setSegmentStatus(null);
|
||||
const res = await fetch(`/api/settings/prompts/${path}`, { method: "DELETE" });
|
||||
if (!res.ok) {
|
||||
setSegmentStatus(`Delete failed: ${res.status}`);
|
||||
return;
|
||||
}
|
||||
fetch("/api/settings/prompts")
|
||||
.then((r) => r.json())
|
||||
.then((body: { prober: SegmentInfo[]; teacher: SegmentInfo[] }) => setPrompts(body));
|
||||
}
|
||||
|
||||
const segmentRow = (info: SegmentInfo) => {
|
||||
const fileExists = info.overrideText !== null;
|
||||
const open = openSegment === info.path;
|
||||
return (
|
||||
<div key={info.path} data-testid={`prompt-segment-${info.path}`}>
|
||||
<SettingsRow
|
||||
label={info.path}
|
||||
hint={fileExists ? undefined : "no override file — write one below to enable the switch"}
|
||||
testid={`prompt-row-${info.path}`}
|
||||
>
|
||||
<Toggle
|
||||
checked={info.enabled && fileExists}
|
||||
disabled={!fileExists}
|
||||
label={`prompt ${info.path}`}
|
||||
onChange={(on) => onSectionChange(composePromptSwitch(agent, info.path, on))}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setOpenSegment(open ? null : info.path);
|
||||
setSegmentStatus(null);
|
||||
}}
|
||||
className="text-xs text-muted-foreground/70 underline-offset-2 hover:text-foreground hover:underline"
|
||||
>
|
||||
{open ? "Hide text" : "Show text"}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="mt-1 flex flex-col gap-2">
|
||||
{info.shared && (
|
||||
<p className="text-xs text-muted-foreground/70">Shared base — appears in both agents.</p>
|
||||
)}
|
||||
<div>
|
||||
<p className="mb-1 text-xs uppercase tracking-widest text-muted-foreground/70">Source</p>
|
||||
<textarea
|
||||
readOnly
|
||||
value={info.sourceText}
|
||||
aria-label={`source ${info.path}`}
|
||||
className="h-24 w-full resize-y rounded-sm border border-border bg-muted p-2 font-ui text-xs text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1 text-xs uppercase tracking-widest text-muted-foreground/70">Override</p>
|
||||
<textarea
|
||||
value={segmentDraft[info.path] ?? info.overrideText ?? ""}
|
||||
onChange={(e) => setSegmentDraft({ ...segmentDraft, [info.path]: e.target.value })}
|
||||
aria-label={`override ${info.path}`}
|
||||
placeholder="Write the override text…"
|
||||
className="h-24 w-full resize-y rounded-sm border border-border bg-background p-2 font-ui text-xs text-foreground outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void saveSegment(info.path, segmentDraft[info.path] ?? "")}
|
||||
className="text-xs text-foreground underline-offset-2 hover:underline"
|
||||
>
|
||||
Save override
|
||||
</button>
|
||||
{fileExists && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void deleteSegment(info.path)}
|
||||
className="text-xs text-muted-foreground/70 underline-offset-2 hover:text-red-500 hover:underline"
|
||||
>
|
||||
Delete override
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ── Models ── */}
|
||||
{BACKGROUND_MODEL_ROLES.map((role) => {
|
||||
const override = agent?.models?.[role];
|
||||
const modelId = override?.model?.id ?? "";
|
||||
const thinking = override?.thinkingLevel ?? "";
|
||||
const chosen = catalog?.find((m) => m.id === modelId);
|
||||
const levels = chosen ? chosen.supportedLevels : THINKING_LEVELS;
|
||||
return (
|
||||
<SettingsRow
|
||||
key={role}
|
||||
label={ROLE_LABEL[role]}
|
||||
hint={modelId ? undefined : "Default — the config pin applies"}
|
||||
testid={`role-row-${role}`}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<ThinSelect
|
||||
value={modelId}
|
||||
ariaLabel={`${ROLE_LABEL[role]} model`}
|
||||
testid={`role-model-${role}`}
|
||||
onChange={(id) => onSectionChange(composeRoleModel(agent, role, id, thinking))}
|
||||
>
|
||||
<option value="">Default</option>
|
||||
{/* The stored id stays VISIBLE even when the catalog
|
||||
hasn't loaded (SSR) or no longer contains it —
|
||||
a select that silently shows "Default" over a
|
||||
live override would lie about the record. */}
|
||||
{modelId && !catalog?.some((m) => m.id === modelId) && (
|
||||
<option value={modelId}>{modelId}</option>
|
||||
)}
|
||||
{catalog?.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.name}
|
||||
</option>
|
||||
))}
|
||||
</ThinSelect>
|
||||
<ThinSelect
|
||||
value={thinking}
|
||||
ariaLabel={`${ROLE_LABEL[role]} thinking`}
|
||||
testid={`role-thinking-${role}`}
|
||||
onChange={(level) => onSectionChange(composeRoleModel(agent, role, modelId, level))}
|
||||
>
|
||||
<option value="">Default</option>
|
||||
{levels.map((l) => (
|
||||
<option key={l} value={l}>
|
||||
{l}
|
||||
</option>
|
||||
))}
|
||||
</ThinSelect>
|
||||
</span>
|
||||
</SettingsRow>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* ── Tools ── */}
|
||||
{TOGGLE_TOOLS.map((id) => (
|
||||
<SettingsRow key={id} label={`Tool · ${id}`} testid={`tool-row-${id}`}>
|
||||
<Toggle
|
||||
checked={agent?.tools?.[id] ?? true}
|
||||
label={`tool ${id}`}
|
||||
onChange={(on) => onSectionChange(composeTool(agent, id, on))}
|
||||
/>
|
||||
</SettingsRow>
|
||||
))}
|
||||
|
||||
{/* ── Prompts ── */}
|
||||
{prompts === null ? (
|
||||
<p className="text-xs text-muted-foreground/70">Loading prompt segments…</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{(["prober", "teacher"] as const).map((who) => (
|
||||
<div key={who}>
|
||||
<p className="mb-1 text-xs uppercase tracking-widest text-muted-foreground/70">{who}</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
{prompts[who].map((info) => segmentRow(info))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{segmentStatus && <p className="text-xs text-muted-foreground/70">{segmentStatus}</p>}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
"use client";
|
||||
/**
|
||||
* The Appearance + Graph sections (thin settings wave).
|
||||
*
|
||||
* Appearance controls resolve against the factory in the provider; writes
|
||||
* PUT the whole appearance section and hand the delta back so the CSS
|
||||
* variables move IMMEDIATELY (the section's whole point — it must DO
|
||||
* something). Hex inputs commit on blur/Enter; the background derives the
|
||||
* raised/accent/edge palette in code (components/settings/appearance.tsx).
|
||||
* Mode is dark-only this wave: control rendered DISABLED with a note — the
|
||||
* light palette is the design wave's work, and a lying control is worse
|
||||
* than an honest disabled one.
|
||||
*
|
||||
* Graph is the dependency ladder (owner decision, 2026-09-19): a disabled
|
||||
* parent grays its dependents — stored values persist but resolve inert.
|
||||
*/
|
||||
|
||||
import type { AppearanceSettings, GraphSettings } from "@/src/core";
|
||||
import { useAppearance } from "./appearance.tsx";
|
||||
import { CommitInput, SettingsRow, ThinSelect, Toggle } from "./controls.tsx";
|
||||
|
||||
export function AppearanceSection({
|
||||
appearance,
|
||||
onSectionChange,
|
||||
}: {
|
||||
/** The record's appearance delta (absent = factory). */
|
||||
appearance: AppearanceSettings | undefined;
|
||||
/** PUT the whole appearance section; the page re-applies on success. */
|
||||
onSectionChange: (next: AppearanceSettings) => void;
|
||||
}) {
|
||||
const { appearance: resolved } = useAppearance();
|
||||
const md = appearance?.mdRendering;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsRow label="Mode" hint="Light mode ships with the design wave" testid="row-mode">
|
||||
<ThinSelect value="dark" ariaLabel="mode" onChange={() => {}} testid="select-mode" disabled>
|
||||
<option value="dark">Dark</option>
|
||||
</ThinSelect>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
label="Background"
|
||||
hint={`One color; raised/accent/edges derive from it. Empty = the dark default (${resolved.background ?? "#141414"})`}
|
||||
testid="row-background"
|
||||
>
|
||||
<CommitInput
|
||||
value={appearance?.background ?? ""}
|
||||
placeholder="#141414"
|
||||
ariaLabel="background hex"
|
||||
testid="input-background"
|
||||
onCommit={(v) => onSectionChange({ ...appearance, background: v === "" ? null : v })}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
label="Reading font"
|
||||
hint="Font family name, by name — everything that reads today's Source Sans 3. Empty = Source Sans 3"
|
||||
testid="row-font"
|
||||
>
|
||||
<CommitInput
|
||||
value={appearance?.fontFamily ?? ""}
|
||||
placeholder="Source Sans 3"
|
||||
ariaLabel="font family"
|
||||
testid="input-font"
|
||||
onCommit={(v) => onSectionChange({ ...appearance, fontFamily: v === "" ? null : v })}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow label="Reading font size" hint={`${resolved.proseFontSize}px — prose, inputs, session titles scale together`} testid="row-prose-size">
|
||||
<CommitInput
|
||||
type="number"
|
||||
value={String(appearance?.proseFontSize ?? "")}
|
||||
placeholder="16"
|
||||
ariaLabel="prose font size"
|
||||
testid="input-prose-size"
|
||||
onCommit={(v) => {
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n)) return; // bad input: no write, factory stays
|
||||
onSectionChange({ ...appearance, proseFontSize: Math.min(24, Math.max(12, Math.round(n))) });
|
||||
}}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow label="Session names in serif" hint="CMU Serif titles on/off" testid="row-session-serif">
|
||||
<Toggle
|
||||
checked={appearance?.sessionNameSerif ?? true}
|
||||
label="session names serif"
|
||||
onChange={(on) => onSectionChange({ ...appearance, sessionNameSerif: on })}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow label="Heading weight" hint={`${resolved.mdRendering.headingWeight} — CSS resolves the nearest cut`} testid="row-heading-weight">
|
||||
<CommitInput
|
||||
type="number"
|
||||
value={String(md?.headingWeight ?? "")}
|
||||
placeholder="900"
|
||||
ariaLabel="heading weight"
|
||||
testid="input-heading-weight"
|
||||
onCommit={(v) => {
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n)) return;
|
||||
onSectionChange({
|
||||
...appearance,
|
||||
mdRendering: { ...md, headingWeight: Math.min(900, Math.max(100, Math.round(n))) },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow label="Bold color" hint="**Bold** spans in teaching prose. Empty = the default ink" testid="row-bold-color">
|
||||
<CommitInput
|
||||
value={md?.boldColor ?? ""}
|
||||
placeholder="#c96b4a"
|
||||
ariaLabel="bold color"
|
||||
testid="input-bold-color"
|
||||
onCommit={(v) => onSectionChange({ ...appearance, mdRendering: { ...md, boldColor: v === "" ? null : v } })}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow label="Italic color" hint="*Italic* spans. Empty = the default ink" testid="row-italic-color">
|
||||
<CommitInput
|
||||
value={md?.italicColor ?? ""}
|
||||
placeholder="#6fa9d6"
|
||||
ariaLabel="italic color"
|
||||
testid="input-italic-color"
|
||||
onCommit={(v) => onSectionChange({ ...appearance, mdRendering: { ...md, italicColor: v === "" ? null : v } })}
|
||||
/>
|
||||
</SettingsRow>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const LADDER: readonly {
|
||||
key: keyof GraphSettings;
|
||||
label: string;
|
||||
needs: keyof GraphSettings | null;
|
||||
hint: string;
|
||||
}[] = [
|
||||
{ key: "enabled", label: "Graph", needs: null, hint: "Master: off = no extraction, embedding, or injection" },
|
||||
{ key: "embedding", label: "Embedding", needs: "enabled", hint: "Vectors + graph-context injection" },
|
||||
{ key: "clustering", label: "Clustering", needs: "embedding", hint: "Cluster computation + settle" },
|
||||
{ key: "labeling", label: "Labeling", needs: "clustering", hint: "The LLM cluster-namer" },
|
||||
];
|
||||
|
||||
export function GraphSection({
|
||||
graph,
|
||||
onSectionChange,
|
||||
}: {
|
||||
graph: GraphSettings | undefined;
|
||||
onSectionChange: (next: GraphSettings) => void;
|
||||
}) {
|
||||
// Stored values (the switches show these) vs EFFECTIVE values (a
|
||||
// stored-on rung under a broken chain is inert — the schema's ladder).
|
||||
const stored = {
|
||||
enabled: graph?.enabled ?? true,
|
||||
embedding: graph?.embedding ?? true,
|
||||
clustering: graph?.clustering ?? true,
|
||||
labeling: graph?.labeling ?? true,
|
||||
};
|
||||
const effective = {
|
||||
enabled: stored.enabled,
|
||||
embedding: stored.enabled && stored.embedding,
|
||||
clustering: stored.enabled && stored.embedding && stored.clustering,
|
||||
labeling: stored.enabled && stored.embedding && stored.clustering && stored.labeling,
|
||||
};
|
||||
return (
|
||||
<>
|
||||
{LADDER.map(({ key, label, needs, hint }) => {
|
||||
// Touchable only when every ancestor is EFFECTIVELY on.
|
||||
const parentOn = needs === null ? true : effective[needs];
|
||||
return (
|
||||
<SettingsRow key={key} label={label} hint={!parentOn ? `${hint} — needs ${needs} on` : hint} testid={`graph-row-${key}`}>
|
||||
<Toggle
|
||||
checked={stored[key]}
|
||||
disabled={!parentOn}
|
||||
label={`graph ${key}`}
|
||||
onChange={(on) => onSectionChange({ ...graph, [key]: on })}
|
||||
/>
|
||||
</SettingsRow>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
"use client";
|
||||
/**
|
||||
* THE APPEARANCE APPLICATION (thin settings wave, 2026-09-19): the settings
|
||||
* record's appearance section actually DOES something — resolved against
|
||||
* the factory defaults and applied as RUNTIME CSS variables on <html>.
|
||||
*
|
||||
* Why variables and not className swaps: every seam the settings touch was
|
||||
* built token-first (--font-prose, --lh-prose-size, --lh-ink-strong, …),
|
||||
* so application is setProperty and nothing re-renders. The ONE thing the
|
||||
* variables can't reach — the mode — is dark-only this wave (the light
|
||||
* palette is designed in the later design wave; <html class="dark"> is
|
||||
* static in app/layout.tsx).
|
||||
*
|
||||
* The provider is mounted at the ROOT LAYOUT level (like ActivityProvider):
|
||||
* every page picks appearance up on load. The settings page writes a
|
||||
* section, hands the new appearance here, and the vars move immediately.
|
||||
*
|
||||
* (The open-settings keyboard chord lives in components/keyboard-shortcuts.tsx
|
||||
* with the other navigations — mod+, pushes /settings from anywhere.)
|
||||
*/
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { FACTORY_SETTINGS, type AppearanceSettings } from "@/src/core";
|
||||
|
||||
/** The resolved, fully-concrete appearance view (delta + factory merged). */
|
||||
export function resolveAppearance(delta: AppearanceSettings | undefined) {
|
||||
const f = FACTORY_SETTINGS.appearance;
|
||||
return {
|
||||
mode: delta?.mode ?? f.mode,
|
||||
background: delta?.background ?? f.background,
|
||||
fontFamily: delta?.fontFamily ?? f.fontFamily,
|
||||
proseFontSize: delta?.proseFontSize ?? f.proseFontSize,
|
||||
sessionNameSerif: delta?.sessionNameSerif ?? f.sessionNameSerif,
|
||||
mdRendering: {
|
||||
headingWeight: delta?.mdRendering?.headingWeight ?? f.mdRendering.headingWeight,
|
||||
boldColor: delta?.mdRendering?.boldColor ?? f.mdRendering.boldColor,
|
||||
italicColor: delta?.mdRendering?.italicColor ?? f.mdRendering.italicColor,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** One channel clamped on the dark curve: additive offsets matching today's
|
||||
* hand-picked palette (#141414 → card #1c1c1c (+8) → accent #2a2a2a (+22)
|
||||
* → border #434343 (+47)) — the "per-mode derivation in code" the schema
|
||||
* promises, as the thinnest curve that keeps the palette's relationships. */
|
||||
function raise(hex: string, add: number): string {
|
||||
const n = hex.length === 4
|
||||
? hex.slice(1).split("").map((c) => parseInt(c + c, 16))
|
||||
: [parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16)];
|
||||
return `#${n.map((c) => Math.min(255, c + add).toString(16).padStart(2, "0")).join("")}`;
|
||||
}
|
||||
|
||||
/** The derived palette a chosen background implies (dark curve): background
|
||||
* itself, the raised surfaces (card/popover/secondary/muted, +8), the
|
||||
* accent (+22), the edges (border/input, +47), and primary-foreground
|
||||
* (the inverted text on primary = the background's own color, as today). */
|
||||
export function derivedPalette(background: string): Record<string, string> {
|
||||
const card = raise(background, 8);
|
||||
const accent = raise(background, 22);
|
||||
const border = raise(background, 47);
|
||||
return {
|
||||
"--background": background,
|
||||
"--card": card,
|
||||
"--popover": card,
|
||||
"--secondary": card,
|
||||
"--muted": card,
|
||||
"--accent": accent,
|
||||
"--border": border,
|
||||
"--input": border,
|
||||
"--primary-foreground": background,
|
||||
};
|
||||
}
|
||||
|
||||
/** THE mapping: resolved appearance → the CSS variables to set on <html>.
|
||||
* Absent entries = leave the factory stylesheet value in place. Pure —
|
||||
* exported for tests. */
|
||||
export function appearanceVars(a: ReturnType<typeof resolveAppearance>): Record<string, string> {
|
||||
const vars: Record<string, string> = {};
|
||||
if (a.background !== null) Object.assign(vars, derivedPalette(a.background));
|
||||
if (a.fontFamily !== null) {
|
||||
vars["--font-prose"] = `"${a.fontFamily}", var(--font-sans3), ui-sans-serif, system-ui, sans-serif`;
|
||||
}
|
||||
// The ONE documented reading-size variable (typography round 4): prose,
|
||||
// inputs, and session titles all read it, so one setProperty moves them.
|
||||
vars["--lh-prose-size"] = `${a.proseFontSize}px`;
|
||||
// Session names: CMU Serif by default; the toggle swaps the title font
|
||||
// to the prose family — covers chat.tsx + new-session.tsx titles with
|
||||
// zero component edits (they use the .font-title utility).
|
||||
if (!a.sessionNameSerif) vars["--font-title"] = "var(--font-prose)";
|
||||
// Markdown rendering: heading weight + the emphasis inks (the vars
|
||||
// were built "ready for the settings UI").
|
||||
vars["--lh-heading-weight"] = String(a.mdRendering.headingWeight);
|
||||
if (a.mdRendering.boldColor !== null) vars["--lh-ink-strong"] = a.mdRendering.boldColor;
|
||||
if (a.mdRendering.italicColor !== null) vars["--lh-ink-italic"] = a.mdRendering.italicColor;
|
||||
return vars;
|
||||
}
|
||||
|
||||
interface AppearanceContextValue {
|
||||
/** Resolved appearance; factory until the record arrives. */
|
||||
appearance: ReturnType<typeof resolveAppearance>;
|
||||
/** Called by the settings page after a successful section PUT. */
|
||||
onAppearanceChange: (delta: AppearanceSettings | undefined) => void;
|
||||
}
|
||||
|
||||
const AppearanceContext = createContext<AppearanceContextValue>({
|
||||
appearance: resolveAppearance(undefined),
|
||||
onAppearanceChange: () => {},
|
||||
});
|
||||
|
||||
export function useAppearance() {
|
||||
return useContext(AppearanceContext);
|
||||
}
|
||||
|
||||
export function AppearanceProvider({ children }: { children: React.ReactNode }) {
|
||||
const [delta, setDelta] = useState<AppearanceSettings | undefined>(undefined);
|
||||
const appliedRef = useRef<string>("");
|
||||
|
||||
// The vars apply on every change — stringified as the change signal.
|
||||
const vars = useMemo(() => appearanceVars(resolveAppearance(delta)), [delta]);
|
||||
const key = useMemo(() => JSON.stringify(vars), [vars]);
|
||||
useEffect(() => {
|
||||
if (appliedRef.current === key) return;
|
||||
appliedRef.current = key;
|
||||
const root = document.documentElement;
|
||||
for (const [name, value] of Object.entries(vars)) root.style.setProperty(name, value);
|
||||
}, [key, vars]);
|
||||
|
||||
// One fetch per app load: the record (sanitized server-side) carries the
|
||||
// delta; resolution happens here against the compiled factory.
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
fetch("/api/settings")
|
||||
.then((r) => r.json())
|
||||
.then((body: { settings?: Record<string, unknown> }) => {
|
||||
if (!alive) return;
|
||||
// The record arrives sanitized server-side; the appearance
|
||||
// slice is unknown-shaped here, so it goes through the same
|
||||
// resolve (absent/invalid fields = factory anyway).
|
||||
const appearance = body.settings?.appearance;
|
||||
setDelta(
|
||||
appearance && typeof appearance === "object" ? (appearance as AppearanceSettings) : undefined,
|
||||
);
|
||||
})
|
||||
.catch(() => {/* settings stay factory — not worth a crash */});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const onAppearanceChange = useCallback((next: AppearanceSettings | undefined) => {
|
||||
setDelta(next);
|
||||
}, []);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ appearance: resolveAppearance(delta), onAppearanceChange }),
|
||||
[delta, onAppearanceChange],
|
||||
);
|
||||
return <AppearanceContext.Provider value={value}>{children}</AppearanceContext.Provider>;
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
"use client";
|
||||
/**
|
||||
* Thin settings primitives (thin settings wave): the minimal control kit
|
||||
* the settings page needs — deliberately plain. No design system, no
|
||||
* animation polish: this wave is a WORKING validation surface over the
|
||||
* settings record, not the designed settings app (that's a later wave).
|
||||
*
|
||||
* Everything SSRs clean (render tests pin the sections' markup).
|
||||
*/
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** A section block: header + rows, separated by the app's border color. */
|
||||
export function SettingsSection({
|
||||
title,
|
||||
hint,
|
||||
children,
|
||||
testid,
|
||||
}: {
|
||||
title: string;
|
||||
/** One-line quiet note under the header (what the section controls). */
|
||||
hint?: string;
|
||||
children: React.ReactNode;
|
||||
testid?: string;
|
||||
}) {
|
||||
return (
|
||||
<section data-testid={testid} className="border-t border-border px-6 py-5 first:border-t-0">
|
||||
<h2 className="text-xs uppercase tracking-widest text-muted-foreground/70">{title}</h2>
|
||||
{hint && <p className="mt-1 text-xs text-muted-foreground/70">{hint}</p>}
|
||||
<div className="mt-3 flex flex-col gap-3">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** One label + one control. Label left (with optional muted hint), control
|
||||
* right — the settings row shape. */
|
||||
export function SettingsRow({
|
||||
label,
|
||||
hint,
|
||||
children,
|
||||
testid,
|
||||
}: {
|
||||
label: string;
|
||||
hint?: string;
|
||||
children: React.ReactNode;
|
||||
testid?: string;
|
||||
}) {
|
||||
return (
|
||||
<div data-testid={testid} className="flex items-center justify-between gap-4 py-1">
|
||||
<span className="min-w-0">
|
||||
<span className="block font-prose text-(length:--lh-prose-size)">{label}</span>
|
||||
{hint && <span className="block text-xs text-muted-foreground/70">{hint}</span>}
|
||||
</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The on/off switch — a plain button in the switch role. */
|
||||
export function Toggle({
|
||||
checked,
|
||||
disabled,
|
||||
onChange,
|
||||
label,
|
||||
}: {
|
||||
checked: boolean;
|
||||
disabled?: boolean;
|
||||
onChange: (next: boolean) => void;
|
||||
/** Accessible name for the switch role. */
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label={label}
|
||||
disabled={disabled}
|
||||
data-testid={`toggle-${label.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={cn(
|
||||
"relative h-4.5 w-8 shrink-0 rounded-full border border-border transition-colors",
|
||||
checked ? "bg-foreground" : "bg-muted",
|
||||
disabled && "cursor-not-allowed opacity-40",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"absolute top-0.5 size-3 rounded-full transition-transform",
|
||||
checked ? "translate-x-4.5 bg-background" : "translate-x-0.5 bg-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** A thin select — native element, options as given. */
|
||||
export function ThinSelect({
|
||||
value,
|
||||
onChange,
|
||||
children,
|
||||
ariaLabel,
|
||||
testid,
|
||||
disabled,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (next: string) => void;
|
||||
children: React.ReactNode;
|
||||
ariaLabel: string;
|
||||
testid?: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<select
|
||||
value={value}
|
||||
aria-label={ariaLabel}
|
||||
data-testid={testid}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className={cn(
|
||||
"h-7 rounded-sm border border-border bg-background px-1.5 font-ui text-xs text-foreground outline-none",
|
||||
disabled && "cursor-not-allowed opacity-40",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
/** A thin hex/text input committing on blur + Enter (settings pace, not
|
||||
* typing pace — every commit is a section PUT). */
|
||||
export function CommitInput({
|
||||
value,
|
||||
placeholder,
|
||||
onCommit,
|
||||
ariaLabel,
|
||||
testid,
|
||||
type = "text",
|
||||
}: {
|
||||
/** "" = unset (the factory default). */
|
||||
value: string;
|
||||
placeholder?: string;
|
||||
onCommit: (next: string) => void;
|
||||
ariaLabel: string;
|
||||
testid?: string;
|
||||
type?: "text" | "number";
|
||||
}) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
defaultValue={value}
|
||||
placeholder={placeholder}
|
||||
aria-label={ariaLabel}
|
||||
data-testid={testid}
|
||||
onBlur={(e) => {
|
||||
const next = e.target.value.trim();
|
||||
if (next !== value) onCommit(next);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") e.currentTarget.blur();
|
||||
if (e.key === "Escape") {
|
||||
e.currentTarget.value = value;
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
className="h-7 w-40 rounded-sm border border-border bg-background px-2 font-ui text-xs text-foreground outline-none placeholder:text-muted-foreground/60"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
"use client";
|
||||
/**
|
||||
* The Keyboard section (thin settings wave): view / rebind / reset over
|
||||
* core/keyboard.ts's registry. The overrides persist in localStorage
|
||||
* (lh:keymap, overrides-only, merged over DEFAULT_BINDINGS), so rebinding
|
||||
* is INSTANT — the dispatcher reads the map per keydown, no reload.
|
||||
*
|
||||
* Rebind flow: click Rebind → the row listens for the next keydown
|
||||
* (capture phase, preventDefault — nothing types anywhere), eventBinding
|
||||
* normalizes it, saveOverrides persists. Escape cancels. displayOnly
|
||||
* actions (a surface owns those keys — the question dock's row machine)
|
||||
* are listed for reading but never rebindable.
|
||||
*
|
||||
* Thin: no conflict detection — if a new binding collides with another
|
||||
* action's, the first match in registry order wins; the cheat sheet shows
|
||||
* both. Detection belongs to the designed wave.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
KEYBOARD_ACTIONS,
|
||||
eventBinding,
|
||||
formatBinding,
|
||||
loadOverrides,
|
||||
saveOverrides,
|
||||
type KeyAction,
|
||||
type KeyScope,
|
||||
} from "@/src/core";
|
||||
import { SettingsRow } from "./controls.tsx";
|
||||
|
||||
const SCOPES: { scope: KeyScope; label: string }[] = [
|
||||
{ scope: "global", label: "Global" },
|
||||
{ scope: "chat", label: "Chat" },
|
||||
{ scope: "graph", label: "Graph" },
|
||||
];
|
||||
|
||||
/** The overrides map after ONE change (pure — exported for tests):
|
||||
* binding null = reset the action to its default (key removed). */
|
||||
export function nextOverrides(
|
||||
current: Partial<Record<KeyAction, string>>,
|
||||
id: KeyAction,
|
||||
binding: string | null,
|
||||
): Partial<Record<KeyAction, string>> {
|
||||
const next = { ...current };
|
||||
if (binding === null) delete next[id];
|
||||
else next[id] = binding;
|
||||
return next;
|
||||
}
|
||||
|
||||
export function KeyboardSection() {
|
||||
const [overrides, setOverrides] = useState<Partial<Record<KeyAction, string>>>({});
|
||||
const [rebinding, setRebinding] = useState<KeyAction | null>(null);
|
||||
const [nonce, setNonce] = useState(0);
|
||||
|
||||
// localStorage is read post-mount (SSR-safe); `nonce` re-reads after writes.
|
||||
useEffect(() => {
|
||||
setOverrides(loadOverrides());
|
||||
}, [nonce]);
|
||||
|
||||
// The capture listener — only while a rebind is armed.
|
||||
useEffect(() => {
|
||||
if (!rebinding) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.key === "Escape") {
|
||||
setRebinding(null);
|
||||
return;
|
||||
}
|
||||
// Modifier keydowns arrive one by one (⌘ down, then ⇧, then the
|
||||
// key) — each is a keydown this capture listener sees. They are
|
||||
// not bindings; keep listening for the real key.
|
||||
if (e.key === "Control" || e.key === "Meta" || e.key === "Alt" || e.key === "Shift") return;
|
||||
const binding = eventBinding(e);
|
||||
if (!binding) return;
|
||||
const next = nextOverrides(loadOverrides(), rebinding, binding);
|
||||
saveOverrides(next);
|
||||
setRebinding(null);
|
||||
setNonce((n) => n + 1);
|
||||
};
|
||||
window.addEventListener("keydown", onKey, true);
|
||||
return () => window.removeEventListener("keydown", onKey, true);
|
||||
}, [rebinding]);
|
||||
|
||||
function resetOne(id: KeyAction) {
|
||||
saveOverrides(nextOverrides(loadOverrides(), id, null));
|
||||
setNonce((n) => n + 1);
|
||||
}
|
||||
|
||||
function resetAll() {
|
||||
saveOverrides({});
|
||||
setNonce((n) => n + 1);
|
||||
}
|
||||
|
||||
const overridden = (id: KeyAction) => overrides[id] !== undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
{SCOPES.map(({ scope, label }) => {
|
||||
const actions = KEYBOARD_ACTIONS.filter((a) => a.scope === scope);
|
||||
if (!actions.length) return null;
|
||||
return (
|
||||
<div key={scope} data-testid={`kbd-scope-${scope}`}>
|
||||
<p className="mb-1 text-xs uppercase tracking-widest text-muted-foreground/70">{label}</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
{actions.map((def) => {
|
||||
const effective = overrides[def.id] ?? def.binding;
|
||||
return (
|
||||
<SettingsRow
|
||||
key={def.id}
|
||||
label={def.label}
|
||||
hint={def.displayOnly ? "Owned by a surface — not rebindable" : undefined}
|
||||
testid={`kbd-row-${def.id}`}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<kbd className="rounded-sm border border-border bg-muted px-1.5 py-0.5 font-ui text-xs">
|
||||
{rebinding === def.id ? "Press keys…" : formatBinding(effective)}
|
||||
</kbd>
|
||||
{def.displayOnly ? null : rebinding === def.id ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRebinding(null)}
|
||||
className="text-xs text-muted-foreground/70 underline-offset-2 hover:text-foreground hover:underline"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRebinding(def.id)}
|
||||
data-testid={`kbd-rebind-${def.id}`}
|
||||
className="text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
|
||||
>
|
||||
Rebind
|
||||
</button>
|
||||
{overridden(def.id) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => resetOne(def.id)}
|
||||
data-testid={`kbd-reset-${def.id}`}
|
||||
className="text-xs text-muted-foreground/70 underline-offset-2 hover:text-foreground hover:underline"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</SettingsRow>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="mt-1 flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground/70">Stored in this browser (lh:keymap) — overrides only.</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetAll}
|
||||
data-testid="kbd-reset-all"
|
||||
className="text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
|
||||
>
|
||||
Reset all to defaults
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
"use client";
|
||||
/**
|
||||
* THE SETTINGS PAGE (thin settings wave, 2026-09-19): one column, section
|
||||
* blocks in fixed order — General, Agent, Appearance, Graph, Keyboard —
|
||||
* over the versioned delta record at GET /api/settings, written per
|
||||
* section through PUT /api/settings/[section] (replace-whole).
|
||||
*
|
||||
* Thin by owner decision: simple controls, real read/write, the point is
|
||||
* a WORKING validation surface that exercises the whole settings schema —
|
||||
* not the designed settings app. Polish lives in a later wave.
|
||||
*
|
||||
* The chat section (new-session defaults) is deliberately NOT here
|
||||
* (owner decision): its controls live on the /chat/new surface; two
|
||||
* editors for one value is a drift bug. The auth/provider-keys page is
|
||||
* deliberately absent (needs auth.json plumbing that doesn't exist).
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { CURRENT_SETTINGS_VERSION, type AgentSettings, type AppearanceSettings, type GraphSettings, type UserSettings } from "@/src/core";
|
||||
import { SidebarToggle } from "@/components/sidebar.tsx";
|
||||
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";
|
||||
|
||||
export function SettingsPage() {
|
||||
const { onAppearanceChange } = useAppearance();
|
||||
const [record, setRecord] = useState<UserSettings | null | undefined>(undefined);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// 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.
|
||||
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));
|
||||
}, []);
|
||||
|
||||
/** ONE writer per page: PUT the whole section, keep the record mirror
|
||||
* in sync, surface route errors inline (a 400 is this wave's point —
|
||||
* the validation surface catching schema mistakes). */
|
||||
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 className="flex h-dvh">
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
{/* The app header — same 60px band, same border, same CMU title
|
||||
(the alignment constant from header round 6). */}
|
||||
<header className="flex h-15 items-center gap-3 border-b border-border px-3">
|
||||
<SidebarToggle />
|
||||
<div className="min-w-0 flex-1 truncate font-title text-2xl tracking-tight">Settings</div>
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<div className="mx-auto w-full max-w-2xl pb-16">
|
||||
{error && (
|
||||
<p role="alert" className="m-3 rounded-sm border border-red-500/40 px-3 py-2 text-xs text-red-500">
|
||||
{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>
|
||||
) : (
|
||||
<>
|
||||
<SettingsSection title="General" hint="App-level behavior" 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>
|
||||
|
||||
<SettingsSection
|
||||
title="Agent"
|
||||
hint="Background-role models, tools, and prompt overrides"
|
||||
testid="section-agent"
|
||||
>
|
||||
<AgentSection
|
||||
agent={record.agent}
|
||||
onSectionChange={(next: AgentSettings) => void write("agent", next)}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Appearance" hint="Applied live as CSS variables" testid="section-appearance">
|
||||
<AppearanceSection
|
||||
appearance={record.appearance}
|
||||
onSectionChange={(next: AppearanceSettings) => void write("appearance", next)}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Graph" hint="Dependency ladder — a disabled rung grays everything below it" testid="section-graph">
|
||||
<GraphSection
|
||||
graph={record.graph}
|
||||
onSectionChange={(next: GraphSettings) => void write("graph", next)}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Keyboard" hint="Rebinds apply instantly — stored in this browser" testid="section-keyboard">
|
||||
<KeyboardSection />
|
||||
</SettingsSection>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user