Merge branch 'feat/ui-polish'
This commit is contained in:
@@ -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
|
||||
|
||||
+10
-7
@@ -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 <SettingsPage />;
|
||||
}
|
||||
/**
|
||||
* 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");
|
||||
}
|
||||
|
||||
@@ -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<typeof useRouter>;
|
||||
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("/");
|
||||
|
||||
+18
-131
@@ -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<string, string> = {
|
||||
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<string | null>(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<number | null>(null);
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const listRef = useRef<HTMLUListElement>(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 (
|
||||
<div ref={containerRef} className="relative flex items-center gap-1.5" data-testid="model-control">
|
||||
@@ -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({
|
||||
<span aria-hidden className="text-xs text-muted-foreground">▾</span>
|
||||
</button>
|
||||
|
||||
{/* The shared dropdown body (extracted 2026-09-20): the agent
|
||||
settings' role selects use the exact same one. */}
|
||||
{open === "model" && (
|
||||
<div
|
||||
data-testid="model-menu"
|
||||
className="absolute bottom-full right-0 z-40 mb-1.5 flex w-[22rem] flex-col rounded-xl border bg-card p-2 text-xs shadow-xl"
|
||||
>
|
||||
<input
|
||||
ref={searchRef}
|
||||
data-testid="model-search"
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
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 && <div className="mb-1.5 text-destructive">{error}</div>}
|
||||
{!catalog && (
|
||||
<div className="px-2 py-3 text-center text-muted-foreground">Loading models…</div>
|
||||
)}
|
||||
{catalog && results.length === 0 && (
|
||||
<div className="px-2 py-3 text-center text-muted-foreground">No models match.</div>
|
||||
)}
|
||||
<ul ref={listRef} data-testid="model-list" className="flex max-h-72 flex-col overflow-y-auto px-1">
|
||||
{results.map((m, i) => (
|
||||
<li key={`${m.provider}/${m.id}`}>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`model-option-${m.id.replace(/[^a-z0-9-]/gi, "_")}`}
|
||||
data-idx={i}
|
||||
onClick={() => pickModel(m)}
|
||||
className={`flex w-full flex-col gap-0.5 rounded-lg px-2 py-1.5 text-left outline-none transition-colors hover:bg-muted ${
|
||||
i === menuIdx ? "bg-accent/40 ring-1 ring-inset ring-border" : ""
|
||||
}${m.id === effectiveId && i !== menuIdx ? " bg-muted/60" : ""}`}
|
||||
>
|
||||
<span className="flex items-center justify-between gap-2">
|
||||
<span className="truncate text-card-foreground">{m.name}</span>
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground" title="USD per 1M tokens in/out">
|
||||
{priceLabel(m.cost)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex items-center justify-between gap-2 text-muted-foreground">
|
||||
<span className="truncate">{m.id}</span>
|
||||
<span className="shrink-0 tabular-nums">{formatTokens(m.contextWindow)}</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-1 border-t px-2 pt-1.5 text-xs text-muted-foreground">
|
||||
Applies to the next turn · background agents stay config-pinned
|
||||
</div>
|
||||
<ModelMenuList
|
||||
models={catalog?.models}
|
||||
effectiveId={effectiveId}
|
||||
onPick={pickModel}
|
||||
footer="Applies to the next turn · background agents stay config-pinned"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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 && <div className="px-2 py-3 text-center text-muted-foreground">Loading…</div>}
|
||||
{catalog && levels.length === 0 && (
|
||||
<div className="px-2 py-3 text-center text-muted-foreground">
|
||||
{effectiveId ? `${prettyModel(effectiveId)} has no thinking levels.` : "Pick a model first."}
|
||||
</div>
|
||||
)}
|
||||
{levels.map((level) => (
|
||||
<button
|
||||
key={level}
|
||||
type="button"
|
||||
data-testid={`thinking-option-${level}`}
|
||||
onClick={() => void save({ ...(settings ?? {}), thinkingLevel: level })}
|
||||
className={`flex items-center justify-between rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-muted ${
|
||||
level === effectiveLevel ? "bg-muted/60 text-card-foreground" : "text-card-foreground"
|
||||
}`}
|
||||
>
|
||||
<span>{LEVEL_LABEL[level] ?? level}</span>
|
||||
{level === effectiveLevel && <span aria-hidden>✓</span>}
|
||||
</button>
|
||||
))}
|
||||
<ThinkingMenuList
|
||||
levels={catalog ? levels : []}
|
||||
effectiveLevel={effectiveLevel}
|
||||
onPick={(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 && <div className="mt-1 text-destructive">{error}</div>}
|
||||
<div className="mt-1 border-t px-2 pt-1.5 text-xs text-muted-foreground">
|
||||
Levels this model actually supports
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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<BackgroundModelRole, string> = {
|
||||
@@ -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 (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={ariaLabel}
|
||||
aria-expanded={open}
|
||||
data-testid={testid}
|
||||
onClick={onClick}
|
||||
className={`flex h-7 items-center gap-1 rounded-sm border border-border bg-background px-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50 ${widthClass}`}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">{value || emptyLabel}</span>
|
||||
<span aria-hidden className="text-muted-foreground">
|
||||
▾
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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(
|
||||
<div
|
||||
data-testid={testid}
|
||||
data-lh-menu=""
|
||||
style={{ position: "fixed", right: style.right, top: style.top, zIndex: 60 }}
|
||||
className={menuClass}
|
||||
>
|
||||
{children}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentSection({
|
||||
agent,
|
||||
onSectionChange,
|
||||
@@ -100,16 +198,27 @@ export function AgentSection({
|
||||
onSectionChange: (next: AgentSettings) => void;
|
||||
}) {
|
||||
const [catalog, setCatalog] = useState<ModelCatalogEntry[] | null>(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<Record<string, { model: { provider: string; id: string }; thinkingLevel?: string }> | 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);
|
||||
/** 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<HTMLSpanElement>(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<string, { model: { provider: string; id: string }; thinkingLevel?: string }> }) => {
|
||||
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) => (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="menu-default"
|
||||
onClick={onPick}
|
||||
className={`flex w-full items-center justify-between rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-muted ${
|
||||
active ? "bg-muted/60 text-card-foreground" : "text-card-foreground"
|
||||
}`}
|
||||
>
|
||||
<span>Default</span>
|
||||
{active && <span aria-hidden>✓</span>}
|
||||
</button>
|
||||
);
|
||||
|
||||
const segmentRow = (info: SegmentInfo) => {
|
||||
const fileExists = info.overrideText !== null;
|
||||
const open = openSegment === info.path;
|
||||
@@ -154,8 +309,9 @@ export function AgentSection({
|
||||
<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"}
|
||||
hint={fileExists ? undefined : "no override file yet — write one below"}
|
||||
testid={`prompt-row-${info.path}`}
|
||||
dimmed={!fileExists}
|
||||
>
|
||||
<Toggle
|
||||
checked={info.enabled && fileExists}
|
||||
@@ -224,86 +380,140 @@ export function AgentSection({
|
||||
|
||||
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))}
|
||||
<SettingsGroup label="Models" testid="group-models">
|
||||
{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 (
|
||||
<SettingsRow
|
||||
key={role}
|
||||
label={ROLE_LABEL[role]}
|
||||
hint={`Default: ${defaultName}`}
|
||||
testid={`role-row-${role}`}
|
||||
>
|
||||
{/* The anchor for the outside-click contract is the
|
||||
span holding BOTH triggers of the open menu. */}
|
||||
<span
|
||||
ref={thisMenuOpen ? menuAnchorRef : undefined}
|
||||
className="relative flex items-center gap-2"
|
||||
>
|
||||
<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>
|
||||
<MenuTrigger
|
||||
value={modelId ? prettyModel(modelId) : ""}
|
||||
emptyLabel="Default"
|
||||
ariaLabel={`${ROLE_LABEL[role]} model`}
|
||||
testid={`role-model-${role}`}
|
||||
open={thisMenuOpen === "model"}
|
||||
onClick={() => setOpenMenu(thisMenuOpen === "model" ? null : { role, kind: "model" })}
|
||||
widthClass="min-w-20"
|
||||
/>
|
||||
<MenuTrigger
|
||||
value={thinking}
|
||||
emptyLabel="Default"
|
||||
ariaLabel={`${ROLE_LABEL[role]} thinking`}
|
||||
testid={`role-thinking-${role}`}
|
||||
open={thisMenuOpen === "thinking"}
|
||||
onClick={() => setOpenMenu(thisMenuOpen === "thinking" ? null : { role, kind: "thinking" })}
|
||||
widthClass="w-20"
|
||||
/>
|
||||
{thisMenuOpen === "model" && (
|
||||
<AnchoredMenu
|
||||
anchorTestid={`role-model-${role}`}
|
||||
testid={`role-model-menu-${role}`}
|
||||
menuClass="flex w-[22rem] flex-col rounded-xl border border-border bg-card p-2 text-xs shadow-xl"
|
||||
>
|
||||
{defaultRow(
|
||||
modelId === "",
|
||||
() => {
|
||||
onSectionChange(composeRoleModel(agent, role, "", thinking));
|
||||
setOpenMenu(null);
|
||||
},
|
||||
)}
|
||||
<ModelMenuList
|
||||
models={catalog ?? undefined}
|
||||
effectiveId={modelId || undefined}
|
||||
onPick={(entry) => {
|
||||
onSectionChange(composeRoleModel(agent, role, entry.id, thinking));
|
||||
setOpenMenu(null);
|
||||
}}
|
||||
footer="Applied to every run of this role"
|
||||
/>
|
||||
</AnchoredMenu>
|
||||
)}
|
||||
{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>
|
||||
{thisMenuOpen === "thinking" && (
|
||||
<AnchoredMenu
|
||||
anchorTestid={`role-thinking-${role}`}
|
||||
testid={`role-thinking-menu-${role}`}
|
||||
menuClass="flex w-44 flex-col gap-0.5 rounded-xl border border-border bg-card p-2 text-xs shadow-xl"
|
||||
>
|
||||
{defaultRow(
|
||||
thinking === "",
|
||||
() => {
|
||||
onSectionChange(composeRoleModel(agent, role, modelId, ""));
|
||||
setOpenMenu(null);
|
||||
},
|
||||
)}
|
||||
<ThinkingMenuList
|
||||
levels={levels.map(String)}
|
||||
effectiveLevel={thinking || undefined}
|
||||
onPick={(level) => {
|
||||
onSectionChange(composeRoleModel(agent, role, modelId, level));
|
||||
setOpenMenu(null);
|
||||
}}
|
||||
/>
|
||||
</AnchoredMenu>
|
||||
)}
|
||||
</span>
|
||||
</SettingsRow>
|
||||
);
|
||||
})}
|
||||
</SettingsGroup>
|
||||
|
||||
<SettingsGroup label="Tools" testid="group-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>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</SettingsGroup>
|
||||
|
||||
{/* ── 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))}
|
||||
<SettingsGroup label="System prompts" testid="group-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>
|
||||
</div>
|
||||
))}
|
||||
{segmentStatus && <p className="text-xs text-muted-foreground/70">{segmentStatus}</p>}
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
{segmentStatus && <p className="text-xs text-muted-foreground/70">{segmentStatus}</p>}
|
||||
</div>
|
||||
)}
|
||||
</SettingsGroup>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<>
|
||||
<SettingsRow label="Mode" hint="Light mode ships with the design wave" testid="row-mode">
|
||||
<SettingsRow label="Mode" hint="Light ships with the design wave" testid="row-mode" dimmed>
|
||||
<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"})`}
|
||||
hint={`Empty = ${resolved.background ?? "#141414"}`}
|
||||
testid="row-background"
|
||||
>
|
||||
<CommitInput
|
||||
@@ -53,7 +54,7 @@ export function AppearanceSection({
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
label="Reading font"
|
||||
hint="Font family name, by name — everything that reads today's Source Sans 3. Empty = Source Sans 3"
|
||||
hint="Empty = Source Sans 3"
|
||||
testid="row-font"
|
||||
>
|
||||
<CommitInput
|
||||
@@ -64,7 +65,7 @@ export function AppearanceSection({
|
||||
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">
|
||||
<SettingsRow label="Reading font size" hint={`${resolved.proseFontSize}px`} testid="row-prose-size">
|
||||
<CommitInput
|
||||
type="number"
|
||||
value={String(appearance?.proseFontSize ?? "")}
|
||||
@@ -78,14 +79,14 @@ export function AppearanceSection({
|
||||
}}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow label="Session names in serif" hint="CMU Serif titles on/off" testid="row-session-serif">
|
||||
<SettingsRow label="Session names in serif" 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">
|
||||
<SettingsRow label="Heading weight" hint={`${resolved.mdRendering.headingWeight}`} testid="row-heading-weight">
|
||||
<CommitInput
|
||||
type="number"
|
||||
value={String(md?.headingWeight ?? "")}
|
||||
@@ -102,7 +103,7 @@ export function AppearanceSection({
|
||||
}}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow label="Bold color" hint="**Bold** spans in teaching prose. Empty = the default ink" testid="row-bold-color">
|
||||
<SettingsRow label="Bold color" hint="Empty = #c96b4a" testid="row-bold-color">
|
||||
<CommitInput
|
||||
value={md?.boldColor ?? ""}
|
||||
placeholder="#c96b4a"
|
||||
@@ -111,7 +112,7 @@ export function AppearanceSection({
|
||||
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">
|
||||
<SettingsRow label="Italic color" hint="Empty = #6fa9d6" testid="row-italic-color">
|
||||
<CommitInput
|
||||
value={md?.italicColor ?? ""}
|
||||
placeholder="#6fa9d6"
|
||||
@@ -128,12 +129,11 @@ 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" },
|
||||
{ key: "enabled", label: "Graph", needs: null },
|
||||
{ key: "embedding", label: "Embedding", needs: "enabled" },
|
||||
{ key: "clustering", label: "Clustering", needs: "embedding" },
|
||||
{ key: "labeling", label: "Labeling", needs: "clustering" },
|
||||
];
|
||||
|
||||
export function GraphSection({
|
||||
@@ -159,11 +159,11 @@ export function GraphSection({
|
||||
};
|
||||
return (
|
||||
<>
|
||||
{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 (
|
||||
<SettingsRow key={key} label={label} hint={!parentOn ? `${hint} — needs ${needs} on` : hint} testid={`graph-row-${key}`}>
|
||||
<SettingsRow key={key} label={label} testid={`graph-row-${key}`} dimmed={!parentOn}>
|
||||
<Toggle
|
||||
checked={stored[key]}
|
||||
disabled={!parentOn}
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
"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).
|
||||
* Thin settings primitives (settings-app wave): the control kit the
|
||||
* settings dialog needs. Sections → groups → rows; everything SSRs clean
|
||||
* (render tests pin the sections' markup).
|
||||
*
|
||||
* Everything SSRs clean (render tests pin the sections' markup).
|
||||
* The dependency graying (owner, 2026-09-20) follows the graph viewer's
|
||||
* render-panel convention: the WHOLE row dims (opacity-40, label and
|
||||
* control together) and the control itself locks — the row owns the dim.
|
||||
*/
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** A section block: header + rows, separated by the app's border color. */
|
||||
/** A section block: pronounced mono heading + content. */
|
||||
export function SettingsSection({
|
||||
title,
|
||||
hint,
|
||||
@@ -18,20 +19,39 @@ export function SettingsSection({
|
||||
testid,
|
||||
}: {
|
||||
title: string;
|
||||
/** One-line quiet note under the header (what the section controls). */
|
||||
/** One quiet line under the header — only where it earns its place. */
|
||||
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 data-testid={testid} className="px-6 py-5">
|
||||
<h2 className="text-sm uppercase tracking-widest text-foreground">{title}</h2>
|
||||
{hint && <p className="mt-1 text-xs text-muted-foreground">{hint}</p>}
|
||||
<div className="mt-4 flex flex-col gap-3">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<div data-testid={testid} className="mt-5 border-t border-border/60 pt-4 first:mt-0 first:border-t-0 first:pt-0">
|
||||
<p className="mb-2 text-xs uppercase tracking-widest text-muted-foreground">{label}</p>
|
||||
<div className="flex flex-col gap-2">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<div data-testid={testid} className="flex items-center justify-between gap-4 py-1">
|
||||
<div data-testid={testid} className={cn("flex items-center justify-between gap-4 py-1", dimmed && "opacity-40")}>
|
||||
<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>}
|
||||
@@ -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",
|
||||
)}
|
||||
>
|
||||
<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",
|
||||
"absolute left-0 top-0.5 size-3 rounded-full transition-transform",
|
||||
// 2px inset from each inner edge, both states (owner round: the on
|
||||
// disc used to touch the pill's right edge).
|
||||
checked ? "translate-x-4 bg-foreground" : "translate-x-0.5 bg-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
@@ -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}
|
||||
|
||||
@@ -155,7 +155,7 @@ export function KeyboardSection() {
|
||||
);
|
||||
})}
|
||||
<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>
|
||||
<span className="text-xs text-muted-foreground/70">Stored on this device (lh:keymap) — overrides only.</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetAll}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
"use client";
|
||||
/**
|
||||
* The model menu (extracted 2026-09-20 from model-picker.tsx, owner ask:
|
||||
* the agent settings' role selects use the EXACT same dropdown as the
|
||||
* chat model selector — same look, same machine). The fuzzy search +
|
||||
* arrow-walk + Enter pick machine lives here once; each mount owns only
|
||||
* the popover shell (positioning, outside-click, Esc — the chat control
|
||||
* opens UP from the bottom bar, the settings rows open DOWN inside the
|
||||
* dialog).
|
||||
*
|
||||
* Keyboard machine (owner polish, 2026-09-17): 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.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { ModelCatalogEntry } from "@/src/core";
|
||||
import { fuzzyScore } from "@/src/core";
|
||||
import { formatTokens } from "@/components/usage-format";
|
||||
|
||||
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)}`;
|
||||
}
|
||||
|
||||
/** The model dropdown body — fuzzy search + ranked list. `models ===
|
||||
* undefined` is the catalog-loading state; an empty result list gets
|
||||
* its own quiet line. */
|
||||
export function ModelMenuList({
|
||||
models,
|
||||
effectiveId,
|
||||
onPick,
|
||||
footer,
|
||||
}: {
|
||||
/** The full catalog — undefined while it loads. */
|
||||
models: ModelCatalogEntry[] | undefined;
|
||||
/** Currently effective id (the row gets the quiet highlight). */
|
||||
effectiveId: string | undefined;
|
||||
/** Receives the chosen entry. */
|
||||
onPick: (entry: ModelCatalogEntry) => void;
|
||||
/** Quiet footer note under the list (context-specific). */
|
||||
footer?: string;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [menuIdx, setMenuIdx] = useState<number | null>(null);
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
const listRef = useRef<HTMLUListElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
searchRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
// Keep the arrow-walked result visible in the scrolling list.
|
||||
useEffect(() => {
|
||||
if (menuIdx === null) return;
|
||||
listRef.current?.querySelector(`[data-idx="${menuIdx}"]`)?.scrollIntoView({ block: "nearest" });
|
||||
}, [menuIdx]);
|
||||
|
||||
// The filtered, ranked model list.
|
||||
const results = (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 (
|
||||
<>
|
||||
<input
|
||||
ref={searchRef}
|
||||
data-testid="model-search"
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setMenuIdx(null); // new query → the walk restarts on Enter/↓
|
||||
}}
|
||||
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();
|
||||
onPick(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"
|
||||
/>
|
||||
{!models && <div className="px-2 py-3 text-center text-muted-foreground">Loading models…</div>}
|
||||
{models && results.length === 0 && (
|
||||
<div className="px-2 py-3 text-center text-muted-foreground">No models match.</div>
|
||||
)}
|
||||
<ul ref={listRef} data-testid="model-list" className="flex max-h-72 flex-col overflow-y-auto px-1">
|
||||
{results.map((m, i) => (
|
||||
<li key={`${m.provider}/${m.id}`}>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`model-option-${m.id.replace(/[^a-z0-9-]/gi, "_")}`}
|
||||
data-idx={i}
|
||||
onClick={() => onPick(m)}
|
||||
className={`flex w-full flex-col gap-0.5 rounded-lg px-2 py-1.5 text-left outline-none transition-colors hover:bg-muted ${
|
||||
i === menuIdx ? "bg-accent/40 ring-1 ring-inset ring-border" : ""
|
||||
}${m.id === effectiveId && i !== menuIdx ? " bg-muted/60" : ""}`}
|
||||
>
|
||||
<span className="flex items-center justify-between gap-2">
|
||||
<span className="truncate text-card-foreground">{m.name}</span>
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground" title="USD per 1M tokens in/out">
|
||||
{priceLabel(m.cost)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex items-center justify-between gap-2 text-muted-foreground">
|
||||
<span className="truncate">{m.id}</span>
|
||||
<span className="shrink-0 tabular-nums">{formatTokens(m.contextWindow)}</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{footer && (
|
||||
<div className="mt-1 border-t px-2 pt-1.5 text-xs text-muted-foreground">{footer}</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 ? (
|
||||
<div className="px-2 py-3 text-center text-muted-foreground">{empty ?? "No thinking levels."}</div>
|
||||
) : (
|
||||
levels.map((level) => (
|
||||
<button
|
||||
key={level}
|
||||
type="button"
|
||||
data-testid={`thinking-option-${level}`}
|
||||
onClick={() => onPick(level)}
|
||||
className={`flex items-center justify-between rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-muted ${
|
||||
level === effectiveLevel ? "bg-muted/60 text-card-foreground" : "text-card-foreground"
|
||||
}`}
|
||||
>
|
||||
<span>{level}</span>
|
||||
{level === effectiveLevel && <span aria-hidden>✓</span>}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
{footer && (
|
||||
<div className="mt-1 border-t px-2 pt-1.5 text-xs text-muted-foreground">{footer}</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<DialogContext.Provider value={{ open }}>
|
||||
{children}
|
||||
{show && <SettingsDialog onClose={() => setShow(false)} />}
|
||||
</DialogContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useSettingsDialog() {
|
||||
return useContext(DialogContext);
|
||||
}
|
||||
|
||||
function SettingsDialog({ onClose }: { onClose: () => void }) {
|
||||
// Plain dim backdrop (no blur — nothing decorative); the click
|
||||
// closes, the dialog body stops propagation.
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-background/80"
|
||||
data-testid="settings-backdrop"
|
||||
onClick={onClose}
|
||||
>
|
||||
<SettingsDialogBody onClose={onClose} />
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
/** The dialog without the portal/backdrop — the SSR-testable frame
|
||||
* (portals render nothing under renderToStaticMarkup). */
|
||||
export function SettingsDialogBody({ onClose }: { onClose: () => void }) {
|
||||
const { onAppearanceChange } = useAppearance();
|
||||
const [record, setRecord] = useState<UserSettings | null | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [active, setActive] = useState<SectionId>("general");
|
||||
|
||||
// Fetch once per dialog mount. 200 {settings: null} is FIRST BOOT
|
||||
// (no record = factory everything), not a failure — the error state is
|
||||
// a non-200 or a network throw only.
|
||||
useEffect(() => {
|
||||
fetch("/api/settings")
|
||||
.then((r) =>
|
||||
r.ok ? r.json() : Promise.reject(new Error(`status ${r.status}`)),
|
||||
)
|
||||
.then((body: { settings: UserSettings | null }) =>
|
||||
setRecord(body.settings ?? { version: CURRENT_SETTINGS_VERSION }),
|
||||
)
|
||||
.catch(() => setRecord(null));
|
||||
}, []);
|
||||
|
||||
// Escape closes — the layered-dismiss convention (Esc is dead for the
|
||||
// sections' own editors while they hold focus; this listener is the
|
||||
// dialog's outer layer).
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
|
||||
/** ONE writer for the whole dialog: PUT the whole section, keep the
|
||||
* record mirror in sync, surface route errors inline (a 400 is a
|
||||
* schema mistake caught by the validation surface). */
|
||||
const write = useCallback(
|
||||
async (
|
||||
section: "general" | "agent" | "appearance" | "graph",
|
||||
body: object,
|
||||
) => {
|
||||
setError(null);
|
||||
const res = await fetch(`/api/settings/${section}`, {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const detail = (await res.json().catch(() => ({}))) as {
|
||||
error?: string;
|
||||
};
|
||||
setError(`${section}: ${detail.error ?? res.status}`);
|
||||
return;
|
||||
}
|
||||
setRecord(
|
||||
(prev) => ({ ...(prev ?? {}), [section]: body }) as UserSettings,
|
||||
);
|
||||
if (section === "appearance")
|
||||
onAppearanceChange(body as AppearanceSettings);
|
||||
},
|
||||
[onAppearanceChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Settings"
|
||||
data-testid="settings-dialog"
|
||||
className="settings-dialog-pop flex h-[620px] max-h-[85vh] w-[920px] max-w-[90vw] flex-col overflow-hidden rounded-md border border-border bg-card shadow-xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header — the app's quiet CMU title language. */}
|
||||
<header className="flex h-15 shrink-0 items-center gap-3 border-b border-border px-5">
|
||||
<h2 className="font-title text-2xl tracking-tight">Settings</h2>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close settings"
|
||||
data-testid="settings-close"
|
||||
className="ml-auto size-7 rounded-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
onClick={onClose}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
className="mx-auto size-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
>
|
||||
<path d="M4 4l8 8M12 4l-8 8" />
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="flex min-h-0 flex-1">
|
||||
{/* Left nav — text-only, the session-list mono language,
|
||||
active = bg-accent. */}
|
||||
<nav
|
||||
aria-label="Settings sections"
|
||||
data-testid="settings-nav"
|
||||
className="flex w-44 shrink-0 flex-col gap-1 border-r border-border p-2"
|
||||
>
|
||||
{SECTIONS.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
data-testid={`settings-nav-${s.id}`}
|
||||
aria-current={active === s.id}
|
||||
className={`rounded-sm px-3 py-1.5 text-left font-ui text-sm transition-colors ${
|
||||
active === s.id
|
||||
? "bg-accent text-foreground"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
}`}
|
||||
onClick={() => setActive(s.id)}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Content column — one section at a time, this scrolls. */}
|
||||
<div
|
||||
className="min-w-0 flex-1 overflow-y-auto"
|
||||
data-testid="settings-content"
|
||||
>
|
||||
{error && (
|
||||
<p
|
||||
role="alert"
|
||||
className="m-4 rounded-sm border border-red-500/40 px-3 py-2 text-xs text-red-500"
|
||||
data-testid="settings-error"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{record === undefined ? (
|
||||
<p className="px-6 py-5 text-xs text-muted-foreground/70">
|
||||
Loading settings…
|
||||
</p>
|
||||
) : record === null ? (
|
||||
<p className="px-6 py-5 text-xs text-muted-foreground/70">
|
||||
Settings could not be loaded — refresh to retry.
|
||||
</p>
|
||||
) : (
|
||||
<div>
|
||||
{active === "general" && (
|
||||
<SettingsSection
|
||||
title="General"
|
||||
testid="section-general"
|
||||
>
|
||||
<SettingsRow
|
||||
label="Auto-update"
|
||||
hint="The update mechanism ships in a later wave"
|
||||
testid="row-auto-update"
|
||||
>
|
||||
<Toggle
|
||||
checked={record.general?.autoUpdate ?? true}
|
||||
label="auto update"
|
||||
onChange={(on) =>
|
||||
void write("general", { autoUpdate: on })
|
||||
}
|
||||
/>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
)}
|
||||
{active === "agent" && (
|
||||
<SettingsSection
|
||||
title="Agent"
|
||||
testid="section-agent"
|
||||
>
|
||||
<AgentSection
|
||||
agent={record.agent}
|
||||
onSectionChange={(next: AgentSettings) =>
|
||||
void write("agent", next)
|
||||
}
|
||||
/>
|
||||
</SettingsSection>
|
||||
)}
|
||||
{active === "appearance" && (
|
||||
<SettingsSection
|
||||
title="Appearance"
|
||||
testid="section-appearance"
|
||||
>
|
||||
<AppearanceSection
|
||||
appearance={record.appearance}
|
||||
onSectionChange={(next: AppearanceSettings) =>
|
||||
void write("appearance", next)
|
||||
}
|
||||
/>
|
||||
</SettingsSection>
|
||||
)}
|
||||
{active === "graph" && (
|
||||
<SettingsSection
|
||||
title="Graph"
|
||||
testid="section-graph"
|
||||
>
|
||||
<GraphSection
|
||||
graph={record.graph}
|
||||
onSectionChange={(next: GraphSettings) =>
|
||||
void write("graph", next)
|
||||
}
|
||||
/>
|
||||
</SettingsSection>
|
||||
)}
|
||||
{active === "keyboard" && (
|
||||
<SettingsSection
|
||||
title="Keyboard"
|
||||
hint="Stored on this device"
|
||||
testid="section-keyboard"
|
||||
>
|
||||
<KeyboardSection />
|
||||
</SettingsSection>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<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>
|
||||
);
|
||||
}
|
||||
+11
-8
@@ -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). */}
|
||||
<ActivitySection />
|
||||
|
||||
{/* 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). */}
|
||||
<div className="px-3 pb-3 pt-1">
|
||||
<Link
|
||||
href="/settings"
|
||||
<button
|
||||
type="button"
|
||||
onClick={openSettings}
|
||||
data-testid="settings-entry"
|
||||
className="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs uppercase tracking-wider text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<SettingsIcon className="size-3" />
|
||||
Settings
|
||||
</Link>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -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 (
|
||||
<>
|
||||
<SettingsDialogProvider>
|
||||
<SessionsSidebar />
|
||||
<main className="min-w-0 flex-1">{children}</main>
|
||||
{/* 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. */}
|
||||
<KeyboardShortcuts />
|
||||
</>
|
||||
</SettingsDialogProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
<SettingsDialogProvider>
|
||||
<p>the app</p>
|
||||
</SettingsDialogProvider>,
|
||||
);
|
||||
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(<SettingsDialogBody onClose={() => {}} />);
|
||||
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(<AgentSection agent={undefined} onSectionChange={() => {}} />);
|
||||
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(<AppearanceSection appearance={undefined} onSectionChange={() => {}} />);
|
||||
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", () => {
|
||||
|
||||
Reference in New Issue
Block a user