diff --git a/app/globals.css b/app/globals.css index ac65329..e17e287 100644 --- a/app/globals.css +++ b/app/globals.css @@ -389,6 +389,30 @@ body { } } +/* Settings dialog pop (settings-app wave, 2026-09-20): a short fade + + 2% settle-scale — understated by design, the app's motion taste + (200ms sidebar, 150ms here); honors reduced-motion by appearing. */ +@keyframes settings-pop { + from { + opacity: 0; + scale: 0.98; + } + to { + opacity: 1; + scale: 1; + } +} + +.settings-dialog-pop { + animation: settings-pop 150ms ease-out; +} + +@media (prefers-reduced-motion: reduce) { + .settings-dialog-pop { + animation: none; + } +} + /* ───────────────────────────────────────────────────────────────────────── Electron merged chrome (owner, 2026-09-17). The system title bar is hidden (titleBarStyle: "hiddenInset" in electron/main.cjs); the app's diff --git a/app/settings/page.tsx b/app/settings/page.tsx index 35ba7f4..1b68fa9 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -1,8 +1,11 @@ -/** The settings route (thin settings wave): server shell over the client - * page — same shape as the chat routes (AppShell wraps via the root - * layout, so the sidebar + its Settings entry come along). */ -import { SettingsPage } from "@/components/settings/settings-page.tsx"; +import { redirect } from "next/navigation"; -export default function Page() { - return ; -} \ No newline at end of file +/** + * The settings surface graduated into a centered dialog over the app + * (settings-app wave, 2026-09-20 — owner decision: modal-only, no page). + * The URL stays routed but redirects: nothing should land on a dead + * page; the dialog opens via the sidebar row or mod+,. + */ +export default function SettingsPage() { + redirect("/chat/new"); +} diff --git a/components/keyboard-shortcuts.tsx b/components/keyboard-shortcuts.tsx index 5bbce11..2bcffb2 100644 --- a/components/keyboard-shortcuts.tsx +++ b/components/keyboard-shortcuts.tsx @@ -34,18 +34,21 @@ import { KEYBOARD_ACTIONS, } from "@/src/core"; import { isDictationActive } from "@/components/dictation"; +import { useSettingsDialog } from "@/components/settings/settings-dialog.tsx"; export function KeyboardShortcuts() { const router = useRouter(); const [cheatOpen, setCheatOpen] = useState(false); + const { open: openSettings } = useSettingsDialog(); - // Router + cheat state inside a ref so the keydown listener (attached - // once) never goes stale. + // Router + cheat + settings state inside a ref so the keydown listener + // (attached once) never goes stale. const handlersRef = useRef<{ router: ReturnType; setCheatOpen: (v: boolean) => void; - }>({ router, setCheatOpen }); - handlersRef.current = { router, setCheatOpen }; + openSettings: () => void; + }>({ router, setCheatOpen, openSettings }); + handlersRef.current = { router, setCheatOpen, openSettings }; useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { @@ -95,7 +98,9 @@ export function KeyboardShortcuts() { handlersRef.current.router.push("/chat/new"); break; case "open-settings": - handlersRef.current.router.push("/settings"); + // Settings-app wave: the dialog is the surface, the URL is + // gone — /settings redirects to the draft shell. + handlersRef.current.openSettings(); break; case "jump-graph": handlersRef.current.router.push("/"); diff --git a/components/model-picker.tsx b/components/model-picker.tsx index 4fc08cd..23991ba 100644 --- a/components/model-picker.tsx +++ b/components/model-picker.tsx @@ -17,13 +17,9 @@ */ import { useCallback, useEffect, useRef, useState } from "react"; -import { - fuzzyScore, - type ChatSettings, - type ModelCatalogEntry, - type ModelCatalogResponse, -} from "@/src/core"; -import { formatTokens, prettyModel } from "@/components/usage-format"; +import type { ChatSettings, ModelCatalogEntry, ModelCatalogResponse } from "@/src/core"; +import { prettyModel } from "@/components/usage-format"; +import { ModelMenuList, ThinkingMenuList } from "@/components/settings/model-menu"; /** GET /api/models is fetched once per page load and shared by every * picker instance (the catalog is static within a pi-ai version). */ @@ -75,11 +71,6 @@ const LEVEL_LABEL: Record = { max: "Max", }; -/** USD per 1M tokens, compact: "$0.08 in · $0.25 out". */ -function priceLabel(cost: ModelCatalogEntry["cost"]): string { - const fmt = (n: number) => `$${n < 1 ? n.toFixed(2).replace(/0$/, "") : n.toFixed(2)}`; - return `${fmt(cost.input)}/${fmt(cost.output)}`; -} export function ModelControl({ chatId, @@ -104,15 +95,8 @@ export function ModelControl({ }) { const catalog = useModelCatalog(); const [open, setOpen] = useState<"model" | "thinking" | null>(null); - const [query, setQuery] = useState(""); const [error, setError] = useState(null); - /** The arrow-walked result (owner polish, 2026-09-17): arrows move it - * through `results` while focus STAYS in the search field; Enter - * picks it (or the first result when nothing is walked). */ - const [menuIdx, setMenuIdx] = useState(null); - const searchRef = useRef(null); const containerRef = useRef(null); - const listRef = useRef(null); const defaults = catalog?.defaults; const effectiveId = noDefaultModel @@ -124,15 +108,6 @@ export function ModelControl({ const entry = catalog?.models.find((m) => m.id === effectiveId); const levels = entry?.supportedLevels ?? []; - // Focus the search box when the model menu opens (pi's /model feel). - useEffect(() => { - if (open === "model") searchRef.current?.focus(); - }, [open]); - // Keep the arrow-walked result visible in the scrolling list. - useEffect(() => { - if (open !== "model" || menuIdx === null) return; - listRef.current?.querySelector(`[data-idx="${menuIdx}"]`)?.scrollIntoView({ block: "nearest" }); - }, [menuIdx, open]); // The intuitive dropdown contract (owner, stats-control round 1): any // click outside the open menu — or an Esc keypress — closes it. Only the @@ -199,9 +174,7 @@ export function ModelControl({ useEffect(() => { const onOpenPicker = () => { setOpen("model"); - setQuery(""); setError(null); - setMenuIdx(null); }; window.addEventListener("lh:kbd-open-model-picker", onOpenPicker); return () => window.removeEventListener("lh:kbd-open-model-picker", onOpenPicker); @@ -230,12 +203,6 @@ export function ModelControl({ void save({ ...(settings ?? {}), model: { provider: m.provider, id: m.id }, thinkingLevel: nextLevel }); }; - // The filtered, ranked model list. - const results = (catalog?.models ?? []) - .map((m) => ({ m, s: fuzzyScore(query, m.provider, m.id, m.name) })) - .filter((r) => r.s !== null) - .sort((a, b) => (b.s! - a.s!) || a.m.id.localeCompare(b.m.id)) - .map((r) => r.m); return (
@@ -244,9 +211,7 @@ export function ModelControl({ data-testid="stats-model" onClick={() => { setOpen((o) => (o === "model" ? null : "model")); - setQuery(""); setError(null); - setMenuIdx(null); }} aria-expanded={open === "model"} title="Conversation model — applies next turn" @@ -277,82 +242,20 @@ export function ModelControl({ ▾ + {/* The shared dropdown body (extracted 2026-09-20): the agent + settings' role selects use the exact same one. */} {open === "model" && (
- { - setQuery(e.target.value); - setMenuIdx(null); // new query → the walk restarts on Enter/↓ - }} - // The menu keyboard machine (owner polish): focus STAYS in the - // search field — ↑/↓ walk the filtered results (clamped, no - // wrap), Enter picks the walked row (or the first result when - // nothing is walked). stopPropagation keeps these keys out of - // every other listener while the menu is open. - onKeyDown={(e) => { - if (e.key === "ArrowDown" || e.key === "ArrowUp") { - e.preventDefault(); - e.stopPropagation(); - if (results.length === 0) return; - setMenuIdx((i) => - e.key === "ArrowDown" - ? Math.min((i ?? -1) + 1, results.length - 1) - : Math.max((i ?? results.length) - 1, 0), - ); - } else if (e.key === "Enter") { - const chosen = results[menuIdx ?? 0]; - if (chosen) { - e.preventDefault(); - e.stopPropagation(); - pickModel(chosen); - } - } - }} - placeholder="Search models…" - className="lh-input mb-1.5 w-full rounded-lg border bg-background px-2 py-1.5 text-foreground outline-none focus:ring-1 focus:ring-ring" - /> {error &&
{error}
} - {!catalog && ( -
Loading models…
- )} - {catalog && results.length === 0 && ( -
No models match.
- )} -
    - {results.map((m, i) => ( -
  • - -
  • - ))} -
-
- Applies to the next turn · background agents stay config-pinned -
+
)} @@ -361,30 +264,14 @@ export function ModelControl({ data-testid="thinking-menu" className="absolute bottom-full right-0 z-40 mb-1.5 flex w-44 flex-col gap-0.5 rounded-xl border bg-card p-2 text-xs shadow-xl" > - {!catalog &&
Loading…
} - {catalog && levels.length === 0 && ( -
- {effectiveId ? `${prettyModel(effectiveId)} has no thinking levels.` : "Pick a model first."} -
- )} - {levels.map((level) => ( - - ))} + void save({ ...(settings ?? {}), thinkingLevel: level as ChatSettings["thinkingLevel"] })} + empty={catalog ? (effectiveId ? `${prettyModel(effectiveId)} has no thinking levels.` : "Pick a model first.") : "Loading…"} + footer="Levels this model actually supports" + /> {error &&
{error}
} -
- Levels this model actually supports -
)} diff --git a/components/settings/agent-section.tsx b/components/settings/agent-section.tsx index 9ed301f..45ad2a2 100644 --- a/components/settings/agent-section.tsx +++ b/components/settings/agent-section.tsx @@ -1,34 +1,42 @@ "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). + * The Agent section (settings-app wave): background-role model overrides, + * the tool toggles, and the prompt override switches — three GROUPS + * (owner, 2026-09-20: clear separation — Models / Tools / System prompts), + * ONE section (PUT /api/settings/agent replaces the whole agent object, + * so every change composes models+tools+prompts together from the record). + * + * The role selects (owner, 2026-09-20) are the EXACT same dropdown as the + * chat model selector — the shared ModelMenuList/ThinkingMenuList from + * ./model-menu.tsx (fuzzy search, arrow-walk, Enter pick). The collapsed + * trigger keeps the thin select look; the opened menu is the model + * picker's. Menus open DOWNWARD here (the dialog sits mid-screen); the + * chat control opens upward — the shell belongs to each mount, the body + * is shared. * * 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). + * Models: each of the six background roles defaults to config.ts's pin + * (no override). The Default row clears the model override; the stored id + * stays VISIBLE even when the catalog no longer contains it — a trigger + * showing "Default" over a live override would lie about the record. * * 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. + * override FILE (route-enforced), so a file-less row dims whole-row + * (graph-view graying) until one is written. Switch state lives in this + * section's PUT; text writes through the segment route. */ -import { useEffect, useState } from "react"; -import { - BACKGROUND_MODEL_ROLES, - type AgentSettings, - type BackgroundModelRole, -} from "@/src/core"; +import { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import type React from "react"; +import { BACKGROUND_MODEL_ROLES, type AgentSettings, type BackgroundModelRole, type ModelCatalogEntry } 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"; +import { prettyModel } from "@/components/usage-format"; +import { SettingsGroup, SettingsRow, Toggle } from "./controls.tsx"; +import { ModelMenuList, ThinkingMenuList } from "./model-menu"; /** The role's display label — schema ids are camelCase, the label is human. */ const ROLE_LABEL: Record = { @@ -91,6 +99,96 @@ export function composePromptSwitch(agent: AgentSettings | undefined, path: stri return { ...agent, prompts }; } +/** The collapsed select look (owner: "the look of the collapsed menu is + * good") — now a button that opens the shared model-picker dropdown. */ +function MenuTrigger({ + value, + emptyLabel, + ariaLabel, + testid, + open, + onClick, + widthClass, +}: { + value: string; + /** What the trigger shows when there is no override ("Default"). */ + emptyLabel: string; + ariaLabel: string; + testid: string; + open: boolean; + onClick: () => void; + /** Owner (2026-09-20): the thinking trigger is FIXED at the width the + * Default state needs (values shifting it mid-edit shifted the row); + * the model trigger takes the same width as a MINIMUM — its values + * genuinely vary in length. */ + widthClass: string; +}) { + return ( + + ); +} + +/** The opened dropdown, PORTALED to document.body with fixed positioning: + * the dialog's overflow frame must not clip it — the owner wants the + * menus flowing over the whole screen, outside the settings surface + * (2026-09-20). Right-aligned to its trigger, repositioned while the + * dialog content scrolls under it. */ +function AnchoredMenu({ + anchorTestid, + menuClass, + testid, + children, +}: { + /** The trigger to hang from — its data-testid. */ + anchorTestid: string; + /** The positioned menu's classes (width, padding, frame). */ + menuClass: string; + testid: string; + children: React.ReactNode; +}) { + const [style, setStyle] = useState<{ right: number; top: number } | null>(null); + useEffect(() => { + const place = () => { + const anchor = document.querySelector(`[data-testid="${anchorTestid}"]`); + if (!anchor) return; + const r = anchor.getBoundingClientRect(); + setStyle({ right: window.innerWidth - r.right, top: r.bottom + 4 }); + }; + place(); + // Capture-phase: catches the dialog's inner content scroll. + window.addEventListener("scroll", place, true); + window.addEventListener("resize", place); + return () => { + window.removeEventListener("scroll", place, true); + window.removeEventListener("resize", place); + }; + }, [anchorTestid]); + if (style === null) return null; + return createPortal( +
+ {children} +
, + document.body, + ); +} + export function AgentSection({ agent, onSectionChange, @@ -100,16 +198,27 @@ export function AgentSection({ onSectionChange: (next: AgentSettings) => void; }) { const [catalog, setCatalog] = useState(null); + /** The config-pinned model per background role — GET /api/models' + * rolePins (Platform stream, landing as a standalone commit; master + * routing 2026-09-20). Optional here: until the rebase the rows read + * "Default: config pin" and light up the moment the field arrives. */ + const [rolePins, setRolePins] = useState | null>(null); const [prompts, setPrompts] = useState<{ prober: SegmentInfo[]; teacher: SegmentInfo[] } | null>(null); const [openSegment, setOpenSegment] = useState(null); const [segmentDraft, setSegmentDraft] = useState>({}); const [segmentStatus, setSegmentStatus] = useState(null); + /** One open dropdown for the whole section: which role, which kind. */ + const [openMenu, setOpenMenu] = useState<{ role: BackgroundModelRole; kind: "model" | "thinking" } | null>(null); + const menuAnchorRef = useRef(null); - // The catalog + the prompt segments — one fetch each per page load. + // The catalog + the prompt segments — one fetch each per section mount. useEffect(() => { fetch("/api/models") .then((r) => r.json()) - .then((body: { models: ModelCatalogEntry[] }) => setCatalog(body.models)) + .then((body: { models: ModelCatalogEntry[]; rolePins?: Record }) => { + setCatalog(body.models); + if (body.rolePins) setRolePins(body.rolePins); + }) .catch(() => setCatalog(null)); fetch("/api/settings/prompts") .then((r) => r.json()) @@ -117,6 +226,36 @@ export function AgentSection({ .catch(() => setPrompts(null)); }, []); + // The dropdown contract (the chat model selector's, mirrored): any + // click outside the open menu — or an Esc — closes it. Esc is CLAIMED + // with stopPropagation so the settings dialog behind does NOT also + // close. A pointerdown on the backdrop is left for the dialog (its own + // click closes everything); a click inside the dialog just closes us. + useEffect(() => { + if (openMenu === null) return; + const onPointerDown = (e: PointerEvent) => { + const target = e.target as Node; + if (menuAnchorRef.current?.contains(target)) return; + if (target instanceof Element && target.closest("[data-lh-menu]")) return; + if (target instanceof Element && target.closest('[role="dialog"]')) { + setOpenMenu(null); + } + // else: outside the dialog — the dialog's own close handles it. + }; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.stopPropagation(); + setOpenMenu(null); + } + }; + document.addEventListener("pointerdown", onPointerDown); + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("pointerdown", onPointerDown); + document.removeEventListener("keydown", onKeyDown); + }; + }, [openMenu]); + async function saveSegment(path: string, text: string) { setSegmentStatus(null); const res = await fetch(`/api/settings/prompts/${path}`, { @@ -147,6 +286,22 @@ export function AgentSection({ .then((body: { prober: SegmentInfo[]; teacher: SegmentInfo[] }) => setPrompts(body)); } + /** The pinned Default row — clears this control's override (absent key + * = config pin). Same row language as the shared menu's list rows. */ + const defaultRow = (active: boolean, onPick: () => void) => ( + + ); + const segmentRow = (info: SegmentInfo) => { const fileExists = info.overrideText !== null; const open = openSegment === info.path; @@ -154,8 +309,9 @@ export function AgentSection({
- {/* ── 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 ( - - - onSectionChange(composeRoleModel(agent, role, id, thinking))} + + {BACKGROUND_MODEL_ROLES.map((role) => { + const override = agent?.models?.[role]; + const modelId = override?.model?.id ?? ""; + const thinking = override?.thinkingLevel ?? ""; + // The model whose levels govern the thinking list: the + // override, else the config pin (rolePins makes the + // default's levels resolvable when it's in the catalog); + // the standard ladder as the last fallback. + const effectiveId = modelId || rolePins?.[role]?.model.id || ""; + const chosen = catalog?.find((m) => m.id === effectiveId); + const levels = chosen ? chosen.supportedLevels : THINKING_LEVELS; + // The row ALWAYS names the default (owner, 2026-09-20: the + // old "config pin applies" hint said nothing). rolePins + // carries the resolved default; the fallback reads until + // Platform's rolePins lands on main. + const defaultName = rolePins?.[role]?.model.id + ? prettyModel(rolePins[role].model.id) + : "config pin"; + const thisMenuOpen = + openMenu?.role === role ? openMenu.kind : null; + return ( + + {/* The anchor for the outside-click contract is the + span holding BOTH triggers of the open menu. */} + - - {/* 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) && ( - + setOpenMenu(thisMenuOpen === "model" ? null : { role, kind: "model" })} + widthClass="min-w-20" + /> + setOpenMenu(thisMenuOpen === "thinking" ? null : { role, kind: "thinking" })} + widthClass="w-20" + /> + {thisMenuOpen === "model" && ( + + {defaultRow( + modelId === "", + () => { + onSectionChange(composeRoleModel(agent, role, "", thinking)); + setOpenMenu(null); + }, + )} + { + onSectionChange(composeRoleModel(agent, role, entry.id, thinking)); + setOpenMenu(null); + }} + footer="Applied to every run of this role" + /> + )} - {catalog?.map((m) => ( - - ))} - - onSectionChange(composeRoleModel(agent, role, modelId, level))} - > - - {levels.map((l) => ( - - ))} - - + {thisMenuOpen === "thinking" && ( + + {defaultRow( + thinking === "", + () => { + onSectionChange(composeRoleModel(agent, role, modelId, "")); + setOpenMenu(null); + }, + )} + { + onSectionChange(composeRoleModel(agent, role, modelId, level)); + setOpenMenu(null); + }} + /> + + )} + + + ); + })} + + + + {TOGGLE_TOOLS.map((id) => ( + + onSectionChange(composeTool(agent, id, on))} + /> - ); - })} + ))} + - {/* ── Tools ── */} - {TOGGLE_TOOLS.map((id) => ( - - onSectionChange(composeTool(agent, id, on))} - /> - - ))} - - {/* ── Prompts ── */} - {prompts === null ? ( -

Loading prompt segments…

- ) : ( -
- {(["prober", "teacher"] as const).map((who) => ( -
-

{who}

-
- {prompts[who].map((info) => segmentRow(info))} + + {prompts === null ? ( +

Loading prompt segments…

+ ) : ( +
+ {(["prober", "teacher"] as const).map((who) => ( +
+

{who}

+
+ {prompts[who].map((info) => segmentRow(info))} +
-
- ))} - {segmentStatus &&

{segmentStatus}

} -
- )} + ))} + {segmentStatus &&

{segmentStatus}

} +
+ )} + ); } \ No newline at end of file diff --git a/components/settings/appearance-section.tsx b/components/settings/appearance-section.tsx index 5ba73f2..ed4bb0b 100644 --- a/components/settings/appearance-section.tsx +++ b/components/settings/appearance-section.tsx @@ -11,8 +11,9 @@ * 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. + * Graph graying (owner, 2026-09-20) follows the graph viewer's render-panel + * convention: the whole row dims when a parent is off (stored values + * persist but resolve inert — the switch shows stored, the dim effective). */ import type { AppearanceSettings, GraphSettings } from "@/src/core"; @@ -33,14 +34,14 @@ export function AppearanceSection({ return ( <> - + {}} testid="select-mode" disabled> onSectionChange({ ...appearance, fontFamily: v === "" ? null : v })} /> - + - + onSectionChange({ ...appearance, sessionNameSerif: on })} /> - + - + onSectionChange({ ...appearance, mdRendering: { ...md, boldColor: v === "" ? null : v } })} /> - + - {LADDER.map(({ key, label, needs, hint }) => { + {LADDER.map(({ key, label, needs }) => { // Touchable only when every ancestor is EFFECTIVELY on. const parentOn = needs === null ? true : effective[needs]; return ( - + -

{title}

- {hint &&

{hint}

} -
{children}
+
+

{title}

+ {hint &&

{hint}

} +
{children}
); } +/** A labeled sub-group inside a section (owner, 2026-09-20: clear + * separation — Models / Tools / Prompts read as three blocks). */ +export function SettingsGroup({ + label, + children, + testid, +}: { + label: string; + children: React.ReactNode; + testid?: string; +}) { + return ( +
+

{label}

+
{children}
+
+ ); +} + /** One label + one control. Label left (with optional muted hint), control * right — the settings row shape. */ export function SettingsRow({ @@ -39,14 +59,18 @@ export function SettingsRow({ hint, children, testid, + dimmed, }: { label: string; hint?: string; children: React.ReactNode; testid?: string; + /** Dependency graying, graph-view style: the whole row dims when the + * control is inert (parent off, no file yet, …). */ + dimmed?: boolean; }) { return ( -
+
{label} {hint && {hint}} @@ -80,14 +104,18 @@ export function Toggle({ 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", + // Owner (2026-09-20): on is a gray track with a WHITE disc (a full-white + // track was too loud); off stays the muted track. + checked ? "bg-muted-foreground" : "bg-muted", + disabled && "cursor-not-allowed", )} > @@ -119,7 +147,7 @@ export function ThinSelect({ 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", + disabled && "cursor-not-allowed", )} > {children} diff --git a/components/settings/keyboard-section.tsx b/components/settings/keyboard-section.tsx index aae1ef5..94ef13c 100644 --- a/components/settings/keyboard-section.tsx +++ b/components/settings/keyboard-section.tsx @@ -155,7 +155,7 @@ export function KeyboardSection() { ); })}
- Stored in this browser (lh:keymap) — overrides only. + Stored on this device (lh:keymap) — overrides only. + + ))} + + {footer && ( +
{footer}
+ )} + + ); +} + +/** The thinking-level dropdown body — the levels this model actually + * supports, ✓ on the effective one. Same shape as the chat selector's. */ +export function ThinkingMenuList({ + levels, + effectiveLevel, + onPick, + empty, + footer, +}: { + levels: string[]; + effectiveLevel: string | undefined; + onPick: (level: string) => void; + /** Shown instead of the list when there are no levels. */ + empty?: string; + footer?: string; +}) { + return ( + <> + {levels.length === 0 ? ( +
{empty ?? "No thinking levels."}
+ ) : ( + levels.map((level) => ( + + )) + )} + {footer && ( +
{footer}
+ )} + + ); +} \ No newline at end of file diff --git a/components/settings/settings-dialog.tsx b/components/settings/settings-dialog.tsx new file mode 100644 index 0000000..9a5c9b1 --- /dev/null +++ b/components/settings/settings-dialog.tsx @@ -0,0 +1,314 @@ +"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 ( + + {children} + {show && setShow(false)} />} + + ); +} + +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( +
+ +
, + 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( + undefined, + ); + const [error, setError] = useState(null); + const [active, setActive] = useState("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 ( +
e.stopPropagation()} + > + {/* Header — the app's quiet CMU title language. */} +
+

