diff --git a/components/model-picker.tsx b/components/model-picker.tsx index e2f6f34..5819ff9 100644 --- a/components/model-picker.tsx +++ b/components/model-picker.tsx @@ -8,6 +8,7 @@ import { ModelMenuList, ThinkingMenuList } from "@/components/settings/model-men import { composeSlotModel } from "@/components/settings/agent-section"; import { MenuShell } from "@/components/menu-shell"; import { notifySettingsSaved, useSettingsSaved } from "@/components/settings-signal"; +import { defaultOrderLevel, populateLevel, THINKING_LABEL } from "@/components/thinking-default"; /** GET /api/models is cached per page load for instant menu opens, but * the federation is LIVE per call — keys saved mid-session widen the @@ -63,16 +64,6 @@ function useModelCatalog(): { catalog: ModelCatalogResponse | undefined; refresh return { catalog, refresh }; } -const LEVEL_LABEL: Record = { - off: "Off", - minimal: "Minimal", - low: "Low", - medium: "Medium", - high: "High", - xhigh: "Xhigh", - max: "Max", -}; - /** * THE STRONG-SLOT CONTROL (fix wave, owner ruling: "one and the same"). * The composer's model + thinking pills ARE the strong slot — the tier @@ -120,11 +111,16 @@ export function ModelControl() { const override = models?.strong; const effectiveId = override?.model?.id; const effectiveProvider = override?.model?.provider; - const effectiveLevel = override?.thinkingLevel; + const storedLevel = override?.thinkingLevel; const entry = catalog?.models.find( (m) => m.id === effectiveId && m.provider === effectiveProvider, ); const levels = entry?.supportedLevels ?? []; + // Legacy display (chunk 2 item 4, owner): a record with a model but no + // stored level shows the RESOLVED level — the ordering's first supported + // — because that is exactly what the engine build would run. Display- + // only: the record stays unset until a fresh pick populates it. + const effectiveLevel = storedLevel ?? (entry ? defaultOrderLevel(levels) : undefined); // The intuitive dropdown contract (owner, stats-control round 1): any // click outside the open menu — or an Esc keypress — closes it. The @@ -216,9 +212,16 @@ export function ModelControl() { // thinking level only when the new model supports it — otherwise // 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(composeSlotModel(models, "strong", m.provider, m.id, nextLevel ?? "")); + // Auto-populate (chunk 2, owner: "populate everywhere"): a fresh pick + // ALWAYS writes a level — the STORED one rides as-is (the write clamps + // server-side when the new model doesn't support it; the signal + // re-reads and the clamped truth displays), and a record with no + // stored level populates the ordering's level. The unset state can + // never re-enter through this door. (The DERIVED display level never + // rides — it belongs to the OLD model; the fresh pick's level comes + // from the fresh model's own ladder.) + const nextLevel = populateLevel(storedLevel, m.supportedLevels); + void save(composeSlotModel(models, "strong", m.provider, m.id, nextLevel)); }; // Stale-while-revalidate on manual open too (⌘K covers the keyboard @@ -259,7 +262,7 @@ export function ModelControl() { {/* Just the level — the button reads like the model button reads the model name (owner, stats-control round 1). */} - {effectiveLevel === undefined ? "—" : (LEVEL_LABEL[effectiveLevel] ?? effectiveLevel)} + {effectiveLevel === undefined ? "—" : (THINKING_LABEL[effectiveLevel] ?? effectiveLevel)} ▾ diff --git a/components/settings/agent-section.tsx b/components/settings/agent-section.tsx index da5bca0..e81f8e1 100644 --- a/components/settings/agent-section.tsx +++ b/components/settings/agent-section.tsx @@ -44,6 +44,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { MenuShell } from "@/components/menu-shell"; import { useSettingsSaved } from "@/components/settings-signal"; +import { defaultOrderLevel, populateLevel } from "@/components/thinking-default"; import type React from "react"; import { MODEL_SLOTS, @@ -506,7 +507,10 @@ export function AgentSection({ widthClass="min-w-20" /> { - onModelsChange(composeSlotModel(models, slot, entry.provider, entry.id, thinking)); + // Auto-populate (chunk 2, owner: "populate everywhere"): the + // fresh pick writes a level — the stored one rides as-is + // (the write clamps server-side), else the ordering's level. + const next = populateLevel(thinking || undefined, entry.supportedLevels); + onModelsChange(composeSlotModel(models, slot, entry.provider, entry.id, next)); setOpenMenu(null); }} footer={ @@ -546,7 +554,9 @@ export function AgentSection({ > { onModelsChange(composeSlotModel(models, slot, provider, modelId, level)); setOpenMenu(null); diff --git a/components/setup-dialog.tsx b/components/setup-dialog.tsx index e1a4f7d..9aafd6a 100644 --- a/components/setup-dialog.tsx +++ b/components/setup-dialog.tsx @@ -37,6 +37,9 @@ import { prettyModel } from "@/components/usage-format"; import { ModelMenuList } from "@/components/settings/model-menu"; import { MenuShell } from "@/components/menu-shell"; import { notifySettingsSaved } from "@/components/settings-signal"; +import { defaultOrderLevel, populateLevel, THINKING_LABEL } from "@/components/thinking-default"; +import { ThinkingMenuList } from "@/components/settings/model-menu"; +import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; /** The pickers' slot identity — same roster language as the Agent * section's slot rows. */ @@ -54,7 +57,7 @@ interface ModelsBody { /** The three tiers' RESOLVED pins (BYOK: NULL per unset tier) — the * PREFILL + "needed" marks (owner, 2026-09-22): pinned tiers open * with their pick, unpinned tiers carry the mark. */ - slots?: Record; + slots?: Record; } /** Pure: which stage the flow opens at — no keyed provider = keys first @@ -86,6 +89,18 @@ export function prefillFromSlots( return pre; } +/** Level prefill (chunk 2): a pinned tier opens with its STORED level + * as-is (Save clamps server-side if the ladder lapsed), else the + * ordering's level for that model — the level a fresh pick would + * populate (owner: the ordering's only purpose is first assignment). + * Exported pure for tests. */ +export function prefillLevelFromPin( + entry: ModelCatalogEntry, + pin: { thinkingLevel?: ThinkingLevel } | null | undefined, +): ThinkingLevel { + return populateLevel(pin?.thinkingLevel, entry.supportedLevels); +} + export function SetupDialog({ open, onClose, @@ -103,10 +118,13 @@ export function SetupDialog({ const [body, setBody] = useState(null); const [failed, setFailed] = useState(false); const [picked, setPicked] = useState>>({}); + /** Per-slot thinking level (chunk 2): populated from the ordering on + * every fresh model pick, editable — an explicit pick always wins. */ + const [levels, setLevels] = useState>>({}); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); - const [openSlot, setOpenSlot] = useState(null); - /** The open slot's trigger wrapper — the shared shell's anchor (fix + const [openMenu, setOpenMenu] = useState<{ slot: ModelSlotName; kind: "model" | "thinking" } | null>(null); + /** The open menu's trigger wrapper — the shared shell's anchor (fix * wave chunk B): one menu open at a time, so one ref suffices, and * the menu PORTALS out of this dialog's overflow-hidden frame — it * extends beyond the dialog exactly like the settings menus do. */ @@ -117,6 +135,7 @@ export function SetupDialog({ useEffect(() => { if (!open) return; setPicked({}); + setLevels({}); setError(null); fetch("/api/models") .then((r) => r.json()) @@ -127,7 +146,16 @@ export function SetupDialog({ // pick (matched in the FEDERATED catalog — a lapsed key's pin // does not prefill); unpinned tiers stay empty and carry the // "needed" mark. First-run = nothing pinned → all three marked. - setPicked(prefillFromSlots(b.models, b.slots)); + const pre = prefillFromSlots(b.models, b.slots); + setPicked(pre); + // Levels prefill with the pins (chunk 2): stored level if still + // supported, else the ordering's level for the pinned model. + const preLevels: Partial> = {}; + for (const slot of SLOT_ORDER) { + const entry = pre[slot]; + if (entry) preLevels[slot] = prefillLevelFromPin(entry, b.slots?.[slot]); + } + setLevels(preLevels); }) .catch(() => setFailed(true)); }, [open]); @@ -140,14 +168,16 @@ export function SetupDialog({ if (!open) return; const onKey = (e: KeyboardEvent) => { if (e.key !== "Escape") return; - if (openSlot !== null) setOpenSlot(null); + if (openMenu !== null) setOpenMenu(null); else onClose(); }; const onPointerDown = (e: PointerEvent) => { - if (openSlot === null) return; + if (openMenu === null) return; const t = e.target as Element; - if (t.closest("[data-lh-menu]") || t.closest(`[data-testid="setup-trigger-${openSlot}"]`)) return; - setOpenSlot(null); + const triggerTestid = + openMenu.kind === "model" ? `setup-trigger-${openMenu.slot}` : `setup-thinking-${openMenu.slot}`; + if (t.closest("[data-lh-menu]") || t.closest(`[data-testid="${triggerTestid}"]`)) return; + setOpenMenu(null); }; window.addEventListener("keydown", onKey); document.addEventListener("pointerdown", onPointerDown); @@ -155,7 +185,7 @@ export function SetupDialog({ window.removeEventListener("keydown", onKey); document.removeEventListener("pointerdown", onPointerDown); }; - }, [open, onClose, openSlot]); + }, [open, onClose, openMenu]); if (!open) return null; @@ -167,7 +197,13 @@ export function SetupDialog({ setSaving(true); setError(null); const models: ModelsSettings = {}; - for (const slot of SLOT_ORDER) models[slot] = { model: { provider: picked[slot]!.provider, id: picked[slot]!.id } }; + for (const slot of SLOT_ORDER) { + const level = levels[slot]; + models[slot] = { + model: { provider: picked[slot]!.provider, id: picked[slot]!.id }, + ...(level ? { thinkingLevel: level } : {}), + }; + } const res = await fetch("/api/settings/models", { method: "PUT", headers: { "content-type": "application/json" }, @@ -235,6 +271,8 @@ export function SetupDialog({

