"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 — the draft WAITS in the * composer, the learner sends it themselves (owner ruling 2026-10-09: * saving models must never send the message). * * 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: a SELF-CONTAINED first-key screen * (zero-keys wave, owner 2026-09-25): provider picker + one key * input + save via PUT /api/keys/:id. The dialog STAYS MOUNTED — * saving dispatches the "keys" signal, the fetch effect re-runs on * it, and the stage flips to the pickers on its own. No Settings * round-trip anymore; the draft lives in the caller's state, * untouched throughout. * 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 { useBackdropDismiss } from "@/components/backdrop-dismiss"; 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, useSettingsSaved } from "@/components/settings-signal"; import { Hint } from "@/components/ui/hint"; import { type ProviderKeyInfo } from "@/components/settings/keys-section"; import { ProviderMenuList } from "@/components/settings/model-menu"; import { ChatGPTBackgroundAccess, ChatGPTConnection } from "@/components/settings/chatgpt-connection"; 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); } /** Stage 1's pure body — exported for SSR pins (the delete/settings * body pattern). The wrapper owns state + effects + the portal. * * The first-key screen (owner, zero-keys wave): provider picker (the * app's shared dropdown language, ProviderMenuList), one paste input, * Save. A save that 400s keeps the line inline (the keys section's * shape-gates write user-facing error text). */ export function SetupKeysBody({ providers, provider, value, saving, error, menuOpen, onToggleMenu, onPick, onValue, onSave, menuAnchor, }: { /** The full provider roster (GET /api/keys providers) — undefined * while it loads; the picker renders an honest empty state then. */ providers: ProviderKeyInfo[] | undefined; /** The chosen provider; null = the picker trigger reads "Pick a * provider…". */ provider: ProviderKeyInfo | null; value: string; saving: boolean; error: string | null; menuOpen: boolean; onToggleMenu: () => void; onPick: (p: ProviderKeyInfo) => void; onValue: (v: string) => void; onSave: () => void; /** The MenuShell anchor — wired by the wrapper (its triggerRef). */ menuAnchor: React.RefObject; }) { return (

Choose your AI provider and paste its API key. You’ll pick your models next.