Settings

+ +
+ +
+ {/* Left nav — text-only, the session-list mono language, + active = bg-accent. */} + + + {/* Content column — one section at a time, this scrolls. */} +
+ {error && ( +

+ {error} +

+ )} + {record === undefined ? ( +

+ Loading settings… +

+ ) : record === null ? ( +

+ Settings could not be loaded — refresh to retry. +

+ ) : ( +
+ {active === "general" && ( + + + + void write("general", { autoUpdate: on }) + } + /> + + + )} + {active === "agent" && ( + + + void write("agent", next) + } + /> + + )} + {active === "appearance" && ( + + + void write("appearance", next) + } + /> + + )} + {active === "graph" && ( + + + void write("graph", next) + } + /> + + )} + {active === "keyboard" && ( + + + + )} +
+ )} +
+
+
+ ); +} diff --git a/components/settings/settings-page.tsx b/components/settings/settings-page.tsx deleted file mode 100644 index edcae8a..0000000 --- a/components/settings/settings-page.tsx +++ /dev/null @@ -1,135 +0,0 @@ -"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(undefined); - const [error, setError] = useState(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 ( -
-
- {/* The app header — same 60px band, same border, same CMU title - (the alignment constant from header round 6). */} -
- -
Settings
-
- -
-
- {error && ( -

- {error} -

- )} - {record === undefined ? ( -

Loading settings…

- ) : record === null ? ( -

- Settings could not be loaded — refresh to retry. -

- ) : ( - <> - - - void write("general", { autoUpdate: on })} - /> - - - - - void write("agent", next)} - /> - - - - void write("appearance", next)} - /> - - - - void write("graph", next)} - /> - - - - - - - )} -
-
-
-
- ); -} \ No newline at end of file diff --git a/components/sidebar.tsx b/components/sidebar.tsx index 89d05f6..6e1702a 100644 --- a/components/sidebar.tsx +++ b/components/sidebar.tsx @@ -8,6 +8,7 @@ import { ActivitySection } from "@/components/activity-dock"; import { LhMark } from "@/components/lh-mark"; import { BrailleSpinner } from "@/components/braille-spinner"; import { KeyboardShortcuts } from "@/components/keyboard-shortcuts"; +import { SettingsDialogProvider, useSettingsDialog } from "@/components/settings/settings-dialog.tsx"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { cn } from "@/lib/utils"; @@ -265,6 +266,7 @@ function SessionsSidebar() { const [query, setQuery] = useState(""); const pathname = usePathname(); const router = useRouter(); + const { open: openSettings } = useSettingsDialog(); /* New session (owner, 2026-09-13): NO POST — the button opens the sessionless draft view; the session is created only when the first @@ -483,18 +485,19 @@ function SessionsSidebar() { learner opted out (no ambient signal, user decision). */} - {/* Settings entry (thin settings wave): the same row language - as New session — quiet, bottom-pinned, one chord away is - mod+, (the keyboard registry's open-settings action). */} + {/* Settings entry (settings-app wave): same row language as + New session — quiet, bottom-pinned, opens the centered + dialog (also mod+, in the keyboard registry). */}
- Settings - +
@@ -504,13 +507,13 @@ function SessionsSidebar() { /** The app frame: sidebar inline + content. Rendered once in the root layout. */ export function AppShell({ children }: { children: React.ReactNode }) { return ( - <> +
{children}
{/* The single document-level keydown listener (keyboard wave A) — after the content in the tree, but it's window-level so order only matters for the events it dispatches, never for DOM priority. */} - +
); } diff --git a/tests/render-settings.test.tsx b/tests/render-settings.test.tsx index e44f3f3..5f5a22b 100644 --- a/tests/render-settings.test.tsx +++ b/tests/render-settings.test.tsx @@ -15,6 +15,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import { TOGGLE_TOOLS, AgentSection } from "../components/settings/agent-section.tsx"; import { AppearanceSection, GraphSection } from "../components/settings/appearance-section.tsx"; import { KeyboardSection } from "../components/settings/keyboard-section.tsx"; +import { SettingsDialogBody, SettingsDialogProvider } from "../components/settings/settings-dialog.tsx"; import { KEYBOARD_ACTIONS } from "../src/core/index.ts"; /** The full opening tag of the element carrying a testid — attribute @@ -26,14 +27,50 @@ function tagOf(html: string, testid: string): string { return html.slice(html.lastIndexOf("<", i), html.indexOf(">", i) + 1); } +describe("SettingsDialog (the designed surface, SSR)", () => { + it("provider CLOSED: children only — no dialog, no portal content", () => { + const html = renderToStaticMarkup( + +

the app

+
, + ); + assert.ok(html.includes("the app")); + assert.ok(!html.includes("settings-backdrop")); + assert.ok(!html.includes("settings-dialog")); + }); + + it("the dialog frame: role=dialog + the ✕ + the five-section nav, General active", () => { + const html = renderToStaticMarkup( {}} />); + assert.ok(html.includes('role="dialog"')); + assert.ok(html.includes('aria-modal="true"')); + assert.ok(html.includes('aria-label="Close settings"')); + for (const id of ["general", "agent", "appearance", "graph", "keyboard"]) { + assert.ok(html.includes(`data-testid="settings-nav-${id}"`), id); + } + // General is the landing section; the others are not current. + assert.ok(tagOf(html, "settings-nav-general").includes('aria-current="true"')); + assert.ok(tagOf(html, "settings-nav-keyboard").includes('aria-current="false"')); + // Fetches don't run under SSR — the loading state IS the SSR state. + assert.ok(html.includes("Loading settings…")); + // Modal-only doctrine: no link back to /settings anywhere in it. + assert.ok(!html.includes('href="/settings"')); + }); +}); + describe("AgentSection (thin SSR)", () => { - it("renders all six background-role rows, Default-first selects", () => { + it("renders all six background-role rows, Default-triggered dropdowns", () => { const html = renderToStaticMarkup( {}} />); for (const role of ["researcher", "demoBuilder", "visualMaker", "extractor", "auditor", "labeler"]) { assert.ok(html.includes(`data-testid="role-row-${role}"`), role); - assert.ok(html.includes(`data-testid="role-model-${role}"`), `${role} model select`); + // The shared model-picker dropdown as the opened menu (owner, + // 2026-09-20), the thin trigger as the collapsed look. + assert.ok(html.includes(`data-testid="role-model-${role}"`), `${role} model trigger`); + assert.ok(html.includes(`data-testid="role-thinking-${role}"`), `${role} thinking trigger`); } - assert.equal((html.match(/>Default<\/option>/g) ?? []).length, 12, "six roles × two selects"); + assert.equal((html.match(/>Default<\/span>/g) ?? []).length, 12, "six roles × two Default triggers"); + assert.ok(html.includes('data-testid="group-models"'), "Models sub-group"); + assert.ok(html.includes('data-testid="group-tools"'), "Tools sub-group"); + assert.ok(html.includes('data-testid="group-prompts"'), "System prompts sub-group"); }); it("renders the owner's FINAL tool list — research, demo, visual, pdf", () => { @@ -59,9 +96,9 @@ describe("AgentSection (thin SSR)", () => { onSectionChange={() => {}} />, ); - // SSR has no catalog: the stored id renders as its own option (a select - // that showed "Default" over a live override would lie about the record). - assert.ok(html.includes(`value="z-ai/glm-4.7-flash" selected=""`), "the override id is selected, visible"); + // The trigger shows the stored id even with no catalog loaded (a + // trigger reading "Default" over a live override would lie). + assert.ok(html.includes("Glm 4.7 Flash"), "the override id is visible (prettyModel form)"); assert.ok(tagOf(html, "toggle-tool-demo").includes('aria-checked="false"'), "demo off"); assert.ok(tagOf(html, "toggle-tool-visual").includes('aria-checked="true"'), "visual still on"); }); @@ -105,7 +142,9 @@ describe("AppearanceSection (thin SSR)", () => { it("mode is rendered DISABLED — dark-only this wave, no lying control", () => { const html = renderToStaticMarkup( {}} />); assert.ok(tagOf(html, "select-mode").includes("disabled"), "mode select disabled"); - assert.ok(html.includes("Light mode ships with the design wave")); + assert.ok(html.includes("Light ships with the design wave")); + // The whole row dims, graph-view style (the control never adds its own). + assert.ok(tagOf(html, "row-mode").includes("opacity-40"), "mode row dims whole"); }); it("hex inputs carry the factory placeholders; prose size resolves against the factory", () => { @@ -113,7 +152,7 @@ describe("AppearanceSection (thin SSR)", () => { assert.ok(html.includes(`placeholder="#141414"`)); assert.ok(html.includes(`placeholder="Source Sans 3"`)); assert.ok(html.includes(`placeholder="#c96b4a"`)); - assert.ok(html.includes("16px — prose, inputs, session titles scale together")); + assert.ok(html.includes("16px")); }); it("a set background shows in the input, not the placeholder", () => {