diff --git a/app/chat/[id]/page.tsx b/app/chat/[id]/page.tsx index 71f4af0..627dc7a 100644 --- a/app/chat/[id]/page.tsx +++ b/app/chat/[id]/page.tsx @@ -25,7 +25,6 @@ export default async function ChatPage({ // A teacher turn that outlived its client: the remounted view must // attach (busy shimmer + poll), never resurrect the stale record. initiallyInFlight={isTurnRunning(chat.id)} - initialSettings={chat.settings} /> ); } diff --git a/components/chat.tsx b/components/chat.tsx index dbb403f..849cffe 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -118,7 +118,6 @@ export function Chat({ initialMessages, initialPrompt, initiallyInFlight, - initialSettings, }: { chatId: string; title: string; @@ -127,8 +126,6 @@ export function Chat({ /** A teacher turn is running server-side but attached to no client * (navigated away mid-turn, returned while in flight). */ initiallyInFlight?: boolean; - /** The record's per-chat model/thinking settings (the picker's seed). */ - initialSettings?: ChatSettings; }) { const { messages, sendMessage, addToolOutput, setMessages, status, error, stop } = useChat({ @@ -445,7 +442,6 @@ export function Chat({ const [turns, setTurns] = useState([]); // The per-chat model control state (model-picker writes via PUT; the // record is the source of truth, lifted here so the stats bar re-renders). - const [settings, setSettings] = useState(initialSettings); // The dock's reserved tail height (DockOverlay reports it; the spacer at // the end of the transcript renders it). Lives here because the spacer is // inside the scroller while the dock overlays OUTSIDE it — two parents. @@ -963,8 +959,6 @@ export function Chat({ } onOpenPlan={() => setPlanOpen(true)} requiresReveal={requiresReveal} @@ -1054,8 +1048,6 @@ function DockOverlay({ composer, onSubmitAnswer, chatId, - settings, - onSettingsSaved, onReserve, plan, onOpenPlan, @@ -1073,8 +1065,6 @@ function DockOverlay({ onSubmitAnswer: (output: QuestionAnswerData) => void; /** The model control's pass-through (the stats bar hosts it). */ chatId: string; - settings?: ChatSettings; - onSettingsSaved: (settings: ChatSettings) => void; /** Reports the dock's settled height (block + gradient) — ChatView's * dockReserve state feeds the transcript's spacer from it. */ onReserve: (h: number) => void; @@ -1351,9 +1341,6 @@ function DockOverlay({ morphing shell so it never joins a height animation. */} diff --git a/components/home.tsx b/components/home.tsx index c8be107..79a0cb0 100644 --- a/components/home.tsx +++ b/components/home.tsx @@ -167,15 +167,11 @@ export function Home({ initialSettings, initialConfigured }: { initialSettings: {/* Same as the chat view's under-composer stats bar (owner) — no session yet, so the free LEFT side carries the knowledge-context - budget (session-data slot), while the RIGHT keeps the model + - thinking control (deferred save). */} + budget (session-data slot), while the RIGHT keeps the strong- + slot control (fix wave: the pills write /api/settings/models). */} } turns={[] as TurnUsage[]} - chatId="" - noDefaultModel - settings={settings} - onSettingsSaved={saveDraft} leftSlot={ | undefined; -function fetchCatalog(): Promise { +function fetchCatalog(force = false): Promise { + if (force) { + catalogCache = undefined; + catalogInflight = undefined; + } catalogInflight ??= fetch("/api/models") .then((r) => { if (!r.ok) throw new Error(`catalog request failed: ${r.status}`); @@ -37,13 +30,13 @@ function fetchCatalog(): Promise { return c; }) .catch((err) => { - catalogInflight = undefined; // retry on next mount + catalogInflight = undefined; // retry on next mount/open throw err; }); return catalogInflight; } -function useModelCatalog(): ModelCatalogResponse | undefined { +function useModelCatalog(): { catalog: ModelCatalogResponse | undefined; refresh: () => void } { const [catalog, setCatalog] = useState(catalogCache); useEffect(() => { if (catalogCache) return; @@ -51,14 +44,21 @@ function useModelCatalog(): ModelCatalogResponse | undefined { fetchCatalog() .then((c) => alive && setCatalog(c)) .catch(() => { - /* the picker stays closed on failure; the buttons still display - the effective model from props */ + /* the picker stays closed on failure; the buttons still + display the strong slot from the record fetch */ }); return () => { alive = false; }; }, []); - return catalog; + const refresh = useCallback(() => { + fetchCatalog(true) + .then(setCatalog) + .catch(() => { + /* refresh is best-effort: the cached list stays on failure */ + }); + }, []); + return { catalog, refresh }; } const LEVEL_LABEL: Record = { @@ -71,43 +71,51 @@ const LEVEL_LABEL: Record = { max: "Max", }; - -export function ModelControl({ - chatId, - settings, - onSaved, - fallbackModelId, - noDefaultModel, -}: { - chatId: string; - /** The record's current settings (undefined = config pin). */ - settings?: ChatSettings; - /** Lifts the persisted settings into the chat view's state. */ - onSaved: (settings: ChatSettings) => void; - /** The last turn's actual model (from the turns log) — the label until - * the catalog (with config's defaults) finishes loading. */ - fallbackModelId?: string; - /** Default-surfaces only (owner, 2026-09-18): NO baked-in default — - * unset settings render "no model"/"—" (BYOK first boot), never the - * config pin. Chat surfaces keep the catalog-default fallback (the - * engine runs the pin, so the control shows what will run). */ - noDefaultModel?: boolean; -}) { - const catalog = useModelCatalog(); +/** + * THE STRONG-SLOT CONTROL (fix wave, owner ruling: "one and the same"). + * The composer's model + thinking pills ARE the strong slot — the tier + * that runs prober + teacher. Reads: the raw models section (GET + * /api/settings) for the user's pick, plus the federated catalog for the + * menus. Writes: PUT /api/settings/models via composeSlotModel — the same + * seam the settings Agent section and the SetupDialog write, so there is + * exactly one strong slot in the world and every surface edits it. + * + * Unpicked reads "—" (the no-factory-fallback language: the UI never + * presents a default model; the engine-side pin still resolves, the UI + * never claims it). Legacy per-chat model overrides on old records keep + * resolving server-side — the UI simply no longer writes that field + * anywhere. + */ +export function ModelControl() { + const { catalog, refresh } = useModelCatalog(); + const [models, setModels] = useState(); const [open, setOpen] = useState<"model" | "thinking" | null>(null); const [error, setError] = useState(null); const containerRef = useRef(null); - const defaults = catalog?.defaults; - const effectiveId = noDefaultModel - ? settings?.model?.id - : (settings?.model?.id ?? defaults?.model.id ?? fallbackModelId); - const effectiveLevel = noDefaultModel - ? settings?.thinkingLevel - : (settings?.thinkingLevel ?? defaults?.thinkingLevel); - const entry = catalog?.models.find((m) => m.id === effectiveId); - const levels = entry?.supportedLevels ?? []; + useEffect(() => { + let alive = true; + fetch("/api/settings") + .then((r) => r.json() as Promise<{ settings: { models?: ModelsSettings } | null }>) + .then((body) => alive && setModels(body.settings?.models ?? undefined)) + .catch(() => { + /* the record fetch failing leaves the control read-only + ("—" triggers, closed menus) — same posture as a + catalog failure */ + }); + return () => { + alive = false; + }; + }, []); + const override = models?.strong; + const effectiveId = override?.model?.id; + const effectiveProvider = override?.model?.provider; + const effectiveLevel = override?.thinkingLevel; + const entry = catalog?.models.find( + (m) => m.id === effectiveId && m.provider === effectiveProvider, + ); + const levels = entry?.supportedLevels ?? []; // The intuitive dropdown contract (owner, stats-control round 1): any // click outside the open menu — or an Esc keypress — closes it. Only the @@ -136,18 +144,14 @@ export function ModelControl({ }; }, [open]); + // One write seam: composeSlotModel preserves the cheap/vision siblings + // (replace-whole-section discipline) and the per-field independence of + // model + thinkingLevel within the strong slot. const save = useCallback( - async (next: ChatSettings) => { + async (next: ModelsSettings) => { setError(null); - // Home view (pre-chat): defer — the selection rides along on the - // chat POST and is PUT after creation (onSaved is still lifted). - if (!chatId) { - onSaved(next); - setOpen(null); - return; - } try { - const res = await fetch(`/api/chats/${chatId}/settings`, { + const res = await fetch("/api/settings/models", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(next), @@ -157,14 +161,13 @@ export function ModelControl({ setError(body.error ?? `settings rejected (${res.status})`); return; } - const { settings: saved } = (await res.json()) as { settings: ChatSettings }; - onSaved(saved); + setModels(next); setOpen(null); } catch { setError("Could not reach the server — try again."); } }, - [chatId, onSaved], + [], ); // Shortcut seams (keyboard wave A): ⌘K opens the model menu (the @@ -175,63 +178,66 @@ export function ModelControl({ const onOpenPicker = () => { setOpen("model"); setError(null); + refresh(); // mid-session key changes widen the menu (cache fix) }; window.addEventListener("lh:kbd-open-model-picker", onOpenPicker); return () => window.removeEventListener("lh:kbd-open-model-picker", onOpenPicker); - }, []); + }, [refresh]); useEffect(() => { const onCycleThinking = () => { if (levels.length === 0) return; const idx = effectiveLevel === undefined ? -1 : levels.indexOf(effectiveLevel); - void save({ ...(settings ?? {}), thinkingLevel: levels[(idx + 1) % levels.length] }); + const next = levels[(idx + 1) % levels.length]; + if (next === undefined) return; + void save(composeSlotModel(models, "strong", effectiveProvider ?? "", effectiveId ?? "", next)); }; window.addEventListener("lh:kbd-cycle-thinking", onCycleThinking); return () => window.removeEventListener("lh:kbd-cycle-thinking", onCycleThinking); - }, [levels, effectiveLevel, save, settings]); + }, [levels, effectiveLevel, effectiveProvider, effectiveId, models, save]); // Selecting a model (click and Enter share one path): keep the // thinking level only when the new model supports it — otherwise - // drop it (config fallback clamps honestly; a carry-over would 400). - // Replace-whole discipline: spread the current settings — a model pick - // must not silently clear a sibling override (graphContextTokens). + // drop it (a carry-over would 400 at the server's catalog gate). const pickModel = (m: ModelCatalogEntry) => { const nextLevel = - effectiveLevel !== undefined && m.supportedLevels.includes(effectiveLevel) - ? effectiveLevel - : undefined; - void save({ ...(settings ?? {}), model: { provider: m.provider, id: m.id }, thinkingLevel: nextLevel }); + effectiveLevel !== undefined && m.supportedLevels.includes(effectiveLevel) ? effectiveLevel : undefined; + void save(composeSlotModel(models, "strong", m.provider, m.id, nextLevel ?? "")); }; + // Stale-while-revalidate on manual open too (⌘K covers the keyboard + // path above): the cached list shows instantly, the fresh federation + // swaps in when it lands. + const openMenu = (kind: "model" | "thinking") => { + setOpen((o) => (o === kind ? null : kind)); + setError(null); + if (kind === "model") refresh(); + }; return (
{/* The shared dropdown body (extracted 2026-09-20): the agent - settings' role selects use the exact same one. */} + settings' slot selects and the setup dialog use the exact same + one. */} {open === "model" && (
)} @@ -267,7 +279,9 @@ export function ModelControl({ void save({ ...(settings ?? {}), thinkingLevel: level as ChatSettings["thinkingLevel"] })} + onPick={(level) => + void save(composeSlotModel(models, "strong", effectiveProvider ?? "", effectiveId ?? "", level as ThinkingLevel)) + } empty={catalog ? (effectiveId ? `${prettyModel(effectiveId)} has no thinking levels.` : "Pick a model first.") : "Loading…"} footer="Levels this model actually supports" /> diff --git a/components/new-session.tsx b/components/new-session.tsx index 00395da..b1c644f 100644 --- a/components/new-session.tsx +++ b/components/new-session.tsx @@ -125,10 +125,6 @@ export function DraftShell({ } turns={[]} - chatId="" - noDefaultModel - settings={settings} - onSettingsSaved={onSettingsSaved} // The session-data slot (owner, 2026-09-15): pre-chat the left // side is free — the knowledge-context budget lives exactly // where session data will sit once a session exists. @@ -161,11 +157,10 @@ export function NewSession({ const [text, setText] = useState(""); const [busy, setBusy] = useState(false); // THE default settings (owner, 2026-09-18): this surface and the home - // topic dock ARE the settings page for the default model — no baked-in - // default exists; the user's last choice (server-rendered in via - // initialSettings, shared with home through data/default-settings.json) - // IS the default. ModelControl with an empty chatId never PUTs — it - // hands the choice up; it still rides the session POST then PUT-on-create. + // topic dock carries only the knowledge-context budget now (fix + // wave): the model pills ARE the strong slot (PUT /api/settings/models, + // app-wide) — the per-chat model pick is retired. saveDraft still + // lifts the context budget so it rides the session POST. const { settings, save: saveDraft } = useDefaultSettings(initialSettings); // First-run gate (model-slots wave, owner 2026-09-20): the send diff --git a/components/settings/agent-section.tsx b/components/settings/agent-section.tsx index 5a3608a..e27150f 100644 --- a/components/settings/agent-section.tsx +++ b/components/settings/agent-section.tsx @@ -388,12 +388,16 @@ export function AgentSection({ .then((body: { prober: SegmentInfo[]; teacher: SegmentInfo[] }) => setPrompts(body)); } - /** The pinned Default row — clears this control's override (absent key - * = the factory pin). Same row language as the shared menu's list rows. */ - const defaultRow = (active: boolean, onPick: () => void) => ( + /** The "—" row — THINKING menus only: clears the thinking pin (the + * slot keeps its model; the tier's resolved budget takes over). Model + * menus carry no such row — unpicking a model would re-arm the + * first-run gate, and a "default" model is not a thing in this app + * (owner, fix wave: no factory fallback is ever presented as a + * choice; an unpicked slot just reads "—"). */ + const noneRow = (active: boolean, onPick: () => void) => (
); } diff --git a/src/server/model-catalog.ts b/src/server/model-catalog.ts index 565f892..bd1da87 100644 --- a/src/server/model-catalog.ts +++ b/src/server/model-catalog.ts @@ -1,17 +1,20 @@ /** - * The model catalog's server surface (model-control wave, 2026-09-12): - * pi-ai's shipped OpenRouter catalog projected to client-safe DTOs for the - * picker (GET /api/models), plus the validation gate for per-chat settings - * (PUT /api/chats/[id]/settings). + * The model catalog's server surface (model-control wave, 2026-09-12; + * federated since the model-slots wave, 2026-09-20): pi-ai's catalog for + * every KEYED provider, projected to client-safe DTOs for the picker (GET + * /api/models), plus the validation gate for settings writes (PUT + * /api/settings/* — the per-chat settings PUT is retired from the UI; + * legacy records' per-chat overrides still resolve server-side). * * pi-ai owns ALL the data: the catalog (id/name/cost/context/modality/ * thinkingLevelMap) and the per-model thinking truth * (getSupportedThinkingLevels / clampThinkingLevel). This module only * projects and validates — no second registry to drift out of sync. * - * Multi-provider future: `getModels()` is called per configured provider, - * so a second provider (pi-ai ships ~30) appears in the picker by adding - * it to piModels() in src/agent/models.ts — nothing here changes. + * Federation: `getModels()` is called per keyed provider (federated, the + * model-slots wave), so a provider appears in the picker exactly when its + * key is present — piModels() in src/agent/models.ts owns the provider + * set; nothing here changes. */ import { getSupportedThinkingLevels, type Api, type Model } from "@earendil-works/pi-ai"; diff --git a/tests/render-settings.test.tsx b/tests/render-settings.test.tsx index 3ac61ad..a8948e7 100644 --- a/tests/render-settings.test.tsx +++ b/tests/render-settings.test.tsx @@ -59,7 +59,7 @@ describe("SettingsDialog (the designed surface, SSR)", () => { }); describe("AgentSection (thin SSR)", () => { - it("renders the three model SLOT rows (slots-only, owner ruling 2026-09-20), Default-triggered dropdowns", () => { + it("renders the three model SLOT rows (slots-only, fix wave: unpicked reads —, no Default option)", () => { const html = renderToStaticMarkup( {}} onModelsChange={() => {}} />); for (const slot of ["strong", "cheap", "vision"]) { assert.ok(html.includes(`data-testid="slot-row-${slot}"`), slot); @@ -70,7 +70,12 @@ describe("AgentSection (thin SSR)", () => { // the roster hint rides every row assert.ok(html.includes("Prober + Teacher") || slot !== "strong", "strong roster"); } - assert.equal((html.match(/>Default<\/span>/g) ?? []).length, 6, "three slots × two Default triggers"); + // Fix wave: no Default anywhere — an unpicked slot reads "—" + // (the owner's no-factory-fallback language), and no menu + // carries a Default row (menu-default testid is retired). + assert.equal((html.match(/>Default<\/span>/g) ?? []).length, 0, "no Default trigger"); + assert.ok(!html.includes('data-testid="menu-default"'), "no Default row"); + assert.equal((html.match(/>—<\/span>/g) ?? []).length, 6, "three slots × two — triggers"); // the per-role rows are GONE (slots-only) for (const role of ["researcher", "demoBuilder", "visualMaker"]) { assert.ok(!html.includes(`data-testid="role-row-${role}"`), `${role} retired`); diff --git a/tests/render-stats-bar.test.tsx b/tests/render-stats-bar.test.tsx index d239c3b..15958e5 100644 --- a/tests/render-stats-bar.test.tsx +++ b/tests/render-stats-bar.test.tsx @@ -24,12 +24,10 @@ function turn(over: Partial = {}): TurnUsage { }; } -const SETTINGS_STUB = (_s: ChatSettings) => {}; - describe("stats bar", () => { it("renders — everywhere when the record has no turns yet (doctrine)", () => { const html = renderToStaticMarkup( - , + , ); assert.match(html, /data-testid="stats-bar"/); assert.match(html, /data-testid="stats-context">— { it("folds context + cost from the turns log (core/prices, no local math)", () => { const html = renderToStaticMarkup( - , + , ); // last turn's last call: 10k in + 1k out = 11k; pi catalog limit // 1_048_576 → "1M" (tables regenerated from pi-ai's catalog) @@ -55,14 +53,15 @@ describe("stats bar", () => { // pi catalog price 0.075/0.25 per 1M: each turn 10k×0.075 + 1k×0.25 // = $0.001 → two = $0.0020 assert.match(html, /data-testid="stats-cost">\$0\.0020]*>\s*]*>Glm 5\.3 Flash<\/span>/); + // model button label: SSR (record fetch not run yet) reads "—" — + // the strong slot arrives client-side via GET /api/settings; the + // turn log below still folds the ACTUAL models billed + assert.match(html, /data-testid="stats-model"[^>]*>\s*]*>—<\/span>/); }); it("shows — for cost when every turn is unpriceable, never a fake $0", () => { const html = renderToStaticMarkup( - , + , ); assert.match(html, /data-testid="stats-cost">—