{SLOT_ORDER.map((slot) => { const entry = picked[slot]; + const level = levels[slot]; + const levelsForEntry = entry?.supportedLevels ?? []; const catalog = slot === "vision" ? body?.models.filter((m) => m.input.includes("image")) : body?.models; return ( @@ -251,40 +289,91 @@ export function SetupDialog({ )} {SLOT_META[slot].roster} -
- - {openSlot === slot && ( - +
+ + {openMenu?.slot === slot && openMenu.kind === "model" && ( + + { + setPicked((prev) => ({ ...prev, [slot]: m })); + // Auto-populate (chunk 2, owner: "populate + // everywhere"): no explicit level → the + // ordering's level for the fresh pick; an + // explicit one rides (Save clamps server-side). + setLevels((prev) => ({ + ...prev, + [slot]: populateLevel(prev[slot], m.supportedLevels), + })); + setOpenMenu(null); + }} + footer={catalog && catalog.length === 0 ? "No image-capable models — widen the provider keys" : undefined} + /> + + )} +
+ {/* Thinking selector (chunk 2): editable, pre-populated from + the ordering per picked model — no model, no levels. */} +
+ + {openMenu?.slot === slot && openMenu.kind === "thinking" && ( + + { + setLevels((prev) => ({ ...prev, [slot]: lv as ThinkingLevel })); + setOpenMenu(null); + }} + /> + + )} +
); diff --git a/components/thinking-default.ts b/components/thinking-default.ts new file mode 100644 index 0000000..d6841df --- /dev/null +++ b/components/thinking-default.ts @@ -0,0 +1,51 @@ +"use client"; + +/** + * The default-order resolution, CLIENT-SIDE (chunk 2, Platform interlude + * bd68486): the UI mirrors defaultThinkingLevel from src/agent/models.ts + * EXACTLY — first DEFAULT_THINKING_ORDER entry the model supports, else + * the first supported level, else "off". One source of truth for the + * ordering (the core barrel); this helper is its only client + * interpretation, so a fresh model pick can auto-populate the SAME + * level the engine would run for an unset record (owner ruling: the + * ordering's ONLY purpose is first assignment — an explicit pick always + * wins, and after a swap the WRITE clamps server-side, the signal + * re-reads, and the clamped truth displays). + */ +import { DEFAULT_THINKING_ORDER } from "@/src/core"; +import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; + +/** Display labels for the levels (shared by every trigger that shows one). */ +export const THINKING_LABEL: Record = { + off: "Off", + minimal: "Minimal", + low: "Low", + medium: "Medium", + high: "High", + xhigh: "Xhigh", + max: "Max", +}; + +/** The level a fresh pick of `supported` populates with. Empty support + * (shouldn't happen — the catalog always reports at least ["off"]) + * resolves "off", matching the server's fallback. */ +export function defaultOrderLevel(supported: readonly ThinkingLevel[]): ThinkingLevel { + for (const level of DEFAULT_THINKING_ORDER) { + if (supported.includes(level)) return level; + } + return supported[0] ?? "off"; +} + +/** An EXPLICIT level always rides — even when the fresh pick's model + * doesn't support it: the WRITE clamps it to the nearest supported level + * server-side (Platform's interlude bd68486 — the owner's swap-400 case + * now just works), and the cross-surface signal re-reads so the clamped + * truth displays. The ordering only assigns FIRST: when no explicit + * level exists, a fresh model pick populates the ordering's level for + * that model (chunk 2: the unset state can never re-enter). */ +export function populateLevel( + explicit: ThinkingLevel | undefined, + supported: readonly ThinkingLevel[], +): ThinkingLevel { + return explicit ?? defaultOrderLevel(supported); +} \ No newline at end of file diff --git a/tests/byok-refusal.test.ts b/tests/byok-refusal.test.ts index 5c9c4dd..7055728 100644 --- a/tests/byok-refusal.test.ts +++ b/tests/byok-refusal.test.ts @@ -1,7 +1,7 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { parseModelsRefusal } from "../components/chat.tsx"; -import { prefillFromSlots, setupStage } from "../components/setup-dialog.tsx"; +import { prefillFromSlots, prefillLevelFromPin, setupStage } from "../components/setup-dialog.tsx"; import { isModelsRefusal, modelsNotConfigured } from "../src/core/settings.ts"; import type { ModelCatalogEntry, ModelSlotName } from "../src/core/index.ts"; @@ -90,6 +90,24 @@ describe("prefillFromSlots — the SetupDialog prefill (BYOK wave)", () => { }); }); +describe("prefillLevelFromPin — the setup dialog's level prefill (chunk 2)", () => { + const entry = (levels: string[]) => + ({ id: "m", name: "M", provider: "p", reasoning: true, input: ["text"], supportedLevels: levels, cost: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0 }, contextWindow: 1000, maxTokens: 1000 }) as never; + + it("a stored level the pinned model still supports prefills as-is", () => { + assert.equal(prefillLevelFromPin(entry(["low", "high"]), { thinkingLevel: "high" }), "high"); + }); + + it("a stored level the model LOST rides as-is — Save clamps server-side (bd68486)", () => { + assert.equal(prefillLevelFromPin(entry(["minimal", "medium"]), { thinkingLevel: "xhigh" }), "xhigh"); + }); + + it("a legacy pin with no stored level prefills the ordering's level", () => { + assert.equal(prefillLevelFromPin(entry(["low", "medium"]), {}), "medium"); + assert.equal(prefillLevelFromPin(entry(["low", "medium"]), null), "medium"); + }); +}); + describe("setupStage — unchanged by the BYOK wave", () => { it("keys first (owner ordering), pickers when a provider is keyed", () => { assert.equal(setupStage(null), "keys"); diff --git a/tests/thinking-default.test.ts b/tests/thinking-default.test.ts new file mode 100644 index 0000000..ae7b09d --- /dev/null +++ b/tests/thinking-default.test.ts @@ -0,0 +1,59 @@ +/** + * The default-order resolution, client-side (chunk 2): the UI populates a + * fresh model pick's thinking level with the SAME level the engine build + * runs for an unset record — first DEFAULT_THINKING_ORDER entry the model + * supports (Platform's interlude bd68486 moved the ordering into the core + * barrel exactly for this). These tests pin the helper against the + * server's defaultThinkingLevel semantics (src/agent/models.ts). + */ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { DEFAULT_THINKING_ORDER } from "../src/core/settings.ts"; +import { defaultOrderLevel, populateLevel } from "../components/thinking-default.ts"; +import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; + +const tl = (levels: string[]) => levels as ThinkingLevel[]; + +describe("defaultOrderLevel — the ordering's first supported", () => { + it("a reasoning model taking the ladder's head (medium) resolves medium", () => { + assert.equal(defaultOrderLevel(tl(["minimal", "low", "medium", "high"])), "medium"); + }); + + it("a model without medium falls to the NEXT order entry it supports", () => { + // order: medium, low, high, minimal, xhigh, max — low wins here + assert.equal(defaultOrderLevel(tl(["minimal", "low", "high"])), "low"); + assert.equal(defaultOrderLevel(tl(["high", "xhigh", "max"])), "high"); + }); + + it("a non-reasoning model (pi reports exactly [off]) resolves off", () => { + assert.equal(defaultOrderLevel(tl(["off"])), "off"); + }); + + it("empty support resolves off (the server's fallback), never crashes", () => { + assert.equal(defaultOrderLevel(tl([])), "off"); + }); + + it("the full ladder resolves its head", () => { + const full = tl(["off", "minimal", "low", "medium", "high", "xhigh", "max"]); + assert.equal(defaultOrderLevel(full), DEFAULT_THINKING_ORDER[0]); + }); +}); + +describe("populateLevel — an explicit pick always rides; absence populates", () => { + it("a supported explicit level rides unchanged", () => { + assert.equal(populateLevel("high", tl(["low", "high"])), "high"); + }); + + it("an UNSUPPORTED explicit level STILL rides — the write clamps server-side (bd68486), the signal re-reads the clamped truth", () => { + // xhigh rides even though unsupported here; never re-populated client-side + assert.equal(populateLevel("xhigh", tl(["minimal", "medium", "high"])), "xhigh"); + }); + + it("no explicit level populates the ordering's level (the fresh-pick case)", () => { + assert.equal(populateLevel(undefined, tl(["low", "high"])), "low"); + }); + + it("off as an explicit level rides when supported", () => { + assert.equal(populateLevel("off", tl(["off"])), "off"); + }); +});