{menuOpen && ( )}
{provider && (
onValue(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && value.trim()) onSave(); }} placeholder="Paste key…" aria-label={`${provider.label} key`} data-testid="setup-key-input" disabled={saving} autoFocus className="h-7 min-w-0 flex-1 rounded-sm border border-border bg-background px-1.5 font-mono text-xs text-foreground outline-none placeholder:text-muted-foreground/60 focus:border-muted-foreground/50" />
)} {error && (

{error}

)}
); } export function SetupDialog({ open, onClose, /** Saved: the models section landed; the caller marks configured (the * draft stays put — save never sends). */ onDone, }: { open: boolean; onClose: () => void; onDone: (models: ModelsSettings) => void; }) { const backdropDismiss = useBackdropDismiss(onClose); 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); // Stage 1's first-key screen (zero-keys wave): the provider roster, // the chosen provider + pasted value, its busy/error pair, and its own // menu-open bit (the slot menus' dismissal machinery covers it too — // see the trigger testid branch below). const [keyProviders, setKeyProviders] = useState(undefined); const [keyProvider, setKeyProvider] = useState(null); const [keyValue, setKeyValue] = useState(""); const [keySaving, setKeySaving] = useState(false); const [keyError, setKeyError] = useState(null); const [keyMenuOpen, setKeyMenuOpen] = useState(false); // Stage 1's provider-picker trigger wrapper — the shared shell's anchor. // Declared with the other hooks: the early `if (!open) return null` sits // below them all. (Stage 2's slot menus anchor by anchorTestid instead — // see the slot MenuShell below for why a ref cannot work there.) const keyTriggerRef = useRef(null); // The keys signal (zero-keys wave): keys-section saves/deletes AND // THIS dialog's own first-key save dispatch it; the fetch effect below // re-runs on it, so saving a key flips the stage to the pickers with // the widened catalog — no restart, no Settings round-trip. const [keysTick, setKeysTick] = useState(0); useSettingsSaved((section) => { if (section === "keys") setKeysTick((t) => t + 1); }); // Fresh /api/models per open AND per keys change — a key saved a // moment ago (stage 1's own screen) widens the pickers immediately. useEffect(() => { if (!open) return; let cancelled = false; setBody(null); setPicked({}); setLevels({}); setError(null); fetch("/api/keys") .then((r) => r.json()) .then((b: { providers?: ProviderKeyInfo[] }) => { if (!cancelled) setKeyProviders(b.providers ?? []); }) .catch(() => { if (!cancelled) setKeyProviders([]); }); fetch("/api/models") .then((r) => { if (!r.ok) throw new Error("Catalog unavailable"); return r.json(); }) .then((b: ModelsBody) => { if (cancelled) return; 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(() => { if (!cancelled) setFailed(true); }); return () => { cancelled = true; }; }, [open, keysTick]); // 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; // THE ONE-ESC LAW (owner, keyboard round 2026-10-06): claim at // the document level before the layered dismissal — every branch // below acts, so the window chain (dictation → abort) must // never also fire. One Esc, one action. e.preventDefault(); e.stopPropagation(); if (keyMenuOpen) { setKeyMenuOpen(false); return; } if (openMenu !== null) setOpenMenu(null); else onClose(); }; const onPointerDown = (e: PointerEvent) => { if (!(e.target instanceof Element)) return; const t = e.target; if (keyMenuOpen) { if (t.closest("[data-lh-menu]") || t.closest('[data-testid="setup-key-provider"]')) return; setKeyMenuOpen(false); } if (openMenu === null) return; 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); }; document.addEventListener("keydown", onKey); document.addEventListener("pointerdown", onPointerDown); return () => { document.removeEventListener("keydown", onKey); document.removeEventListener("pointerdown", onPointerDown); }; }, [open, onClose, openMenu, keyMenuOpen]); if (!open) return null; const stage = failed ? "keys" : setupStage(body ? body.providers : null); const allPicked = SLOT_ORDER.every((s) => picked[s]); /** Stage 1's save: PUT the pasted key, dispatch the keys signal, and * CLEAN the local screen — the signal re-fetch widens the catalog and * the stage flips to the pickers on its own; the dialog stays mounted * the whole way. Shape-gate 400s keep their user-facing line inline. */ async function saveKey() { if (!keyProvider || !keyValue.trim() || keySaving) return; setKeySaving(true); setKeyError(null); const res = await fetch(`/api/keys/${encodeURIComponent(keyProvider.envVar)}`, { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ value: keyValue }), }).catch(() => null); setKeySaving(false); if (!res || !res.ok) { const body = (await res?.json().catch(() => ({}))) as { error?: string } | undefined; setKeyError(res ? (body?.error ?? `Save failed (${res.status})`) : "Save failed. Check your connection and try again."); return; } setKeyProvider(null); setKeyValue(""); notifySettingsSaved("keys"); } 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. Check your connection and try again."); 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.

) : ( setKeyMenuOpen((v) => !v)} onPick={(p) => { setKeyProvider(p); setKeyMenuOpen(false); setKeyError(null); }} onValue={setKeyValue} onSave={() => void saveKey()} /> )}
) : (

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} {slot !== "strong" && }
{/* Anchor by TESTID, not a ref (the settings-section pattern): React attaches a host element's ref AFTER its descendants' layout effects run, so a ref freshly attached in this same commit (menu opening = the ref condition turning true) is still null when MenuShell measures — place() gives up, the menu stays invisible, and only a window resize (which re-runs place()) shows it. querySelector at place time has no such ordering hazard. Live-caught by the owner 2026-10-09: every setup picker opened dead until a window resize. */} {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. Add a provider key with access to a vision model." : 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, ); }