diff --git a/app/chat/new/page.tsx b/app/chat/new/page.tsx
index 63ffe9c..423ea76 100644
--- a/app/chat/new/page.tsx
+++ b/app/chat/new/page.tsx
@@ -1,5 +1,6 @@
import { NewSession } from "@/components/new-session";
-import { readDefaultSettings } from "@/src/server";
+import { readDefaultSettings, readSettingsRecord } from "@/src/server";
+import { slotsConfigured } from "@/src/core";
export const dynamic = "force-dynamic";
@@ -16,5 +17,10 @@ export const dynamic = "force-dynamic";
* only before the first-ever pick).
*/
export default function NewSessionPage() {
- return ;
+ return (
+
+ );
}
diff --git a/app/page.tsx b/app/page.tsx
index e61fc67..f88010d 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -1,5 +1,6 @@
import { Home } from "@/components/home";
-import { readDefaultSettings } from "@/src/server";
+import { readDefaultSettings, readSettingsRecord } from "@/src/server";
+import { slotsConfigured } from "@/src/core";
// Dynamic: the default settings record is server-rendered into the page —
// the model + thinking + context controls show truth on the FIRST frame
@@ -8,5 +9,11 @@ import { readDefaultSettings } from "@/src/server";
export const dynamic = "force-dynamic";
export default function HomePage() {
- return ;
+ // First-run gate (model-slots wave): chats cannot start until the user
+ // has picked all three slots — the dock shows the CTA while the raw
+ // record's models section lacks any slot pin.
+ const record = readSettingsRecord();
+ return (
+
+ );
}
diff --git a/components/home.tsx b/components/home.tsx
index ccafd8a..c8be107 100644
--- a/components/home.tsx
+++ b/components/home.tsx
@@ -4,6 +4,8 @@ import { useState } from "react";
import { useRouter } from "next/navigation";
import { CornerDownLeftIcon } from "lucide-react";
import { GraphView } from "@/components/graph-view";
+import { SetupDialog } from "@/components/setup-dialog";
+import { useSettingsDialog } from "@/components/settings/settings-dialog.tsx";
import { GraphContextControl, effectiveContextTokens } from "@/components/graph-context-control";
import { useDefaultSettings } from "@/components/default-settings";
import { DictationBar, useDictation } from "@/components/dictation";
@@ -17,9 +19,17 @@ import type { ChatSettings, TurnUsage } from "@/src/core";
* under the header and the new-session dock, both of which float over it
* with no gradient scrims. Sessions live in the toggleable sidebar.
*/
-export function Home({ initialSettings }: { initialSettings: ChatSettings | null }) {
+export function Home({ initialSettings, initialConfigured }: { initialSettings: ChatSettings | null; initialConfigured: boolean }) {
const router = useRouter();
const [topic, setTopic] = useState("");
+ // First-run gate (model-slots wave, owner 2026-09-20): chats cannot
+ // start until the user has picked all three slots — no defaults for
+ // 33 providers. The dock shows the CTA while unconfigured; submitting
+ // a topic opens the setup dialog WITH THE DRAFT PRESERVED (Save
+ // stores the slots and the queued message sends).
+ const [configured, setConfigured] = useState(initialConfigured);
+ const [setupOpen, setSetupOpen] = useState(false);
+ const { open: openSettings } = useSettingsDialog();
// Voice dictation (STT wave, 2026-09-17): finals append at the end of the
// topic draft; the strip lives in the stats bar's middle slot.
const dictation = useDictation({
@@ -34,9 +44,17 @@ export function Home({ initialSettings }: { initialSettings: ChatSettings | null
// defers its save when chatId is empty; see components/model-picker).
const { settings, save: saveDraft } = useDefaultSettings(initialSettings);
- const start = async () => {
+ const start = async (force = false) => {
const t = topic.trim();
if (!t || busy) return;
+ // Unconfigured: the send INTERCEPTS into the setup flow (the draft
+ // stays in the textarea; Save completes and this re-runs). `force`
+ // is the onDone path — the state this closure captured is still
+ // the pre-save `configured`, the state update hasn't landed yet.
+ if (!configured && !force) {
+ setSetupOpen(true);
+ return;
+ }
setBusy(true);
try {
const res = await fetch("/api/chats", {
@@ -96,6 +114,18 @@ export function Home({ initialSettings }: { initialSettings: ChatSettings | null
px-4) so nothing shifts when a session starts (owner). */}
+ {!configured && (
+
+
+
+ )}
+
+ {/* First-run setup (unconfigured): the CTA's target and the send
+ interception's landing surface. onDone replays the intercepted
+ draft; onOpenKeys deep-links the settings dialog (stage 1). */}
+ setSetupOpen(false)}
+ onDone={() => {
+ setConfigured(true);
+ setSetupOpen(false);
+ void start(true); // the queued draft sends
+ }}
+ onOpenKeys={() => {
+ setSetupOpen(false);
+ openSettings("keys");
+ }}
+ />
);
}
diff --git a/components/new-session.tsx b/components/new-session.tsx
index 3c2f210..00395da 100644
--- a/components/new-session.tsx
+++ b/components/new-session.tsx
@@ -9,6 +9,8 @@ import { GraphContextControl, effectiveContextTokens } from "@/components/graph-
import { useDefaultSettings } from "@/components/default-settings";
import { PhaseChip } from "@/components/phase-divider";
import { SidebarToggle } from "@/components/sidebar";
+import { SetupDialog } from "@/components/setup-dialog";
+import { useSettingsDialog } from "@/components/settings/settings-dialog.tsx";
import { StatsBar } from "@/components/stats-bar";
import type { ChatSettings } from "@/src/core";
@@ -148,7 +150,13 @@ export function DraftShell({
);
}
-export function NewSession({ initialSettings }: { initialSettings: ChatSettings | null }) {
+export function NewSession({
+ initialSettings,
+ initialConfigured,
+}: {
+ initialSettings: ChatSettings | null;
+ initialConfigured: boolean;
+}) {
const router = useRouter();
const [text, setText] = useState("");
const [busy, setBusy] = useState(false);
@@ -160,8 +168,22 @@ export function NewSession({ initialSettings }: { initialSettings: ChatSettings
// hands the choice up; it still rides the session POST then PUT-on-create.
const { settings, save: saveDraft } = useDefaultSettings(initialSettings);
- const send = useCallback(() => {
+ // First-run gate (model-slots wave, owner 2026-09-20): the send
+ // INTERCEPTS into the setup flow while unconfigured — no defaults for
+ // 33 providers, chats cannot start. The draft stays in the textarea;
+ // Save completes and the send replays.
+ const [configured, setConfigured] = useState(initialConfigured);
+ const [setupOpen, setSetupOpen] = useState(false);
+ const { open: openSettings } = useSettingsDialog();
+
+ const send = useCallback((force = false) => {
if (!text.trim() || busy) return;
+ // `force` is the onDone path — the captured `configured` is still
+ // the pre-save value; the state update hasn't landed yet.
+ if (!configured && !force) {
+ setSetupOpen(true);
+ return;
+ }
setBusy(true);
void startDraftSession(text, settings, fetch)
.then((target) => {
@@ -169,16 +191,32 @@ export function NewSession({ initialSettings }: { initialSettings: ChatSettings
else setBusy(false);
})
.catch(() => setBusy(false));
- }, [text, busy, settings, router]);
+ }, [text, busy, settings, router, configured]);
return (
-
+ <>
+
+ {/* First-run setup: the send interception's landing surface. */}
+ setSetupOpen(false)}
+ onDone={() => {
+ setConfigured(true);
+ setSetupOpen(false);
+ send(true); // the queued draft sends
+ }}
+ onOpenKeys={() => {
+ setSetupOpen(false);
+ openSettings("keys");
+ }}
+ />
+ >
);
}
\ No newline at end of file
diff --git a/components/settings/agent-section.tsx b/components/settings/agent-section.tsx
index 45ad2a2..72e053d 100644
--- a/components/settings/agent-section.tsx
+++ b/components/settings/agent-section.tsx
@@ -1,28 +1,40 @@
"use client";
/**
- * 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 Agent section (settings-app wave): the THREE MODEL SLOTS, the tool
+ * toggles + the search-engine preference, and the prompt override
+ * switches — three GROUPS (owner, 2026-09-20: clear separation — Models /
+ * Tools / System prompts).
*
- * 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.
+ * Model slots (model-slots wave, 2026-09-20, owner ruling: SLOTS ONLY —
+ * the six per-role rows disappeared; per-role overrides remain in the
+ * schema for a future advanced surface): Strong = Prober + Teacher, Cheap
+ * = the five workhorse roles, Vision = Visual maker + PDF reader. Each
+ * slot row carries the shared dropdown (fuzzy search, arrow-walk, Enter
+ * pick — ModelMenuList/ThinkingMenuList from ./model-menu.tsx) over the
+ * FEDERATED catalog (/api/models serves only keyed providers, fresh per
+ * call — saving a key in Keys immediately widens this picker). The
+ * vision slot filters to image-capable models (Model.input). The slots
+ * live in the `models` settings section (PUT /api/settings/models — slot
+ * pins are engine-level, not per-chat); the rest of the section writes
+ * the `agent` section as before. Resolution per tier: slot override →
+ * factory pin (GET /api/models' `slots` — resolved, cannot drift from
+ * what runs). The Default row clears the slot override; a stored id stays
+ * VISIBLE even when its provider's key lapsed out of the catalog — a
+ * trigger showing "Default" over a live override would lie about the
+ * record.
+ *
+ * Search engine (search wave, 2026-09-20, owner: no default — an engine
+ * is live iff its key is present; with a key the engine AUTO-activates,
+ * the explicit choice is only a preference between live engines): a row
+ * in the Tools group, Tavily / Brave via the same shared-menu look, with
+ * the live reason line from /api/search. No engine key at all → the row
+ * grays whole-row (a choice without a key would only BLOCK the other
+ * engine's auto-activation).
*
* 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 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), so a file-less row dims whole-row
* (graph-view graying) until one is written. Switch state lives in this
@@ -32,22 +44,29 @@
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 {
+ MODEL_SLOTS,
+ type AgentSettings,
+ type ModelCatalogEntry,
+ type ModelSlotName,
+ type ModelsSettings,
+} from "@/src/core";
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
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 = {
- researcher: "Researcher",
- demoBuilder: "Demo builder",
- visualMaker: "Visual maker",
- extractor: "Extractor",
- auditor: "Auditor",
- labeler: "Labeler",
+/** One slot's display identity — the tier's roster as the row hint rides
+ * with the resolved default (owner: the hint ALWAYS names the default). */
+const SLOT_META: Record = {
+ strong: { label: "Strong", roster: "Prober + Teacher" },
+ cheap: { label: "Cheap", roster: "Researcher, demo builder, extractor, auditor, labeler" },
+ vision: { label: "Vision", roster: "Visual maker + PDF reader" },
};
+/** The search engines this wave ships (src/core/search.ts) + their labels. */
+const SEARCH_ENGINES = ["tavily", "brave"] as const;
+
/** The owner's final toggleable-tool list (confirm with the user before
* changing — the Teacher stream settled this 2026-09-19). */
export const TOGGLE_TOOLS = ["research", "demo", "visual", "pdf"] as const;
@@ -63,30 +82,57 @@ interface SegmentInfo {
shared: boolean;
}
+/** The GET /api/models response fields this section consumes. */
+interface ModelsBody {
+ models: ModelCatalogEntry[];
+ /** The three tiers' RESOLVED pins (override → factory) — the honest
+ * "what runs now" the Default rows and hints speak from. */
+ slots: Record;
+}
+
/** Compose: one tool toggled, the rest of the section carried verbatim.
* Pure — exported for tests. */
export function composeTool(agent: AgentSettings | undefined, id: string, on: boolean): AgentSettings {
return { ...agent, tools: { ...agent?.tools, [id]: on } };
}
-/** Compose: one role's model/thinking override. Empty model = absent
- * override (Default); empty thinking = thinking falls back. Pure — tests. */
-export function composeRoleModel(
- agent: AgentSettings | undefined,
- role: BackgroundModelRole,
- modelId: string,
+/** Compose: one slot's model/thinking override. Empty model = absent
+ * override (Default — falls to the factory pin); empty thinking = the
+ * tier keeps its resolved thinking budget. Per-field independence, same
+ * as the schema's ModelSlot. Pure — exported for tests. */
+export function composeSlotModel(
+ models: ModelsSettings | undefined,
+ slot: ModelSlotName,
+ provider: string,
+ id: string,
thinking: string,
-): AgentSettings {
- const prev = agent?.models?.[role];
- const models = { ...agent?.models };
- if (!modelId && !thinking) delete models[role];
- else {
- models[role] = {
- ...(modelId ? { model: { provider: "openrouter", id: modelId } } : {}),
- ...(thinking ? { thinkingLevel: thinking as ThinkingLevel } : {}),
- };
+): ModelsSettings {
+ const prev = models?.[slot];
+ const next = { ...models };
+ if (!provider && !id && !thinking) {
+ delete next[slot];
+ return next;
}
- return { ...agent, models };
+ next[slot] = {
+ ...(provider && id ? { model: { provider, id } } : prev?.model ? { model: prev.model } : {}),
+ ...(thinking ? { thinkingLevel: thinking as ThinkingLevel } : {}),
+ };
+ return next;
+}
+
+/** Compose: the search-engine preference. null = the explicit pin is
+ * REMOVED (auto-activation takes over — the schema's resting state).
+ * Pure — exported for tests. */
+export function composeSearchEngine(
+ agent: AgentSettings | undefined,
+ engine: (typeof SEARCH_ENGINES)[number] | null,
+): AgentSettings {
+ if (engine === null) {
+ if (!agent?.search) return { ...agent };
+ const { search: _drop, ...rest } = agent;
+ return { ...rest } as AgentSettings;
+ }
+ return { ...agent, search: { engine } };
}
/** Compose: one prompt switch. on = explicit true; off = key REMOVED
@@ -109,6 +155,7 @@ function MenuTrigger({
open,
onClick,
widthClass,
+ disabled,
}: {
value: string;
/** What the trigger shows when there is no override ("Default"). */
@@ -122,6 +169,8 @@ function MenuTrigger({
* the model trigger takes the same width as a MINIMUM — its values
* genuinely vary in length. */
widthClass: string;
+ /** The no-key search state grays the whole row; the trigger follows. */
+ disabled?: boolean;
}) {
return (
diff --git a/components/settings/settings-dialog.tsx b/components/settings/settings-dialog.tsx
index 1c151e7..37c9d28 100644
--- a/components/settings/settings-dialog.tsx
+++ b/components/settings/settings-dialog.tsx
@@ -39,6 +39,7 @@ import {
import { useAppearance } from "./appearance.tsx";
import { SettingsSection, SettingsRow, Toggle } from "./controls.tsx";
import { AgentSection } from "./agent-section.tsx";
+import type { ModelsSettings } from "@/src/core";
import { KeysSection } from "./keys-section.tsx";
import { AppearanceSection, GraphSection } from "./appearance-section.tsx";
import { KeyboardSection } from "./keyboard-section.tsx";
@@ -56,20 +57,26 @@ const SECTIONS = [
] as const;
type SectionId = (typeof SECTIONS)[number]["id"];
-const DialogContext = createContext<{ open: () => void }>({ open: () => {} });
+const DialogContext = createContext<{ open: (section?: string) => void }>({ open: () => {} });
-/** AppShell-level mount: the sidebar row and the mod+, chord call this. */
+/** AppShell-level mount: the sidebar row and the mod+, chord call this.
+ * `open(section)` lands the dialog on a specific section (the first-run
+ * setup flow deep-links to Keys when no provider key exists yet). */
export function SettingsDialogProvider({
children,
}: {
children: React.ReactNode;
}) {
const [show, setShow] = useState(false);
- const open = useCallback(() => setShow(true), []);
+ const [landOn, setLandOn] = useState("general");
+ const open = useCallback((section?: string) => {
+ if (section) setLandOn(section as SectionId);
+ setShow(true);
+ }, []);
return (
{children}
- {show && setShow(false)} />}
+ {show && setShow(false)} initialSection={landOn} />}
);
}
@@ -78,7 +85,7 @@ export function useSettingsDialog() {
return useContext(DialogContext);
}
-function SettingsDialog({ onClose }: { onClose: () => void }) {
+function SettingsDialog({ onClose, initialSection }: { onClose: () => void; initialSection: SectionId }) {
// Plain dim backdrop (no blur — nothing decorative); the click
// closes, the dialog body stops propagation.
return createPortal(
@@ -87,7 +94,7 @@ function SettingsDialog({ onClose }: { onClose: () => void }) {
data-testid="settings-backdrop"
onClick={onClose}
>
-
+
,
document.body,
);
@@ -95,13 +102,19 @@ function SettingsDialog({ onClose }: { onClose: () => void }) {
/** The dialog without the portal/backdrop — the SSR-testable frame
* (portals render nothing under renderToStaticMarkup). */
-export function SettingsDialogBody({ onClose }: { onClose: () => void }) {
+export function SettingsDialogBody({
+ onClose,
+ initialSection = "general",
+}: {
+ onClose: () => void;
+ initialSection?: SectionId;
+}) {
const { onAppearanceChange } = useAppearance();
const [record, setRecord] = useState(
undefined,
);
const [error, setError] = useState(null);
- const [active, setActive] = useState("general");
+ const [active, setActive] = useState(initialSection);
// Fetch once per dialog mount. 200 {settings: null} is FIRST BOOT
// (no record = factory everything), not a failure — the error state is
@@ -133,7 +146,7 @@ export function SettingsDialogBody({ onClose }: { onClose: () => void }) {
* schema mistake caught by the validation surface). */
const write = useCallback(
async (
- section: "general" | "agent" | "appearance" | "graph",
+ section: "general" | "agent" | "models" | "appearance" | "graph",
body: object,
) => {
setError(null);
@@ -271,6 +284,10 @@ export function SettingsDialogBody({ onClose }: { onClose: () => void }) {
>
+ void write("models", next)
+ }
onSectionChange={(next: AgentSettings) =>
void write("agent", next)
}
diff --git a/components/setup-dialog.tsx b/components/setup-dialog.tsx
new file mode 100644
index 0000000..2ce79ef
--- /dev/null
+++ b/components/setup-dialog.tsx
@@ -0,0 +1,261 @@
+"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, 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";
+
+/** 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, labeler" },
+ 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 }[];
+}
+
+/** 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";
+}
+
+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>>({});
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState(null);
+ const [openSlot, setOpenSlot] = useState(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({});
+ setError(null);
+ fetch("/api/models")
+ .then((r) => r.json())
+ .then((b: ModelsBody) => {
+ setBody(b);
+ setFailed(false);
+ })
+ .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 (openSlot !== null) setOpenSlot(null);
+ else onClose();
+ };
+ const onPointerDown = (e: PointerEvent) => {
+ if (openSlot === null) return;
+ const t = e.target as Element;
+ if (t.closest("[data-lh-menu]") || t.closest(`[data-testid="setup-trigger-${openSlot}"]`)) return;
+ setOpenSlot(null);
+ };
+ window.addEventListener("keydown", onKey);
+ document.addEventListener("pointerdown", onPointerDown);
+ return () => {
+ window.removeEventListener("keydown", onKey);
+ document.removeEventListener("pointerdown", onPointerDown);
+ };
+ }, [open, onClose, openSlot]);
+
+ 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) models[slot] = { model: { provider: picked[slot]!.provider, id: picked[slot]!.id } };
+ 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;
+ }
+ 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.
+
+
+
+ Open Keys settings
+
+
+
+ ) : (
+
+
+ Pick the model for each tier — all three are required before chats can start.
+