2026-09-22 13:54:28 +03:00
"use client" ;
2026-10-25 17:18:44 +03:00
import { useI18n } from "@/components/i18n" ;
2026-09-22 13:54:28 +03:00
/**
* 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
2026-10-09 04:54:24 +03:00
* 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).
2026-09-22 13:54:28 +03:00
*
* 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"):
2026-10-26 19:43:09 +02:00
* Unknown catalog — neutral loading (or error/Retry), never a key screen.
* Stage 1 — no keyed provider or saved selection: a SELF-CONTAINED first-key screen
2026-09-24 23:20:59 +03:00
* (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.
2026-09-22 13:54:28 +03:00
* 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).
2026-10-27 16:12:13 +02:00
* Saved disconnected selections and edits are retained internally in
* the exclusive provider stage. Connecting with all slots already saved
* closes setup; otherwise the pickers require only the missing slots.
2026-09-22 13:54:28 +03:00
* 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.
*/
2026-09-22 16:56:44 +03:00
import { useEffect , useRef , useState } from "react" ;
2026-09-22 13:54:28 +03:00
import { createPortal } from "react-dom" ;
2026-10-17 00:38:57 +03:00
import { useBackdropDismiss } from "@/components/backdrop-dismiss" ;
2026-09-22 13:54:28 +03:00
import type React from "react" ;
2026-10-24 09:53:10 -07:00
import { type ModelCatalogEntry , type ModelSlotName , type ModelsSettings , CUSTOM_PROVIDER } from "@/src/core" ;
2026-09-22 13:54:28 +03:00
import { prettyModel } from "@/components/usage-format" ;
import { ModelMenuList } from "@/components/settings/model-menu" ;
2026-09-22 16:56:44 +03:00
import { MenuShell } from "@/components/menu-shell" ;
2026-09-24 23:20:59 +03:00
import { notifySettingsSaved , useSettingsSaved } from "@/components/settings-signal" ;
2026-10-26 19:43:09 +02:00
import { useModelCatalog } from "@/components/model-catalog" ;
2026-10-27 16:12:13 +02:00
import { savedSlotsConfigured } from "@/components/setup-cta" ;
2026-09-25 01:52:20 +03:00
import { Hint } from "@/components/ui/hint" ;
2026-10-29 04:43:53 +02:00
import { type ProviderKeyInfo , CUSTOM_PROVIDER_INFO , CustomEndpointSetup } from "@/components/settings/keys-section" ;
2026-09-24 23:20:59 +03:00
import { ProviderMenuList } from "@/components/settings/model-menu" ;
2026-10-25 00:27:52 +03:00
import { ChatGPTBackgroundAccess } from "@/components/settings/chatgpt-connection" ;
import { NativeSubscriptionBackgroundAccess , SubscriptionConnections } from "@/components/settings/subscription-connections" ;
2026-09-22 22:05:27 +03:00
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" ;
2026-09-22 13:54:28 +03:00
/** The pickers' slot identity — same roster language as the Agent
* section's slot rows. */
const SLOT_META : Record < ModelSlotName , { label : string ; roster : string }> = {
2026-10-04 21:34:24 +03:00
strong : { label : "Strong" , roster : "Prober, teacher" },
2026-09-23 15:37:34 +03:00
cheap : { label : "Cheap" , roster : "Researcher, demo builder, extractor, auditor" },
2026-09-22 13:54:28 +03:00
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 }[];
2026-09-22 18:12:57 +03:00
/** 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. */
2026-09-22 22:05:27 +03:00
slots? : Record < ModelSlotName , { model : { provider : string ; id : string } ; thinkingLevel ?: ThinkingLevel } | null >;
2026-09-22 13:54:28 +03:00
}
/** 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 ,
2026-10-26 19:43:09 +02:00
) : "loading" | "keys" | "pickers" {
if ( providers === null ) return "loading" ;
2026-09-22 13:54:28 +03:00
return providers . some (( p ) => p . keyPresent ) ? "pickers" : "keys" ;
}
2026-10-26 19:43:09 +02:00
type ModelSelection = Pick < ModelCatalogEntry , "provider" | "id" >;
/** Saved identity is independent of selectable metadata. A disconnected
* pin still prefills; only the menu is restricted to the live catalog. */
2026-09-22 18:12:57 +03:00
export function prefillFromSlots (
models : ModelCatalogEntry [],
slots : Record < ModelSlotName , { model : { provider : string ; id : string } } | null > | undefined ,
2026-10-26 19:43:09 +02:00
) : Partial < Record < ModelSlotName , ModelSelection >> {
const pre : Partial < Record < ModelSlotName , ModelSelection >> = {};
2026-09-22 18:12:57 +03:00
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 ;
2026-10-26 19:43:09 +02:00
if ( pin ? . model ) pre [ slot ] = entry ?? pin . model ;
2026-09-22 18:12:57 +03:00
}
return pre ;
}
2026-09-22 22:05:27 +03:00
/** 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 );
}
2026-09-24 23:20:59 +03:00
/** 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 ,
2026-10-24 09:53:10 -07:00
isCustom ,
2026-10-29 04:43:53 +02:00
onCustomSaved ,
2026-09-24 23:20:59 +03:00
} : {
/** 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 < HTMLDivElement | null >;
2026-10-24 10:31:48 -07:00
/** Chosen provider is the custom endpoint: a base URL field joins the key
* field. All three optional so plain-key callers (and tests) stay unchanged. */
2026-10-24 09:53:10 -07:00
isCustom? : boolean ;
2026-10-29 04:43:53 +02:00
onCustomSaved ?: () => void ;
2026-09-24 23:20:59 +03:00
}) {
2026-10-25 17:18:44 +03:00
const { t } = useI18n ();
2026-09-24 23:20:59 +03:00
return (
< div className = "flex flex-col gap-3 px-6 py-5" data-testid = "setup-stage-keys" >
< p className = "text-xs text-muted-foreground" >
2026-10-25 17:18:44 +03:00
{ t ( "Choose your AI provider and paste its API key. You’ ll pick your models next." )}</ p >
2026-09-24 23:20:59 +03:00
< div className = "flex flex-col gap-2" >
< div ref = { menuAnchor } className = "relative max-w-96" >
< button
type = "button"
2026-10-25 17:18:44 +03:00
aria-label = { t ( "Provider" )}
2026-09-24 23:20:59 +03:00
data-testid = "setup-key-provider"
aria-expanded = { menuOpen }
onClick = { onToggleMenu }
className = "flex h-7 w-full items-center gap-1 rounded-sm border border-border bg-background px-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50"
>
2026-10-25 17:18:44 +03:00
< span className = "min-w-0 flex-1 truncate text-left" >{ provider ? provider.label : t ( "Pick a provider…" )}</ span >
2026-09-24 23:20:59 +03:00
< span aria - hidden className = "text-muted-foreground" >
▾
</ span >
</ button >
{ menuOpen && (
< MenuShell
anchorRef = { menuAnchor }
testid = "setup-key-provider-menu"
preferredSide = "down"
align = "left"
menuClass = "flex w-[22rem] flex-col rounded-xl border border-border bg-card p-2 text-xs shadow-xl"
>
< ProviderMenuList providers = { providers ?? []} onPick = { onPick } />
</ MenuShell >
)}
</ div >
2026-10-29 04:43:53 +02:00
{ provider && ( isCustom ? < CustomEndpointSetup onSaved = { onCustomSaved ?? onSave } /> : (
2026-10-24 09:53:10 -07:00
< div className = "flex max-w-96 flex-col gap-2" >
< div className = "flex items-center gap-2" >
< input
type = "text"
value = { value }
onChange = {( e ) => onValue ( e . target . value )}
onKeyDown = {( e ) => {
2026-10-24 10:18:13 -07:00
if ( e . key === "Enter" && value . trim ()) onSave ();
2026-10-24 09:53:10 -07:00
}}
2026-10-31 13:45:06 +03:00
placeholder = { t ( "Paste key…" )}
aria-label = { t ( "{v0} key" , { v0 : provider.label })}
2026-10-24 09:53:10 -07:00
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"
/>
< button
type = "button"
onClick = { onSave }
2026-10-24 10:18:13 -07:00
disabled = { saving || ! value . trim ()}
2026-10-24 09:53:10 -07:00
data-testid = "setup-key-save"
className = "shrink-0 rounded-sm border border-border bg-background px-3 py-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50 disabled:cursor-not-allowed disabled:opacity-40"
>
2026-10-31 13:45:06 +03:00
{ saving ? t ( "Saving…" ) : t ( "Save key" )}
2026-10-24 09:53:10 -07:00
</ button >
</ div >
2026-09-24 23:20:59 +03:00
</ div >
2026-10-29 04:43:53 +02:00
))}
2026-09-24 23:20:59 +03:00
{ error && (
< p role = "alert" data-testid = "setup-key-error" className = "text-xs text-red-400" >
2026-10-25 17:18:44 +03:00
{ t ( error )}
2026-09-24 23:20:59 +03:00
</ p >
)}
</ div >
2026-10-25 17:18:44 +03:00
< div className = "mt-2 border-t border-border/60 pt-4" >< h3 className = "mb-3 font-ui text-xs text-muted-foreground" >{ t ( "Subscriptions" )}</ h3 >< SubscriptionConnections setup /></ div >
2026-09-24 23:20:59 +03:00
</ div >
);
}
2026-10-26 19:43:09 +02:00
function prefillLevels ( body : ModelsBody | undefined ) : Partial < Record < ModelSlotName , ThinkingLevel >> {
const levels : Partial < Record < ModelSlotName , ThinkingLevel >> = {};
for ( const slot of SLOT_ORDER ) {
const pin = body ? . slots ? .[ slot ];
const entry = body ? . models . find (( m ) => m . provider === pin ? . model . provider && m . id === pin . model . id );
if ( pin ? . thinkingLevel !== undefined ) levels [ slot ] = pin . thinkingLevel ;
else if ( entry ) levels [ slot ] = prefillLevelFromPin ( entry , pin );
}
return levels ;
}
interface SetupDialogProps {
2026-09-22 13:54:28 +03:00
open : boolean ;
onClose : () => void ;
2026-10-26 19:43:09 +02:00
/** Saving never sends the caller's draft. */
2026-09-22 13:54:28 +03:00
onDone : ( models : ModelsSettings ) => void ;
2026-10-26 19:43:09 +02:00
}
export function SetupDialog ( props : SetupDialogProps ) {
// An open owns its draft. Mount-time cache seeding avoids a key-screen
// first frame, and reopening cannot reuse a previous open's edits.
return props . open ? < OpenSetupDialog { ...props } /> : null ;
}
function OpenSetupDialog ({ onClose , onDone } : SetupDialogProps ) {
2026-10-25 17:18:44 +03:00
const { t } = useI18n ();
2026-10-17 00:38:57 +03:00
const backdropDismiss = useBackdropDismiss ( onClose );
2026-10-26 19:43:09 +02:00
const { catalog , failed , refresh } = useModelCatalog ();
2026-10-27 16:12:13 +02:00
const providerStageSeen = useRef ( false );
const connectionCloseRequested = useRef ( false );
2026-10-26 19:43:09 +02:00
const [ picked , setPicked ] = useState (() => prefillFromSlots ( catalog ? . models ?? [], catalog ? . slots ));
const [ levels , setLevels ] = useState (() => prefillLevels ( catalog ));
const editedSlots = useRef ( new Set < ModelSlotName >());
2026-09-22 13:54:28 +03:00
const [ saving , setSaving ] = useState ( false );
const [ error , setError ] = useState < string | null >( null );
2026-09-22 22:05:27 +03:00
const [ openMenu , setOpenMenu ] = useState < { slot : ModelSlotName ; kind : "model" | "thinking" } | null > ( null );
2026-09-24 23:20:59 +03:00
// 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 < ProviderKeyInfo [] | undefined >( undefined );
const [ keyProvider , setKeyProvider ] = useState < ProviderKeyInfo | null >( null );
const [ keyValue , setKeyValue ] = useState ( "" );
const [ keySaving , setKeySaving ] = useState ( false );
const [ keyError , setKeyError ] = useState < string | null >( null );
const [ keyMenuOpen , setKeyMenuOpen ] = useState ( false );
2026-10-09 04:54:24 +03:00
// Stage 1's provider-picker trigger wrapper — the shared shell's anchor.
2026-10-26 19:43:09 +02:00
// Stage 2's slot menus anchor by anchorTestid instead — see the slot
// MenuShell below for why a freshly attached ref cannot work there.
2026-09-24 23:20:59 +03:00
const keyTriggerRef = useRef < HTMLDivElement >( null );
2026-09-22 13:54:28 +03:00
2026-09-24 23:20:59 +03:00
// The keys signal (zero-keys wave): keys-section saves/deletes AND
// THIS dialog's own first-key save dispatch it; the fetch effect below
2026-10-27 16:12:13 +02:00
// re-runs on it, so saving a key widens the catalog. Incomplete saved
// slots advance to pickers; complete saved slots just close setup.
2026-09-24 23:20:59 +03:00
const [ keysTick , setKeysTick ] = useState ( 0 );
useSettingsSaved (( section ) => {
if ( section === "keys" ) setKeysTick (( t ) => t + 1 );
});
2026-09-22 13:54:28 +03:00
useEffect (() => {
2026-10-24 12:17:08 +03:00
let cancelled = false ;
2026-09-24 23:20:59 +03:00
fetch ( "/api/keys" )
. then (( r ) => r . json ())
2026-10-24 12:17:08 +03:00
. then (( b : { providers? : ProviderKeyInfo [] }) => { if ( ! cancelled ) setKeyProviders ( b . providers ?? []); })
. catch (() => { if ( ! cancelled ) setKeyProviders ([]); });
return () => { cancelled = true ; };
2026-10-26 19:43:09 +02:00
}, [ keysTick ]);
useEffect (() => {
if ( ! catalog ) return ;
const pre = prefillFromSlots ( catalog . models , catalog . slots );
const preLevels = prefillLevels ( catalog );
// Fresh metadata is always used, but a response cannot reset an
// edited slot, including a level chosen while the request was pending.
setPicked (( prev ) => {
const next = { ... prev };
for ( const slot of SLOT_ORDER ) if ( ! editedSlots . current . has ( slot )) {
if ( pre [ slot ]) next [ slot ] = pre [ slot ]; else delete next [ slot ];
}
return next ;
});
setLevels (( prev ) => {
const next = { ... prev };
for ( const slot of SLOT_ORDER ) if ( ! editedSlots . current . has ( slot )) {
if ( preLevels [ slot ] !== undefined ) next [ slot ] = preLevels [ slot ]; else delete next [ slot ];
}
return next ;
});
}, [ catalog ]);
2026-09-22 13:54:28 +03:00
2026-10-27 16:12:13 +02:00
const stage = setupStage ( catalog ? . providers ?? null );
const closeAfterConnection = stage === "pickers" && providerStageSeen . current && savedSlotsConfigured ( catalog ? . slots );
useEffect (() => {
if ( stage === "keys" ) providerStageSeen . current = true ;
if ( closeAfterConnection && ! connectionCloseRequested . current ) {
connectionCloseRequested . current = true ;
onClose (); // Connection recovery is not a model-settings save.
}
}, [ stage , closeAfterConnection , onClose ]);
2026-09-22 13:54:28 +03:00
// 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 (() => {
const onKey = ( e : KeyboardEvent ) => {
if ( e . key !== "Escape" ) return ;
2026-10-07 00:08:49 +03:00
// 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 ();
2026-09-24 23:20:59 +03:00
if ( keyMenuOpen ) {
setKeyMenuOpen ( false );
return ;
}
2026-09-22 22:05:27 +03:00
if ( openMenu !== null ) setOpenMenu ( null );
2026-09-22 13:54:28 +03:00
else onClose ();
};
const onPointerDown = ( e : PointerEvent ) => {
2026-10-17 00:38:57 +03:00
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 );
}
2026-09-22 22:05:27 +03:00
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 );
2026-09-22 13:54:28 +03:00
};
2026-10-07 00:08:49 +03:00
document . addEventListener ( "keydown" , onKey );
2026-09-22 13:54:28 +03:00
document . addEventListener ( "pointerdown" , onPointerDown );
return () => {
2026-10-07 00:08:49 +03:00
document . removeEventListener ( "keydown" , onKey );
2026-09-22 13:54:28 +03:00
document . removeEventListener ( "pointerdown" , onPointerDown );
};
2026-10-26 19:43:09 +02:00
}, [ onClose , openMenu , keyMenuOpen ]);
2026-09-22 13:54:28 +03:00
2026-10-27 16:12:13 +02:00
// Do not briefly paint model pickers between successful connection and
// closing an already-configured setup. No saved model/thinking writes.
if ( closeAfterConnection ) return null ;
2026-09-22 13:54:28 +03:00
const allPicked = SLOT_ORDER . every (( s ) => picked [ s ]);
2026-09-24 23:20:59 +03:00
/** Stage 1's save: PUT the pasted key, dispatch the keys signal, and
2026-10-27 16:12:13 +02:00
* CLEAN the local key field. The fresh catalog decides whether to
* close recovery or advance to the missing model slots. Shape-gate
* 400s keep their user-facing line inline. */
2026-09-24 23:20:59 +03:00
async function saveKey() {
2026-10-24 09:53:10 -07:00
if ( ! keyProvider || keySaving ) return ;
2026-10-24 10:18:13 -07:00
if ( ! keyValue . trim ()) return ;
2026-09-24 23:20:59 +03:00
setKeySaving ( true );
setKeyError ( null );
const res = await fetch ( `/api/keys/ ${ encodeURIComponent ( keyProvider . envVar ) } ` , {
2026-10-24 09:53:10 -07:00
method : "PUT" ,
headers : { "content-type" : "application/json" },
body : JSON.stringify ({ value : keyValue }),
}). catch (() => null );
2026-09-24 23:20:59 +03:00
setKeySaving ( false );
if ( ! res || ! res . ok ) {
const body = ( await res ? . json (). catch (() => ({}))) as { error? : string } | undefined ;
2026-10-17 00:38:57 +03:00
setKeyError ( res ? ( body ? . error ?? `Save failed ( ${ res . status } )` ) : "Save failed. Check your connection and try again." );
2026-09-24 23:20:59 +03:00
return ;
}
setKeyProvider ( null );
setKeyValue ( "" );
notifySettingsSaved ( "keys" );
}
2026-09-22 13:54:28 +03:00
async function save() {
if ( ! allPicked || saving ) return ;
setSaving ( true );
setError ( null );
const models : ModelsSettings = {};
2026-09-22 22:05:27 +03:00
for ( const slot of SLOT_ORDER ) {
const level = levels [ slot ];
models [ slot ] = {
model : { provider : picked [ slot ] ! . provider , id : picked [ slot ] ! . id },
...( level ? { thinkingLevel : level } : {}),
};
}
2026-09-22 13:54:28 +03:00
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 );
2026-10-17 00:38:57 +03:00
setError ( res ? `Save failed: ${ ( await res . json (). catch (() => ({ } ))).error ?? res.status}` : "Save failed. Check your connection and try again." );
2026-09-22 13:54:28 +03:00
return ;
}
2026-09-22 21:55:18 +03:00
notifySettingsSaved ( "models" );
2026-09-22 13:54:28 +03:00
onDone ( models );
}
2026-10-26 19:43:09 +02:00
const keysBody = < SetupKeysBody
2026-10-24 10:31:48 -07:00
providers = { keyProviders ? [... keyProviders , CUSTOM_PROVIDER_INFO ] : undefined }
2026-10-26 19:43:09 +02:00
provider = { keyProvider }
value = { keyValue }
saving = { keySaving }
error = { keyError }
menuOpen = { keyMenuOpen }
menuAnchor = { keyTriggerRef }
2026-10-24 09:53:10 -07:00
isCustom = { keyProvider ? . id === CUSTOM_PROVIDER . id }
2026-10-29 04:43:53 +02:00
onCustomSaved = {() => { setKeyProvider ( null ); notifySettingsSaved ( "keys" ); }}
2026-10-26 19:43:09 +02:00
onToggleMenu = {() => setKeyMenuOpen (( v ) => ! v )}
onPick = {( p ) => { setKeyProvider ( p ); setKeyMenuOpen ( false ); setKeyError ( null ); }}
onValue = { setKeyValue }
onSave = {() => void saveKey ()}
/>;
2026-09-22 13:54:28 +03:00
return createPortal (
< div
className = "fixed inset-0 z-50 flex items-center justify-center bg-background/80"
data-testid = "setup-backdrop"
2026-10-17 00:38:57 +03:00
{ ...backdropDismiss }
2026-09-22 13:54:28 +03:00
>
< div
role = "dialog"
aria-modal = "true"
2026-10-25 17:18:44 +03:00
aria-label = { t ( "Set up your models" )}
2026-09-22 13:54:28 +03:00
data-testid = "setup-dialog"
className = "settings-dialog-pop flex max-h-[85vh] w-[640px] max-w-[90vw] flex-col overflow-hidden rounded-md border border-border bg-card shadow-xl"
onClick = {( e ) => e . stopPropagation ()}
>
< header className = "flex h-15 shrink-0 items-center gap-3 border-b border-border px-5" >
2026-10-25 17:18:44 +03:00
< h2 className = "font-title text-2xl tracking-tight" >{ t ( "Set up your models" )}</ h2 >
< Hint label = { t ( "Close" )} binding = "escape" side = "left" >
2026-09-25 01:52:20 +03:00
< button
2026-09-22 13:54:28 +03:00
type = "button"
2026-10-25 17:18:44 +03:00
aria-label = { t ( "Close setup" )}
2026-09-22 13:54:28 +03:00
data-testid = "setup-close"
onClick = { onClose }
className = "ml-auto text-muted-foreground transition-colors hover:text-foreground"
>
✕
</ button >
2026-09-25 01:52:20 +03:00
</ Hint >
2026-09-22 13:54:28 +03:00
</ header >
2026-10-26 19:43:09 +02:00
{ failed && (
< p role = "alert" data-testid = "setup-catalog-error" className = "px-6 py-3 text-sm text-foreground" >
2026-10-31 13:45:06 +03:00
{ t ( "Model catalog could not be loaded. Check the connection and retry." )} < button type = "button" data-testid = "setup-retry" className = "underline" onClick = { refresh }>{ t ( "Retry" )}</ button >
2026-10-26 19:43:09 +02:00
</ p >
)}
{ stage === "loading" ? (
! failed && < p data-testid = "setup-stage-loading" role = "status" className = "px-6 py-5 text-xs text-muted-foreground" > Loading models … </ p >
) : stage === "keys" ? (
< div className = "flex flex-col px-6 py-5" >{ keysBody }</ div >
2026-09-22 13:54:28 +03:00
) : (
< div className = "flex flex-col gap-4 overflow-y-auto px-6 py-5" data-testid = "setup-stage-pickers" >
< p className = "text-xs text-muted-foreground" >
2026-10-25 17:18:44 +03:00
{ t ( "Pick the model for each tier. All three are required before chats can start." )}</ p >
2026-09-22 13:54:28 +03:00
{ SLOT_ORDER . map (( slot ) => {
const entry = picked [ slot ];
2026-09-22 22:05:27 +03:00
const level = levels [ slot ];
2026-10-26 19:43:09 +02:00
const metadata = catalog ? . models . find (( m ) => m . id === entry ? . id && m . provider === entry . provider );
// Unavailable metadata cannot erase or clamp a saved preference.
const levelsForEntry = metadata ? . supportedLevels ?? ( entry ? Object . keys ( THINKING_LABEL ) : []);
const available = catalog ? . models ?? ( failed ? [] : undefined );
2026-10-24 10:40:17 -07:00
const slotCatalog = slot === "vision"
? available ? . filter (( m ) => m . input . includes ( "image" ) || m . provider === CUSTOM_PROVIDER . id )
: available ;
2026-09-22 13:54:28 +03:00
return (
< div key = { slot } data-testid = { `setup-slot- ${ slot } ` } className = "flex flex-col gap-1.5" >
2026-09-22 18:12:57 +03:00
< span className = "flex items-center gap-1.5" >
< span className = "font-ui text-sm text-foreground" >{ SLOT_META [ slot ]. label }</ span >
2026-10-27 16:12:13 +02:00
{ ! catalog ? . slots ? .[ slot ] ? . model && ! picked [ slot ] && (
2026-09-22 18:12:57 +03:00
< span
data-testid = { `setup-needed- ${ slot } ` }
className = "rounded-sm border border-border px-1 py-0.5 text-[10px] font-normal tracking-wide text-muted-foreground"
>
2026-10-25 17:18:44 +03:00
{ t ( "needed" )}</ span >
2026-09-22 18:12:57 +03:00
)}
</ span >
2026-10-25 17:18:44 +03:00
< span className = "text-xs text-muted-foreground/70" >{ t ( SLOT_META [ slot ]. roster )}</ span >
2026-10-25 00:27:52 +03:00
{ slot !== "strong" && <>< ChatGPTBackgroundAccess provider = { entry ? . provider } />< NativeSubscriptionBackgroundAccess provider = { entry ? . provider } /></>}
2026-09-22 22:05:27 +03:00
< div className = "flex items-center gap-2" >
2026-10-09 04:54:24 +03:00
< div className = "relative flex-1" >
2026-10-26 19:43:09 +02:00
< Hint label = { entry ? ` ${ entry . provider } : ${ entry . id } ` : "Pick a model…" }>
2026-09-22 22:05:27 +03:00
< button
type = "button"
2026-10-25 17:18:44 +03:00
aria-label = { t ( "{v0} model" , { v0 : SLOT_META [ slot ]. label })}
2026-09-22 22:05:27 +03:00
data-testid = { `setup-trigger- ${ slot } ` }
aria-expanded = { openMenu ? . slot === slot && openMenu . kind === "model" }
onClick = {() => setOpenMenu ( openMenu ? . slot === slot && openMenu . kind === "model" ? null : { slot , kind : "model" })}
className = "flex h-7 min-w-40 w-full items-center gap-1 rounded-sm border border-border bg-background px-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50"
2026-09-22 13:54:28 +03:00
>
2026-10-25 17:18:44 +03:00
< span className = "min-w-0 flex-1 truncate" >{ entry ? prettyModel ( entry . id ) : t ( "Pick a model…" )}</ span >
2026-09-22 22:05:27 +03:00
< span aria - hidden className = "text-muted-foreground" >
▾
</ span >
</ button >
2026-10-26 19:43:09 +02:00
</ Hint >
2026-10-09 04:54:24 +03:00
{ /* 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. */ }
2026-09-22 22:05:27 +03:00
{ openMenu ? . slot === slot && openMenu . kind === "model" && (
< MenuShell
2026-10-09 04:54:24 +03:00
anchorTestid = { `setup-trigger- ${ slot } ` }
2026-09-22 22:05:27 +03:00
testid = { `setup-menu- ${ slot } ` }
preferredSide = "down"
align = "left"
menuClass = "flex w-[22rem] flex-col rounded-xl border border-border bg-card p-2 text-xs shadow-xl"
>
< ModelMenuList
2026-10-26 19:43:09 +02:00
models = { slotCatalog }
2026-09-22 22:05:27 +03:00
effectiveId = { entry ? . id }
effectiveProvider = { entry ? . provider }
2026-10-24 10:40:17 -07:00
vision = { slot === "vision" }
2026-09-22 22:05:27 +03:00
onPick = {( m ) => {
2026-10-26 19:43:09 +02:00
editedSlots . current . add ( slot );
2026-09-22 22:05:27 +03:00
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 );
}}
2026-10-26 19:43:09 +02:00
footer = { slotCatalog && slotCatalog . length === 0 && slot === "vision" ? "No image-capable models. Add a provider key with access to a vision model." : undefined }
2026-09-22 22:05:27 +03:00
/>
</ MenuShell >
)}
</ div >
{ /* Thinking selector (chunk 2): editable, pre-populated from
the ordering per picked model — no model, no levels. */ }
2026-10-09 04:54:24 +03:00
< div className = "relative" >
2026-09-22 22:05:27 +03:00
< button
type = "button"
2026-10-25 17:18:44 +03:00
aria-label = { t ( "{v0} thinking" , { v0 : SLOT_META [ slot ]. label })}
2026-09-22 22:05:27 +03:00
data-testid = { `setup-thinking- ${ slot } ` }
aria-expanded = { openMenu ? . slot === slot && openMenu . kind === "thinking" }
disabled = { ! entry }
onClick = {() => setOpenMenu ( openMenu ? . slot === slot && openMenu . kind === "thinking" ? null : { slot , kind : "thinking" })}
className = "flex h-7 w-24 items-center gap-1 rounded-sm border border-border bg-background px-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50 disabled:cursor-not-allowed disabled:opacity-40"
>
< span className = "min-w-0 flex-1 truncate text-left" >
{ entry ? ( level ? THINKING_LABEL [ level ] ?? level : "…" ) : "—" }
</ span >
< span aria - hidden className = "text-muted-foreground" >
▾
</ span >
</ button >
{ openMenu ? . slot === slot && openMenu . kind === "thinking" && (
< MenuShell
2026-10-09 04:54:24 +03:00
anchorTestid = { `setup-thinking- ${ slot } ` }
2026-09-22 22:05:27 +03:00
testid = { `setup-thinking-menu- ${ slot } ` }
preferredSide = "down"
align = "left"
menuClass = "flex w-44 flex-col gap-0.5 rounded-xl border border-border bg-card p-2 text-xs shadow-xl"
>
< ThinkingMenuList
levels = { levelsForEntry . map ( String )}
effectiveLevel = { level }
onPick = {( lv ) => {
2026-10-26 19:43:09 +02:00
editedSlots . current . add ( slot );
2026-09-22 22:05:27 +03:00
setLevels (( prev ) => ({ ... prev , [ slot ] : lv as ThinkingLevel }));
setOpenMenu ( null );
}}
/>
</ MenuShell >
)}
</ div >
2026-09-22 13:54:28 +03:00
</ div >
</ div >
);
})}
< div className = "flex items-center gap-3" >
< button
type = "button"
data-testid = "setup-save"
disabled = { ! allPicked || saving }
onClick = {() => void save ()}
className = "rounded-sm border border-border bg-background px-3 py-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50 disabled:cursor-not-allowed disabled:opacity-40"
>
2026-10-25 17:18:44 +03:00
{ saving ? t ( "Saving…" ) : t ( "Save models" )}
2026-09-22 13:54:28 +03:00
</ button >
2026-10-25 17:18:44 +03:00
{ error && < p className = "text-xs text-red-400" >{ t ( error )}</ p >}
2026-09-22 13:54:28 +03:00
</ div >
</ div >
)}
</ div >
</ div >,
document . body ,
);
}