"use client"; /** * The first-run setup flow (model-slots wave, 2026-09-20, owner design * "A — composer CTA + send interception"): chats cannot start until the * user has picked all three model slots — there are no defaults for 33 * providers. The graph view's new-session dock (the app's home, where * chats start) shows a "Set up your models" CTA while unconfigured; * submitting a topic — or sending on /chat/new — opens THIS dialog with * the draft preserved; Save stores the slots and the queued message * sends. * * STAGED BY DEPENDENCY (owner, 2026-09-20: "first keys, then models — * there's no point presenting the model picker before the user has set a * key because there are no models to pick then"): * Stage 1 — no keyed provider: "add a provider key first" + a button * that deep-links the settings dialog on Keys. The draft lives in * the caller's state, untouched; after the key lands the next * submit/CTA click reopens here at stage 2 (the /api/models check is * fresh per open — saving a key immediately widens the pickers). * Stage 2 — Strong → Cheap → Vision pickers over the FEDERATED catalog * (keyed providers only), the vision slot filtered to image-capable * models, in the app's shared dropdown language (ModelMenuList). * All three required (owner: no skip, no defaults); Save PUTs the * models section and reports onDone(models). * * Closable without configuring (Esc/backdrop/✕) — browsing the app stays * possible; the GATE is the send interception, not the dialog. The * settings-dialog visual language: dim backdrop, border rounded-md card, * CMU title, z-50. */ import { useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import type React from "react"; import { type ModelCatalogEntry, type ModelSlotName, type ModelsSettings } from "@/src/core"; 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. */ const SLOT_META: Record = { strong: { label: "Strong", roster: "Prober + Teacher" }, cheap: { label: "Cheap", roster: "Researcher, demo builder, extractor, auditor" }, vision: { label: "Vision", roster: "Visual maker + PDF reader" }, }; const SLOT_ORDER: readonly ModelSlotName[] = ["strong", "cheap", "vision"]; /** The GET /api/models response fields the flow consumes. */ interface ModelsBody { models: ModelCatalogEntry[]; providers: { id: string; keyPresent: boolean }[]; /** 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; } /** Pure: which stage the flow opens at — no keyed provider = keys first * (owner's dependency ordering). Exported for tests. */ export function setupStage( providers: { id: string; keyPresent: boolean }[] | null, ): "keys" | "pickers" { if (providers === null) return "keys"; // fetch failed → the honest fallback return providers.some((p) => p.keyPresent) ? "pickers" : "keys"; } /** Prefill (owner, BYOK wave 2026-09-22): each pinned tier opens with its * 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 empty + marked. Exported pure * for tests. */ export function prefillFromSlots( models: ModelCatalogEntry[], slots: Record | undefined, ): Partial> { const pre: Partial> = {}; for (const slot of SLOT_ORDER) { const pin = slots?.[slot]; const entry = pin?.model ? models.find((m) => m.provider === pin.model!.provider && m.id === pin.model!.id) : undefined; if (entry) pre[slot] = entry; } 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, /** Saved: the models section landed; the caller marks configured and * replays the intercepted draft. */ onDone, /** Deep-link to the Keys section ("add a provider key first"). */ onOpenKeys, }: { open: boolean; onClose: () => void; onDone: (models: ModelsSettings) => void; onOpenKeys: () => void; }) { 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 [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. */ const triggerRef = useRef(null); // Fresh /api/models per open — a key saved a moment ago (stage 1 → // Keys → back) widens the pickers immediately, no restart. useEffect(() => { if (!open) return; setPicked({}); setLevels({}); setError(null); fetch("/api/models") .then((r) => r.json()) .then((b: ModelsBody) => { setBody(b); setFailed(false); // Prefill (owner, 2026-09-22): each pinned tier opens with its // 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. 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]); // Esc + outside-click — layered dismissal, closable WITHOUT // configuring (browsing stays possible; the gate is the send // interception, not this dialog). An open slot menu eats Esc first // (and any click outside it); only a bare Esc closes the dialog. useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent) => { if (e.key !== "Escape") return; if (openMenu !== null) setOpenMenu(null); else onClose(); }; const onPointerDown = (e: PointerEvent) => { if (openMenu === null) return; const t = e.target as Element; 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); return () => { window.removeEventListener("keydown", onKey); document.removeEventListener("pointerdown", onPointerDown); }; }, [open, onClose, openMenu]); if (!open) return null; const stage = failed ? "keys" : setupStage(body ? body.providers : null); const allPicked = SLOT_ORDER.every((s) => picked[s]); async function save() { if (!allPicked || saving) return; setSaving(true); setError(null); const models: ModelsSettings = {}; 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" }, body: JSON.stringify(models), }).catch(() => null); if (!res || !res.ok) { setSaving(false); setError(res ? `Save failed: ${(await res.json().catch(() => ({}))).error ?? res.status}` : "Save failed — network"); return; } notifySettingsSaved("models"); onDone(models); } return createPortal(
e.stopPropagation()} >

Set up your models

{stage === "keys" ? (

{failed ? "Model catalog could not be loaded — check the connection and retry." : "First, add a provider key."}

The model pickers list only what your keys unlock — there is nothing to pick until one exists.

) : (

Pick the model for each tier — all three are required before chats can start.

{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 (
{SLOT_META[slot].label} {!body?.slots?.[slot]?.model && !picked[slot] && ( needed )} {SLOT_META[slot].roster}
{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); }} /> )}
); })}
{error &&

{error}

}
)}
, document.body, ); }