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 && ( +
+ +
+ )}
{ @@ -155,6 +185,23 @@ export function Home({ initialSettings }: { initialSettings: ChatSettings | null />
+ + {/* 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 ( + ); + const segmentRow = (info: SegmentInfo) => { const fileExists = info.overrideText !== null; const open = openSegment === info.path; @@ -378,35 +483,36 @@ export function AgentSection({ ); }; + + return ( <> - {BACKGROUND_MODEL_ROLES.map((role) => { - const override = agent?.models?.[role]; + {MODEL_SLOTS.map((slot) => { + const override = models?.[slot]; const modelId = override?.model?.id ?? ""; + const provider = override?.model?.provider ?? ""; const thinking = override?.thinkingLevel ?? ""; + // The tier's resolved pin (override → factory): the Default + // hint names it; the thinking list resolves from its levels. + const pin = slots?.[slot]; + const defaultName = pin ? prettyModel(pin.model.id) : "config pin"; // The model whose levels govern the thinking list: the - // override, else the config pin (rolePins makes the - // default's levels resolvable when it's in the catalog); - // the standard ladder as the last fallback. - const effectiveId = modelId || rolePins?.[role]?.model.id || ""; - const chosen = catalog?.find((m) => m.id === effectiveId); + // override, else the resolved pin (when it's in the federated + // catalog); the standard ladder as the last fallback. + const coord = override?.model ?? pin?.model; + const chosen = coord ? catalog?.find((m) => m.id === coord.id && m.provider === coord.provider) : undefined; const levels = chosen ? chosen.supportedLevels : THINKING_LEVELS; - // The row ALWAYS names the default (owner, 2026-09-20: the - // old "config pin applies" hint said nothing). rolePins - // carries the resolved default; the fallback reads until - // Platform's rolePins lands on main. - const defaultName = rolePins?.[role]?.model.id - ? prettyModel(rolePins[role].model.id) - : "config pin"; - const thisMenuOpen = - openMenu?.role === role ? openMenu.kind : null; + const thisMenuOpen = openMenu?.target === slot ? openMenu.kind : null; + // Vision slots pick from image-capable models only. + const slotCatalog = + slot === "vision" ? catalog?.filter((m) => m.input.includes("image")) : catalog; return ( {/* The anchor for the outside-click contract is the span holding BOTH triggers of the open menu. */} @@ -417,55 +523,60 @@ export function AgentSection({ setOpenMenu(thisMenuOpen === "model" ? null : { role, kind: "model" })} + onClick={() => setOpenMenu(thisMenuOpen === "model" ? null : { target: slot, kind: "model" })} widthClass="min-w-20" /> setOpenMenu(thisMenuOpen === "thinking" ? null : { role, kind: "thinking" })} + onClick={() => setOpenMenu(thisMenuOpen === "thinking" ? null : { target: slot, kind: "thinking" })} widthClass="w-20" /> {thisMenuOpen === "model" && ( {defaultRow( modelId === "", () => { - onSectionChange(composeRoleModel(agent, role, "", thinking)); + onModelsChange(composeSlotModel(models, slot, "", "", thinking)); setOpenMenu(null); }, )} { - onSectionChange(composeRoleModel(agent, role, entry.id, thinking)); + onModelsChange(composeSlotModel(models, slot, entry.provider, entry.id, thinking)); setOpenMenu(null); }} - footer="Applied to every run of this role" + footer={ + catalog && catalog.length === 0 + ? "No models — add a provider key in Keys" + : "Runs every agent of this tier" + } /> )} {thisMenuOpen === "thinking" && ( {defaultRow( thinking === "", () => { - onSectionChange(composeRoleModel(agent, role, modelId, "")); + onModelsChange(composeSlotModel(models, slot, provider, modelId, "")); setOpenMenu(null); }, )} @@ -473,7 +584,7 @@ export function AgentSection({ levels={levels.map(String)} effectiveLevel={thinking || undefined} onPick={(level) => { - onSectionChange(composeRoleModel(agent, role, modelId, level)); + onModelsChange(composeSlotModel(models, slot, provider, modelId, level)); setOpenMenu(null); }} /> @@ -495,6 +606,52 @@ export function AgentSection({ /> ))} + {(() => { + const engine = agent?.search?.engine ?? null; + const anyKey = engineKeys.tavily === true || engineKeys.brave === true; + const open = openMenu?.target === "search"; + return ( + + + setOpenMenu(open ? null : { target: "search", kind: "engine" })} + widthClass="min-w-20" + disabled={!anyKey} + /> + {open && ( + + {engineOption("auto", "Automatic", engine === null, null, () => { + onSectionChange(composeSearchEngine(agent, null)); + setOpenMenu(null); + refreshSearch(); + })} + {SEARCH_ENGINES.map((id) => + engineOption(id, id === "tavily" ? "Tavily" : "Brave", engine === id, engineKeys[id] ?? false, () => { + onSectionChange(composeSearchEngine(agent, id)); + setOpenMenu(null); + refreshSearch(); + }), + )} + + )} + + + ); + })()} @@ -516,4 +673,4 @@ export function AgentSection({ ); -} \ No newline at end of file +} diff --git a/components/settings/model-menu.tsx b/components/settings/model-menu.tsx index 24c74d3..4c1ec86 100644 --- a/components/settings/model-menu.tsx +++ b/components/settings/model-menu.tsx @@ -31,6 +31,7 @@ function priceLabel(cost: ModelCatalogEntry["cost"]): string { export function ModelMenuList({ models, effectiveId, + effectiveProvider, onPick, footer, }: { @@ -38,6 +39,11 @@ export function ModelMenuList({ models: ModelCatalogEntry[] | undefined; /** Currently effective id (the row gets the quiet highlight). */ effectiveId: string | undefined; + /** The effective id's provider (model-slots wave: the catalog + * federates multiple providers and ids can collide across them — + * the highlight must match BOTH coordinates or it lands on the + * wrong row). */ + effectiveProvider?: string; /** Receives the chosen entry. */ onPick: (entry: ModelCatalogEntry) => void; /** Quiet footer note under the list (context-specific). */ @@ -111,7 +117,7 @@ export function ModelMenuList({ onClick={() => onPick(m)} className={`flex w-full flex-col gap-0.5 rounded-lg px-2 py-1.5 text-left outline-none transition-colors hover:bg-muted ${ i === menuIdx ? "bg-accent/40 ring-1 ring-inset ring-border" : "" - }${m.id === effectiveId && i !== menuIdx ? " bg-muted/60" : ""}`} + }${m.id === effectiveId && m.provider === (effectiveProvider ?? m.provider) && i !== menuIdx ? " bg-muted/60" : ""}`} > {m.name} @@ -120,7 +126,7 @@ export function ModelMenuList({ - {m.id} + {m.provider} · {m.id} {formatTokens(m.contextWindow)} 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. +

+
+ +
+
+ ) : ( +
+

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

+ {SLOT_ORDER.map((slot) => { + const entry = picked[slot]; + const catalog = + slot === "vision" ? body?.models.filter((m) => m.input.includes("image")) : body?.models; + return ( +
+ {SLOT_META[slot].label} + {SLOT_META[slot].roster} +
+ + {openSlot === slot && ( +
+ { + setPicked((prev) => ({ ...prev, [slot]: m })); + setOpenSlot(null); + }} + footer={catalog && catalog.length === 0 ? "No image-capable models — widen the provider keys" : undefined} + /> +
+ )} +
+
+ ); + })} +
+ + {error &&

{error}

} +
+
+ )} +
+
, + document.body, + ); +} diff --git a/components/sidebar.tsx b/components/sidebar.tsx index 6e1702a..31cf1af 100644 --- a/components/sidebar.tsx +++ b/components/sidebar.tsx @@ -491,7 +491,7 @@ function SessionsSidebar() {