Feat/model selector (#770)

* feat(x): add useModels hook — shared model-catalog store

One module-level store behind useSyncExternalStore: N mounted consumers
share one snapshot and one in-flight fetch (models:list + models.json +
oauth state + llm:getDefaultModel). Re-fetches on models-config-changed,
oauth:didConnect and chatgpt:statusChanged; newest-wins seq guard ports
the composer's epoch race protection. Extracted verbatim from the chat
composer's loadModelConfig for the ModelSelector consolidation (Phase 1).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(x): add ModelSelector extracted from the chat composer

Controlled picker ({provider, model} value + onChange, persistence stays
with the caller) rendering the composer's exact UI: provider-grouped
catalog + live groups via useProviderModels, search filter, standalone
default row, locked-model display, and the reasoning-effort control
(shown only for catalog-flagged reasoning models, '' reported up when
the model loses reasoning support). Catalog comes from useModels().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(x): migrate chat composer to useModels + ModelSelector

Deletes the composer's inline copies of the catalog loader (oauth state,
llm:getDefaultModel, models:list, models.json parsing, event listeners)
and the picker/effort dropdown JSX in favor of the shared hook and
component. Behavior preserved: picks persist globally via
models:updateConfig({defaultSelection}) + models-config-changed dispatch,
locked-model display, per-turn unpersisted reasoning effort, and the
tab-activation re-fetch. SelectedModel/ReasoningEffortLevel exports stay
for existing consumers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(x): ModelSelector settings modes — sentinel, field trigger, provider scoping, custom ids

defaultOption pins a top entry that selects null ('Rowboat default' /
'Same as assistant'); variant='field' renders a SelectTrigger-style
full-width box; providerFilter scopes to one provider's group, falling
back to the raw models:list catalog (newly exposed on the useModels
snapshot as catalogByProvider) for providers mid-setup; allowCustom
offers a Use-"<text>" row when the search matches nothing. onChange
widens to ModelRef | null — the composer guards the (unreachable
without defaultOption) null and is otherwise untouched. Still zero
persistence inside the component.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(x): signed-in Settings Models section on ModelSelector

The four hybrid dropdowns (Assistant / Knowledge graph / Background
agents / Permission checks) become field-variant ModelSelectors with a
'Rowboat default' sentinel, sourcing the shared store's groups — BYOK
providers without a static catalog (e.g. OpenRouter) now live-fetch
their full list inside the open dropdown instead of showing only the
saved model (user-reported bug). State is ModelRef | null; the
provider::model hybridKey round-trip, options collector, and local
providerDisplayNames map are gone. Persistence unchanged: nothing
writes until Save's single models:updateConfig + models-config-changed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(x): BYOK per-provider category fields on ModelSelector

Knowledge graph / Meeting notes / Track block / Auto-permission become
provider-scoped field-variant ModelSelectors (providerFilter=active
card, allowCustom, 'Same as assistant' sentinel), replacing the static
catalog Select + free-text Input pair. Values still persist as bare
model-id strings inside providers[flavor] through the existing save
path — the ModelRef ↔ string adapter lives at the call site. Deletes
the section's own models:list loader (modelsCatalog/modelsLoading/
showModelInput/LlmModelOption); scoped pickers fall back to the store's
catalogByProvider for providers mid-setup, and configured providers now
live-fetch full lists (OpenRouter bug fix on the BYOK side too).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(x): assert defaultModel refreshes on models-config-changed

Covers the settings-Save → event → store refetch leg that updates a
fresh composer tab's trigger label.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(x): ModelSelector inheritance mode + un-scoped custom ids

inheritDefault?: {label} is defaultOption with placeholder styling —
one sentinel code path, the trigger renders the label muted when
nothing overrides. allowCustom no longer requires providerFilter: an
un-scoped custom entry splits 'provider/model' on the first slash
(OpenRouter-style ids must be typed provider-qualified) and pairs
slash-less text with the global default's provider, matching how the
runtime resolves a provider-less override. Adds modelOverrideToRef /
refToModelOverride adapters for the two-optional-strings persistence
shape shared by BackgroundTask and LiveNote.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(x): bg-task model/provider override on ModelSelector

The Advanced section's two free-text Inputs become one inheritance-mode
picker ('(global default)' sentinel, allowCustom for arbitrary ids).
Same draft state and bg-task:patch save path; a task with model set but
no provider round-trips untouched via the shared override adapters.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(x): live-note model/provider override on ModelSelector

Same inheritance-mode picker as bg-tasks (the section was a copy-paste
of it), same shared adapters, unchanged live-note:set save path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(x): EnableAgentsDialog model picker on ModelSelector

Replaces the flat models:list-flattening <select> with an
inheritance-mode picker pre-seeded from the pinned agent's bg-task
model/provider. '(default)' (null) still enables with an active-only
patch so agents keep their pinned model; a picked model still patches
each bundled agent once. Drops the dialog's private catalog fetch —
the shared store already has the data.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(x): ModelSelector staticOptions mode for caller-supplied lists

When staticOptions is set the picker renders ONLY those rows (+ the
defaultOption sentinel) — no catalog groups, no live fetches. Entries
are opaque engine ids carried in ref.model with provider '', so
call-site adapters stay one-liners ('default' ↔ null, id ↔ string).
Rows whose label differs from their id show the id as secondary text,
disambiguating Claude's colliding 'Opus' labels (alias + concrete id).
Adds toSelectorOptions() in code-agent-options to split an engine list
into sentinel label + rows, and a triggerTitle prop for header tooltips.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(x): new-session dialog on ModelSelector

Deletes loadModelOptions() — the admitted composer-mirror duplicate —
in favor of the shared store: the Rowboat-mode picker (still rendered
only for rowboat mode, gated on configured providers) is a field
ModelSelector whose null sentinel keeps 'default → omit model/provider'
create-payload semantics. The agent-model select becomes a
staticOptions picker fed by toSelectorOptions(fetchCodeAgentOptions),
preserving per-agent reload/reset and the 'default → omit agentModel'
payload rule. The effort select stays a plain Select on purpose — a
caller-supplied axis, not model selection.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(x): code session header agent-model picker on ModelSelector

The header's model DropdownMenu becomes a pill ModelSelector with
staticOptions (search + Opus id disambiguation for free); the effort
dropdown stays untouched. codeSession:update payload semantics
unchanged — the sentinel still sends the literal 'default'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(x): delete dead onboarding-modal.tsx

Nothing imports it — App.tsx renders OnboardingModal from
components/onboarding/index.tsx (the steps-based flow). Its rich
four-category model picker was superseded by the silent auto-resolve
in use-onboarding-state; the only other mention was a stale comment in
useConnectors, updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
PRAKHAR PANDEY 2026-07-22 10:59:35 +05:30 committed by GitHub
parent 711aff58f2
commit 2274369383
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1301 additions and 2398 deletions

View file

@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { BadgeCheck, Bot, Download, Link2, RefreshCw, Search, ShieldAlert, Star } from 'lucide-react'
import type { rowboatApp } from '@x/shared'
import { themeForIndex, patternFor } from '@/components/apps/card-theme'
import { ModelSelector } from '@/components/model-selector'
// Catalog tab (spec §14): search the registry, install with the D18 capability
// disclosure, install from a direct bundle URL.
@ -89,26 +90,9 @@ function EnableAgentsDialog({ appName, names, defaultModel, busy, onEnable, onSk
onEnable: (model: ModelChoice | null) => void
onSkip: () => void
}) {
const [options, setOptions] = useState<Array<ModelChoice & { label: string }>>([])
const [selected, setSelected] = useState<string>(defaultModel ? `${defaultModel.provider}::${defaultModel.model}` : '')
useEffect(() => {
void (async () => {
try {
const r = await window.ipc.invoke('models:list', null)
setOptions(r.providers.flatMap((p) => p.models.map((m) => ({
provider: p.id,
model: m.id,
label: `${m.name ?? m.id} (${p.name})`,
}))))
} catch { /* no picker — enable keeps the pinned model */ }
})()
}, [])
const choice = (): ModelChoice | null => {
const [provider, model] = selected.split('::')
return provider && model ? { provider, model } : null
}
// Pre-seeded with the host-pinned model; "(default)" (null) enables
// without a model patch, keeping whatever each agent has pinned.
const [selected, setSelected] = useState<ModelChoice | null>(defaultModel ?? null)
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
@ -123,24 +107,21 @@ function EnableAgentsDialog({ appName, names, defaultModel, busy, onEnable, onSk
<ul className="mb-3 list-inside list-disc text-sm text-muted-foreground">
{names.map((n) => <li key={n}>{n}</li>)}
</ul>
{options.length > 0 && (
<label className="mb-3 flex items-center gap-2 text-sm text-muted-foreground">
Run with
<select value={selected} onChange={(e) => setSelected(e.target.value)}
className="min-w-0 flex-1 truncate rounded-md border border-border bg-background px-2 py-1 text-sm">
{defaultModel && !options.some((o) => o.provider === defaultModel.provider && o.model === defaultModel.model) && (
<option value={`${defaultModel.provider}::${defaultModel.model}`}>{defaultModel.model} (default)</option>
)}
{options.map((o) => (
<option key={`${o.provider}::${o.model}`} value={`${o.provider}::${o.model}`}>{o.label}</option>
))}
</select>
</label>
)}
<label className="mb-3 flex items-center gap-2 text-sm text-muted-foreground">
Run with
<div className="min-w-0 flex-1">
<ModelSelector
variant="field"
inheritDefault={{ label: '(default)' }}
value={selected}
onChange={setSelected}
/>
</div>
</label>
<div className="flex justify-end gap-2">
<button type="button" onClick={onSkip} disabled={busy}
className="rounded-md border border-border px-3 py-1.5 text-sm font-medium hover:bg-accent">Not now</button>
<button type="button" onClick={() => onEnable(choice())} disabled={busy}
<button type="button" onClick={() => onEnable(selected)} disabled={busy}
className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50">
{busy ? 'Turning on…' : 'Turn on & run now'}
</button>

View file

@ -19,6 +19,7 @@ import type { ConversationItem } from '@/lib/chat-conversation'
import { fetchAgentRunTranscript } from '@/lib/agent-transcript'
import { useAgentRunTranscript } from '@/hooks/use-agent-run-transcript'
import { CompactConversation } from '@/components/compact-conversation'
import { ModelSelector, modelOverrideToRef, refToModelOverride } from '@/components/model-selector'
import { RichMarkdownViewer } from '@/components/rich-markdown-viewer'
import { HtmlFileViewer } from '@/components/html-file-viewer'
@ -924,19 +925,13 @@ function SetupTab({
{showAdvanced && (
<div className="mt-3">
<div className="grid grid-cols-[74px_1fr] gap-x-3 gap-y-2.5 text-xs">
<span className="pt-1.5 text-muted-foreground">Model</span>
<Input
value={draft.model ?? ''}
onChange={e => setDraft({ ...draft, model: e.target.value || undefined })}
placeholder="(global default)"
className="h-7 font-mono text-xs"
/>
<span className="pt-1.5 text-muted-foreground">Provider</span>
<Input
value={draft.provider ?? ''}
onChange={e => setDraft({ ...draft, provider: e.target.value || undefined })}
placeholder="(global default)"
className="h-7 font-mono text-xs"
<span className="pt-2 text-muted-foreground">Model</span>
<ModelSelector
variant="field"
inheritDefault={{ label: '(global default)' }}
allowCustom
value={modelOverrideToRef(draft.model, draft.provider)}
onChange={(ref) => setDraft({ ...draft, ...refToModelOverride(ref) })}
/>
</div>
<div className="mt-4">

View file

@ -1,4 +1,4 @@
import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import {
ArrowUp,
@ -17,7 +17,6 @@ import {
Globe,
ImagePlus,
LoaderIcon,
Brain,
Lock,
Mic,
MoreHorizontal,
@ -38,15 +37,13 @@ import {
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useProviderModels, type ProviderModelsFlavor } from '@/hooks/use-provider-models'
import { ModelSelector, type ModelRef, type ReasoningEffortLevel } from '@/components/model-selector'
import { useModels } from '@/hooks/use-models'
import {
type AttachmentIconKind,
getAttachmentDisplayName,
@ -85,131 +82,18 @@ const RECENT_WORK_DIRS_CONFIG_PATH = 'config/recent-work-dirs.json'
const RECENT_WORK_DIRS_CHANGED_EVENT = 'rowboat-chat-recent-work-dirs-changed'
const providerDisplayNames: Record<string, string> = {
openai: 'OpenAI',
anthropic: 'Anthropic',
google: 'Gemini',
ollama: 'Ollama',
openrouter: 'OpenRouter',
aigateway: 'AI Gateway',
'openai-compatible': 'OpenAI-Compatible',
rowboat: 'Rowboat',
// Matches what other subscription clients call this provider; the auth
// itself is "Sign in with ChatGPT" (Plus/Pro subscription).
codex: 'OpenAI Codex',
}
type ProviderName = "openai" | "anthropic" | "google" | "openrouter" | "aigateway" | "ollama" | "openai-compatible" | "rowboat" | "codex"
interface ConfiguredModel {
provider: ProviderName
model: string
}
// One picker group per connected provider. Catalog groups carry a resolved
// model list (models:list / saved config); live groups carry credentials and
// fetch their list from the provider inside the dropdown via
// useProviderModels (models:listForProvider).
const LIVE_PICKER_FLAVORS = new Set<string>(['openrouter', 'aigateway', 'ollama', 'openai-compatible'])
// Catalog-preferred flavors that degrade to a live fetch when models:list has
// no catalog for them (signed-in mode returns only the rowboat provider, or
// the models.dev cache is empty).
const LIVE_FALLBACK_FLAVORS = new Set<string>(['openai', 'anthropic', 'google'])
type ModelPickerGroup =
| { kind: 'catalog'; flavor: string; models: string[] }
| { kind: 'live'; flavor: ProviderModelsFlavor; apiKey: string; baseURL: string; savedModel: string }
// Rendered inside the dropdown's radio group: each live provider fetches its
// own list, so groups load and fail independently. Pinned models (the saved
// default / app default) render first — the model that actually runs is
// always pickable even while the fetch is pending or failed. Live-fetched
// ids carry no reasoning metadata, so the effort control stays hidden for
// them (reasoningByKey lookup misses default to off).
//
// The group owns its header so it can hide itself when the search filter
// matches none of its rows. Loading/error rows are status, not models — they
// render (with the header) regardless of the filter, and don't count toward
// the parent's "No models match" check (which is what gets reported up).
function LiveProviderGroupItems({ group, label, pinnedModels, filter, onModelRowsChange }: {
group: Extract<ModelPickerGroup, { kind: 'live' }>
label: string
pinnedModels: string[]
filter: string
onModelRowsChange: (flavor: string, hasModelRows: boolean) => void
}) {
const { status, models, error, refetch } = useProviderModels({
flavor: group.flavor,
apiKey: group.apiKey,
baseURL: group.baseURL,
})
const items = [...pinnedModels, ...models.filter((m) => !pinnedModels.includes(m))]
const visible = filter ? items.filter((m) => m.toLowerCase().includes(filter)) : items
const showStatus = status === 'loading' || status === 'error'
const hasModelRows = visible.length > 0
useEffect(() => {
onModelRowsChange(group.flavor, hasModelRows)
}, [group.flavor, hasModelRows, onModelRowsChange])
if (!hasModelRows && !showStatus) return null
return (
<>
<DropdownMenuLabel className="text-xs text-muted-foreground">{label}</DropdownMenuLabel>
{visible.map((m) => {
const key = `${group.flavor}/${m}`
return (
<DropdownMenuRadioItem key={key} value={key}>
<span className="truncate">{m}</span>
</DropdownMenuRadioItem>
)
})}
{status === 'loading' && (
<div className="flex items-center gap-2 px-2 py-1.5 text-xs text-muted-foreground">
<LoaderIcon className="h-3 w-3 animate-spin" />
Loading models
</div>
)}
{status === 'error' && (
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault()
refetch()
}}
className="text-xs"
>
<span className="truncate text-destructive">{error || 'Failed to load models'}</span>
<span className="ml-auto shrink-0 text-muted-foreground">Retry</span>
</DropdownMenuItem>
)}
</>
)
}
type RecentWorkDir = {
path: string
lastUsedAt: number
}
export interface SelectedModel {
provider: string
model: string
}
export type ReasoningEffortLevel = 'low' | 'medium' | 'high'
// '' = auto (provider default). Ordered as shown in the picker.
const REASONING_EFFORT_OPTIONS: Array<{ value: '' | ReasoningEffortLevel; label: string; hint: string }> = [
{ value: '', label: 'Auto', hint: 'Provider default' },
{ value: 'low', label: 'Fast', hint: 'Minimal thinking' },
{ value: 'medium', label: 'Balanced', hint: 'Moderate thinking' },
{ value: 'high', label: 'Thorough', hint: 'Deep thinking, costs more' },
]
// The picker itself lives in ModelSelector; these aliases keep the composer's
// public prop surface stable for existing consumers (chat-sidebar, App).
export type SelectedModel = ModelRef
export type { ReasoningEffortLevel } from '@/components/model-selector'
export type PermissionMode = 'manual' | 'auto'
function getSelectedModelDisplayName(model: string) {
return model.split('/').pop() || model
}
function getAttachmentIcon(kind: AttachmentIconKind) {
switch (kind) {
case 'audio':
@ -401,21 +285,15 @@ function ChatInputInner({
const fileInputRef = useRef<HTMLInputElement>(null)
const canSubmit = (Boolean(message.trim()) || attachments.length > 0) && !isProcessing
const [modelGroups, setModelGroups] = useState<ModelPickerGroup[]>([])
const [activeModelKey, setActiveModelKey] = useState('')
// The effective runtime default (what a run actually uses when the user
// hasn't picked a model) — shown in the picker instead of guessing from
// list order, which can disagree with the real default.
const [defaultModel, setDefaultModel] = useState<ConfiguredModel | null>(null)
const loadModelConfigEpoch = useRef(0)
// Shared model-catalog store (one fetch app-wide); sign-in state also
// gates search availability below.
const { isRowboatConnected, refresh: refreshModels } = useModels()
const [selectedModel, setSelectedModel] = useState<SelectedModel | null>(null)
const [lockedModel, setLockedModel] = useState<SelectedModel | null>(null)
// '' = auto. Per-model reasoning capability ("provider/model" → flag) from
// models:list; the effort control renders only for known-reasoning models.
// '' = auto. Effort is per-turn config: reported up, never persisted.
const [reasoningEffort, setReasoningEffort] = useState<'' | ReasoningEffortLevel>('')
const [reasoningByKey, setReasoningByKey] = useState<Record<string, boolean>>({})
const [searchEnabled, setSearchEnabled] = useState(false)
const [searchAvailable, setSearchAvailable] = useState(false)
const [isRowboatConnected, setIsRowboatConnected] = useState(false)
const [codingAgent, setCodingAgent] = useState<'claude' | 'codex'>('claude')
const [codeModeEnabled, setCodeModeEnabled] = useState(false)
const [codeModeFeatureEnabled, setCodeModeFeatureEnabled] = useState(false)
@ -455,7 +333,7 @@ function ChatInputInner({
// no-dep effect below still re-collapses if any toggle happens to widen the row.
useLayoutEffect(() => {
setCollapseLevel(0)
}, [workDir, searchAvailable, codeModeFeatureEnabled, lockedModel, activeModelKey])
}, [workDir, searchAvailable, codeModeFeatureEnabled, lockedModel, selectedModel])
// After each render, if the left group still overflows, collapse one more step.
// Runs before paint, so the intermediate (overflowing) state is never visible.
@ -487,155 +365,17 @@ function ChatInputInner({
}
}, [])
// Check Rowboat sign-in state
// The store loads on mount and re-fetches on config/sign-in events by
// itself; re-fetch on tab activation too, preserving the old per-mount
// reload that picked up external edits to config/models.json.
const didLoadModelsRef = useRef(false)
useEffect(() => {
window.ipc.invoke('oauth:getState', null).then((result) => {
setIsRowboatConnected(result.config?.rowboat?.connected ?? false)
}).catch(() => setIsRowboatConnected(false))
}, [isActive])
// Update sign-in state when OAuth events fire
useEffect(() => {
const cleanup = window.ipc.on('oauth:didConnect', () => {
window.ipc.invoke('oauth:getState', null).then((result) => {
setIsRowboatConnected(result.config?.rowboat?.connected ?? false)
}).catch(() => setIsRowboatConnected(false))
})
return cleanup
}, [])
// Load the list of models the user can choose from. Hybrid mode: signed-in
// users get the gateway list AND every BYOK provider configured in
// models.json (selecting a BYOK model routes that message through the
// user's own key / local server). Signed-out users get BYOK only.
const loadModelConfig = useCallback(async () => {
// Concurrent runs race (mount fires one before the sign-in state resolves,
// which fires another) — only the newest run may write state, else a slow
// stale run can clobber the fresh list with an empty one.
const epoch = ++loadModelConfigEpoch.current
try {
const def = await window.ipc.invoke('llm:getDefaultModel', null)
if (loadModelConfigEpoch.current !== epoch) return
setDefaultModel({ provider: def.provider as ProviderName, model: def.model })
} catch {
if (loadModelConfigEpoch.current === epoch) setDefaultModel(null)
if (!didLoadModelsRef.current) {
didLoadModelsRef.current = true
return
}
try {
const groups: ModelPickerGroup[] = []
// Full catalog per provider (gateway + models.dev cloud providers).
const catalog: Record<string, string[]> = {}
const reasoningFlags: Record<string, boolean> = {}
try {
const listResult = await window.ipc.invoke('models:list', null)
for (const p of listResult.providers || []) {
catalog[p.id] = (p.models || []).map((m: { id: string }) => m.id)
for (const m of p.models || []) {
if (typeof m.reasoning === 'boolean') {
reasoningFlags[`${p.id}/${m.id}`] = m.reasoning
}
}
}
} catch { /* offline / no catalog — groups fall back to saved config below */ }
if (loadModelConfigEpoch.current === epoch) setReasoningByKey(reasoningFlags)
if (isRowboatConnected && (catalog['rowboat'] || []).length > 0) {
groups.push({ kind: 'catalog', flavor: 'rowboat', models: catalog['rowboat'] })
}
// ChatGPT subscription (codex): models:list only carries this catalog
// while signed in with ChatGPT, so presence is the gate.
if ((catalog['codex'] || []).length > 0) {
groups.push({ kind: 'catalog', flavor: 'codex', models: catalog['codex'] })
}
try {
const result = await window.ipc.invoke('workspace:readFile', { path: 'config/models.json' })
const parsed = JSON.parse(result.data)
// List the default provider's group first.
const defaultFlavor = typeof parsed?.provider?.flavor === 'string' ? parsed.provider.flavor : ''
const flavors = Object.keys(parsed?.providers || {})
.sort((a, b) => (a === defaultFlavor ? -1 : b === defaultFlavor ? 1 : 0))
for (const flavor of flavors) {
const e = (parsed.providers[flavor] || {}) as Record<string, unknown>
const apiKey = typeof e.apiKey === 'string' ? e.apiKey.trim() : ''
const baseURL = typeof e.baseURL === 'string' ? e.baseURL.trim() : ''
if (!apiKey && !baseURL) continue // provider not configured
const savedModel = typeof e.model === 'string' ? e.model : ''
// Live flavors fetch their list from the provider inside the
// dropdown, with the credentials saved in config. Catalog flavors
// degrade to the same live fetch when models:list carried no
// catalog for them (signed in, or empty models.dev cache).
const catalogModels = catalog[flavor] || []
if (LIVE_PICKER_FLAVORS.has(flavor) || (catalogModels.length === 0 && LIVE_FALLBACK_FLAVORS.has(flavor))) {
groups.push({ kind: 'live', flavor: flavor as ProviderModelsFlavor, apiKey, baseURL, savedModel })
continue
}
// Catalog group: the saved default model leads, then the catalog.
// Saved models[] survives as the fallback for unknown flavors the
// live fetch doesn't support.
const models: string[] = []
const push = (model: string) => {
if (model && !models.includes(model)) models.push(model)
}
push(savedModel)
if (catalogModels.length > 0) {
for (const m of catalogModels) push(m)
} else {
const saved = Array.isArray(e.models) ? e.models as string[] : []
for (const m of saved) push(m)
}
groups.push({ kind: 'catalog', flavor, models })
}
// The user's explicit default selection leads the picker: its group
// first and, within a catalog group, the model itself first. (Live
// groups pin the default at the top themselves.)
const sel = parsed?.defaultSelection
if (sel && typeof sel.provider === 'string' && typeof sel.model === 'string') {
const index = groups.findIndex((g) => g.flavor === sel.provider)
if (index >= 0) {
const [group] = groups.splice(index, 1)
groups.unshift(group)
if (group.kind === 'catalog') {
const mi = group.models.indexOf(sel.model)
if (mi > 0) {
group.models.splice(mi, 1)
group.models.unshift(sel.model)
}
}
}
}
} catch { /* no BYOK config yet */ }
if (loadModelConfigEpoch.current !== epoch) return
setModelGroups(groups)
} catch (err) {
// No config yet — but surface unexpected failures for diagnosis.
console.error('[chat-input] failed to load model list', err)
}
}, [isRowboatConnected])
useEffect(() => {
loadModelConfig()
}, [isActive, loadModelConfig])
// ChatGPT subscription models appear/disappear with the ChatGPT session.
useEffect(() => {
const cleanup = window.ipc.on('chatgpt:statusChanged', () => { loadModelConfig() })
return cleanup
}, [loadModelConfig])
// Reload when model config changes (e.g. from settings dialog)
useEffect(() => {
const handler = () => { loadModelConfig() }
window.addEventListener('models-config-changed', handler)
return () => window.removeEventListener('models-config-changed', handler)
}, [loadModelConfig])
refreshModels()
}, [isActive, refreshModels])
// Load the global code-mode feature flag (from settings) and stay in sync.
useEffect(() => {
@ -809,83 +549,31 @@ function ChatInputInner({
checkSearch()
}, [isActive, isRowboatConnected])
// Search filter for the model dropdown. Reset each time the menu opens;
// matching is a case-insensitive substring test on the model id. Live
// groups filter themselves and report whether they still have rows, so the
// parent can render the global "No models match" row.
const [modelFilter, setModelFilter] = useState('')
const modelFilterInputRef = useRef<HTMLInputElement>(null)
const [liveGroupHasRows, setLiveGroupHasRows] = useState<Record<string, boolean>>({})
const modelFilterValue = modelFilter.trim().toLowerCase()
const handleLiveGroupRows = useCallback((flavor: string, hasRows: boolean) => {
setLiveGroupHasRows((prev) => (prev[flavor] === hasRows ? prev : { ...prev, [flavor]: hasRows }))
}, [])
// The effective default always renders even when no group carries it (the
// gateway list failed, or its provider was removed from config) — the
// picker must never be missing the model that actually runs. Live groups
// pin the default themselves, so a flavor match is enough there.
const standaloneDefault = useMemo<ConfiguredModel | null>(() => {
if (!defaultModel) return null
const covered = modelGroups.some((g) =>
g.flavor === defaultModel.provider &&
(g.kind === 'live' || g.models.includes(defaultModel.model)))
return covered ? null : defaultModel
}, [modelGroups, defaultModel])
const standaloneVisible = standaloneDefault !== null &&
(!modelFilterValue || standaloneDefault.model.toLowerCase().includes(modelFilterValue))
// Nothing matches anywhere → "No models match". Live groups that haven't
// reported yet (first render after opening) count as having rows so the
// empty row never flashes.
const anyModelRowVisible = standaloneVisible || modelGroups.some((g) =>
g.kind === 'catalog'
? g.models.some((m) => m.toLowerCase().includes(modelFilterValue))
: liveGroupHasRows[g.flavor] !== false)
// Selecting a model affects the *next* run created from this tab (frozen
// once a run exists) AND persists as the app default so background agents
// and new tabs follow the last pick. The models-config-changed dispatch
// re-runs loadModelConfig here too — that re-read lands on the same
// selection (activeModelKey is untouched, the live lists come from the
// hook's cache), so it's visually a no-op.
const handleModelChange = useCallback((key: string) => {
// re-fetches the shared store too — that re-read lands on the same
// selection (selectedModel is untouched, the live lists come from the
// useProviderModels cache), so it's visually a no-op.
const handleModelChange = useCallback((model: SelectedModel | null) => {
if (lockedModel) return
const slash = key.indexOf('/')
if (slash <= 0 || slash === key.length - 1) return
const provider = key.slice(0, slash)
const model = key.slice(slash + 1)
setActiveModelKey(key)
onSelectedModelChange?.({ provider, model })
void window.ipc.invoke('models:updateConfig', { defaultSelection: { provider, model } })
// null = the sentinel row, which the composer never renders (no
// defaultOption) — guard for the widened onChange contract only.
if (!model) return
setSelectedModel(model)
onSelectedModelChange?.(model)
void window.ipc.invoke('models:updateConfig', { defaultSelection: { provider: model.provider, model: model.model } })
.then(() => { window.dispatchEvent(new Event('models-config-changed')) })
.catch(() => { toast.error('Failed to save default model') })
}, [lockedModel, onSelectedModelChange])
// Reasoning effort applies to the model the next message will actually use:
// the run's frozen model once one exists, else the picker selection, else
// the app default. Only known-reasoning models show the control.
const effectiveModelKey = lockedModel
? `${lockedModel.provider}/${lockedModel.model}`
: activeModelKey
|| (defaultModel ? `${defaultModel.provider}/${defaultModel.model}` : '')
const reasoningAvailable = reasoningByKey[effectiveModelKey] === true
const handleReasoningEffortChange = useCallback((value: string) => {
const effort = value === 'low' || value === 'medium' || value === 'high' ? value : ''
// Effort is per-turn and unpersisted; ModelSelector reports '' when the
// effective model loses reasoning support so a stale effort never sticks.
const handleReasoningEffortChange = useCallback((effort: '' | ReasoningEffortLevel) => {
setReasoningEffort(effort)
onReasoningEffortChange?.(effort === '' ? null : effort)
}, [onReasoningEffortChange])
// Switching to a model without reasoning support drops a stale selection —
// otherwise the next message would carry an effort the model rejects.
useEffect(() => {
if (!reasoningAvailable && reasoningEffort !== '') {
setReasoningEffort('')
onReasoningEffortChange?.(null)
}
}, [reasoningAvailable, reasoningEffort, onReasoningEffortChange])
// Restore the tab draft when this input mounts.
useEffect(() => {
if (initialDraft) {
@ -1477,165 +1165,13 @@ function ChatInputInner({
</DropdownMenu>
)}
<div className="flex-1" />
{reasoningAvailable && (
<DropdownMenu>
<Tooltip delayDuration={CHAT_INPUT_TOOLTIP_DELAY_MS}>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex h-7 shrink-0 items-center gap-1 rounded-full px-2 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<Brain className="h-3 w-3 shrink-0" />
{reasoningEffort !== '' && (
<span>{REASONING_EFFORT_OPTIONS.find((o) => o.value === reasoningEffort)?.label}</span>
)}
<ChevronDown className="h-3 w-3 shrink-0" />
</button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="top">Reasoning effort applies to your next message</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end">
<DropdownMenuRadioGroup value={reasoningEffort} onValueChange={handleReasoningEffortChange}>
{REASONING_EFFORT_OPTIONS.map((option) => (
<DropdownMenuRadioItem key={option.value || 'auto'} value={option.value}>
<span>{option.label}</span>
<span className="ml-2 text-xs text-muted-foreground">{option.hint}</span>
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)}
{lockedModel ? (
<Tooltip delayDuration={CHAT_INPUT_TOOLTIP_DELAY_MS}>
<TooltipTrigger asChild>
<span className="flex h-7 min-w-0 items-center gap-1 rounded-full px-2 text-xs text-muted-foreground">
<span className="min-w-0 truncate">{getSelectedModelDisplayName(lockedModel.model)}</span>
</span>
</TooltipTrigger>
<TooltipContent side="top">
{providerDisplayNames[lockedModel.provider] || lockedModel.provider} fixed for this chat
</TooltipContent>
</Tooltip>
) : (
<DropdownMenu
onOpenChange={(open) => {
// The filter is per-opening, never sticky. Focus the search
// input once the content has mounted and Radix has run its own
// open-focus (DropdownMenu.Content has no onOpenAutoFocus).
if (open) {
setModelFilter('')
setLiveGroupHasRows({})
setTimeout(() => modelFilterInputRef.current?.focus(), 0)
}
}}
>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex h-7 min-w-0 items-center gap-1 rounded-full px-2 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<span className="min-w-0 truncate">
{getSelectedModelDisplayName(
(activeModelKey ? activeModelKey.slice(activeModelKey.indexOf('/') + 1) : '')
|| defaultModel?.model
|| 'Model'
)}
</span>
<ChevronDown className="h-3 w-3 shrink-0" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="p-0 overflow-hidden">
{modelGroups.length === 0 && !standaloneDefault ? (
<div className="p-1">
<DropdownMenuItem disabled>Connect a provider in Settings</DropdownMenuItem>
</div>
) : (
<>
{/* Fixed search header lives OUTSIDE the scroll area (the
inner div below scrolls), so it's flush at the very top
and always visible without any scroll. */}
<div className="bg-popover p-1">
<input
ref={modelFilterInputRef}
value={modelFilter}
onChange={(e) => setModelFilter(e.target.value)}
onKeyDown={(e) => {
// Printable keys belong to the input, not the menu's
// typeahead; arrows and Escape stay with the menu.
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp' && e.key !== 'Escape') {
e.stopPropagation()
}
}}
placeholder="Search models…"
className="h-7 w-full rounded-sm border border-input bg-transparent px-2 text-xs outline-none placeholder:text-muted-foreground"
/>
</div>
<div className="max-h-80 overflow-y-auto p-1 pt-0">
<DropdownMenuRadioGroup
value={activeModelKey || (defaultModel ? `${defaultModel.provider}/${defaultModel.model}` : '')}
onValueChange={handleModelChange}
>
{standaloneDefault && standaloneVisible && (
<DropdownMenuRadioItem value={`${standaloneDefault.provider}/${standaloneDefault.model}`}>
<span className="truncate">{standaloneDefault.model}</span>
<span className="ml-2 text-xs text-muted-foreground">
{providerDisplayNames[standaloneDefault.provider] || standaloneDefault.provider}
</span>
</DropdownMenuRadioItem>
)}
{modelGroups.map((g) => {
const label = providerDisplayNames[g.flavor] || g.flavor
if (g.kind === 'live') {
// The app default leads its live group; the group's
// own saved model follows (both stay pickable through
// fetch loading/failure).
const pinned: string[] = []
if (defaultModel && defaultModel.provider === g.flavor) pinned.push(defaultModel.model)
if (g.savedModel && !pinned.includes(g.savedModel)) pinned.push(g.savedModel)
return (
<LiveProviderGroupItems
key={g.flavor}
group={g}
label={label}
pinnedModels={pinned}
filter={modelFilterValue}
onModelRowsChange={handleLiveGroupRows}
/>
)
}
const visibleModels = modelFilterValue
? g.models.filter((m) => m.toLowerCase().includes(modelFilterValue))
: g.models
if (visibleModels.length === 0) return null
return (
<Fragment key={g.flavor}>
<DropdownMenuLabel className="text-xs text-muted-foreground">
{label}
</DropdownMenuLabel>
{visibleModels.map((m) => {
const key = `${g.flavor}/${m}`
return (
<DropdownMenuRadioItem key={key} value={key}>
<span className="truncate">{m}</span>
</DropdownMenuRadioItem>
)
})}
</Fragment>
)
})}
{modelFilterValue && !anyModelRowVisible && (
<div className="px-2 py-1.5 text-xs text-muted-foreground">No models match</div>
)}
</DropdownMenuRadioGroup>
</div>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
<ModelSelector
value={selectedModel}
onChange={handleModelChange}
lockedModel={lockedModel}
effort={reasoningEffort}
onEffortChange={handleReasoningEffortChange}
/>
{onStartCall && (
<div className="flex shrink-0 items-center">
<Tooltip delayDuration={CHAT_INPUT_TOOLTIP_DELAY_MS}>

View file

@ -23,6 +23,20 @@ export function withDefault(options: CodeAgentOption[]): CodeAgentOption[] {
return options.some((o) => o.value === 'default') ? options : [{ value: 'default', label: 'Default' }, ...options]
}
// Adapt an engine option list for ModelSelector: the 'default' entry becomes
// the sentinel (keeping the engine's own label, e.g. Claude's "Default
// (recommended)"), the rest become staticOptions rows.
export function toSelectorOptions(options: CodeAgentOption[]): {
defaultLabel: string
options: Array<{ id: string; label?: string }>
} {
const all = withDefault(options)
return {
defaultLabel: all.find((o) => o.value === 'default')?.label ?? 'Default',
options: all.filter((o) => o.value !== 'default').map((o) => ({ id: o.value, label: o.label })),
}
}
export function optionLabel(options: CodeAgentOption[], value: string | undefined): string {
return options.find((o) => o.value === (value ?? 'default'))?.label ?? value ?? 'Default'
}

View file

@ -1,7 +1,8 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Bot, ChevronDown, ChevronUp, Code2, GitBranch, Terminal as TerminalIcon } from 'lucide-react'
import type { CodeSession, CodeSessionStatus, CodeAgentModelOptions } from '@x/shared/src/code-sessions.js'
import { fetchCodeAgentOptions, withDefault, optionLabel } from './code-agent-options'
import { fetchCodeAgentOptions, toSelectorOptions, withDefault, optionLabel } from './code-agent-options'
import { ModelSelector } from '@/components/model-selector'
import type { ApprovalPolicy } from '@x/shared/src/code-mode.js'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
@ -228,27 +229,15 @@ export function CodeView({
</div>
</div>
<div className="ml-auto flex shrink-0 flex-wrap items-center justify-end gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 gap-1.5 px-2 text-xs text-muted-foreground"
title="Coding agent model"
>
<span className="whitespace-nowrap">{optionLabel(modelOpts.models, selectedSession.agentModel)}</span>
<ChevronDown className="size-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="max-h-80 overflow-y-auto">
{withDefault(modelOpts.models).map((m) => (
<DropdownMenuItem key={m.value} onClick={() => void handleUpdateSession({ agentModel: m.value })}>
{m.label}
{(selectedSession.agentModel ?? 'default') === m.value && <span className="ml-auto"></span>}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<ModelSelector
triggerTitle="Coding agent model"
defaultOption={{ label: toSelectorOptions(modelOpts.models).defaultLabel }}
staticOptions={toSelectorOptions(modelOpts.models).options}
value={selectedSession.agentModel && selectedSession.agentModel !== 'default'
? { provider: '', model: selectedSession.agentModel }
: null}
onChange={(ref) => void handleUpdateSession({ agentModel: ref?.model ?? 'default' })}
/>
{modelOpts.efforts.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>

View file

@ -1,7 +1,9 @@
import { useEffect, useState } from 'react'
import { Bot, GitBranch, Loader2, Terminal } from 'lucide-react'
import type { CodeSession, CodeSessionMode, CodeAgentModelOptions } from '@x/shared/src/code-sessions.js'
import { fetchCodeAgentOptions, withDefault } from './code-agent-options'
import { fetchCodeAgentOptions, toSelectorOptions, withDefault } from './code-agent-options'
import { ModelSelector, type ModelRef } from '@/components/model-selector'
import { useModels } from '@/hooks/use-models'
import type { ApprovalPolicy, CodingAgent } from '@x/shared/src/code-mode.js'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
@ -25,7 +27,6 @@ import {
import type { ProjectRow } from './use-code-sessions'
type AgentStatus = { installed: boolean; signedIn: boolean }
type ModelOption = { provider: string; model: string }
const POLICY_LABEL: Record<ApprovalPolicy, string> = {
ask: 'Ask every time',
@ -33,38 +34,6 @@ const POLICY_LABEL: Record<ApprovalPolicy, string> = {
yolo: 'Auto-approve everything (YOLO)',
}
// Models the user can pick for Rowboat-mode turns — mirrors the chat
// composer's loading: gateway list when signed in, models.json otherwise.
async function loadModelOptions(): Promise<ModelOption[]> {
try {
const oauth = await window.ipc.invoke('oauth:getState', null)
const connected = oauth.config?.rowboat?.connected ?? false
if (connected) {
const listResult = await window.ipc.invoke('models:list', null)
const rowboatProvider = (listResult.providers as Array<{ id: string; models?: Array<{ id: string }> }> | undefined)
?.find((p) => p.id === 'rowboat')
return (rowboatProvider?.models ?? []).map((m) => ({ provider: 'rowboat', model: m.id }))
}
const result = await window.ipc.invoke('workspace:readFile', { path: 'config/models.json' })
const parsed = JSON.parse(result.data)
const models: ModelOption[] = []
if (parsed?.providers) {
for (const [flavor, entry] of Object.entries(parsed.providers)) {
const e = entry as Record<string, unknown>
const modelList: string[] = Array.isArray(e.models) ? e.models as string[] : []
const singleModel = typeof e.model === 'string' ? e.model : ''
const allModels = modelList.length > 0 ? modelList : singleModel ? [singleModel] : []
for (const model of allModels) {
if (model) models.push({ provider: flavor, model })
}
}
}
return models
} catch {
return []
}
}
export function NewSessionDialog({
projectRow,
open,
@ -84,9 +53,11 @@ export function NewSessionDialog({
const [isolation, setIsolation] = useState<'in-repo' | 'worktree'>('in-repo')
const [title, setTitle] = useState('')
const [creating, setCreating] = useState(false)
const [modelOptions, setModelOptions] = useState<ModelOption[]>([])
// 'default' = let the backend use the configured default model.
const [modelKey, setModelKey] = useState('default')
// null = let the backend use the configured default model.
const [sessionModel, setSessionModel] = useState<ModelRef | null>(null)
// Gates the Rowboat-mode picker the way the old options list did: no
// configured providers → no picker.
const { groups: modelGroups } = useModels()
// The coding agent's own model + reasoning effort. 'default' leaves the
// engine default. Choices are discovered live per agent (see effect below).
const [agentModel, setAgentModel] = useState('default')
@ -102,10 +73,9 @@ export function NewSessionDialog({
setCreating(false)
setIsolation('in-repo')
setMode('direct')
setModelKey('default')
setSessionModel(null)
setAgentModel('default')
setAgentEffort('default')
void loadModelOptions().then(setModelOptions)
void window.ipc.invoke('codeMode:checkAgentStatus', null).then((status) => {
setAgentStatus(status)
// Default to whichever agent is actually ready.
@ -138,9 +108,6 @@ export function NewSessionDialog({
if (!projectRow) return
setCreating(true)
try {
const picked = modelKey !== 'default'
? modelOptions.find((m) => `${m.provider}/${m.model}` === modelKey)
: undefined
const res = await window.ipc.invoke('codeSession:create', {
projectId: projectRow.project.id,
title: title.trim() || undefined,
@ -148,7 +115,7 @@ export function NewSessionDialog({
mode,
policy,
isolation,
...(picked ? { model: picked.model, provider: picked.provider } : {}),
...(sessionModel ? { model: sessionModel.model, provider: sessionModel.provider } : {}),
...(agentModel !== 'default' ? { agentModel } : {}),
...(modelOpts.efforts.length > 0 && agentEffort !== 'default' ? { agentEffort } : {}),
})
@ -303,20 +270,19 @@ export function NewSessionDialog({
{/* The coding agent's own model + reasoning effort, discovered live
from the engine and applied to the ACP session each turn (so they
stay editable from the session header later). Effort is a separate
axis only for Claude; Codex folds it into the model id. */}
axis only for Claude; Codex folds it into the model id it stays
a plain Select deliberately: it's not model selection, so it's
out of ModelSelector's scope. */}
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium">Model</label>
<Select value={agentModel} onValueChange={setAgentModel}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{withDefault(modelOpts.models).map((m) => (
<SelectItem key={m.value} value={m.value}>{m.label}</SelectItem>
))}
</SelectContent>
</Select>
<ModelSelector
variant="field"
defaultOption={{ label: toSelectorOptions(modelOpts.models).defaultLabel }}
staticOptions={toSelectorOptions(modelOpts.models).options}
value={agentModel !== 'default' ? { provider: '', model: agentModel } : null}
onChange={(ref) => setAgentModel(ref?.model ?? 'default')}
/>
</div>
{modelOpts.efforts.length > 0 && (
<div className="flex flex-col gap-1.5">
@ -337,21 +303,15 @@ export function NewSessionDialog({
{/* The model only powers Rowboat's own turns; the coding agent uses its
own configured model, so hide this entirely for direct sessions. */}
{mode === 'rowboat' && modelOptions.length > 0 && (
{mode === 'rowboat' && modelGroups.length > 0 && (
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium">Model</label>
<Select value={modelKey} onValueChange={setModelKey}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">Default model</SelectItem>
{modelOptions.map((m) => {
const key = `${m.provider}/${m.model}`
return <SelectItem key={key} value={key}>{m.model}</SelectItem>
})}
</SelectContent>
</Select>
<ModelSelector
variant="field"
defaultOption={{ label: 'Default model' }}
value={sessionModel}
onChange={setSessionModel}
/>
<p className="text-[11px] text-muted-foreground">
Used when Rowboat drives. Fixed once the session is created, like any chat.
</p>

View file

@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import { Input } from '@/components/ui/input'
import { ModelSelector, modelOverrideToRef, refToModelOverride } from '@/components/model-selector'
import {
Play, Square, Loader2, Sparkles,
AlertCircle, Plus, X, Check, Pencil, Radio, Repeat, Clock, Zap,
@ -605,19 +606,13 @@ function DetailsTab({
{showAdvanced && (
<div className="mt-3">
<div className="grid grid-cols-[74px_1fr] gap-x-3 gap-y-2.5 text-xs">
<span className="pt-1.5 text-muted-foreground">Model</span>
<Input
value={draft.model ?? ''}
onChange={(e) => setDraft({ ...draft, model: e.target.value || undefined })}
placeholder="(global default)"
className="h-7 font-mono text-xs"
/>
<span className="pt-1.5 text-muted-foreground">Provider</span>
<Input
value={draft.provider ?? ''}
onChange={(e) => setDraft({ ...draft, provider: e.target.value || undefined })}
placeholder="(global default)"
className="h-7 font-mono text-xs"
<span className="pt-2 text-muted-foreground">Model</span>
<ModelSelector
variant="field"
inheritDefault={{ label: '(global default)' }}
allowCustom
value={modelOverrideToRef(draft.model, draft.provider)}
onChange={(ref) => setDraft({ ...draft, ...refToModelOverride(ref) })}
/>
</div>
<div className="mt-4">

View file

@ -0,0 +1,215 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { __resetModelsForTests } from '@/hooks/use-models'
import { ModelSelector } from './model-selector'
// Radix popper content needs these in jsdom.
class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
}
;(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = ResizeObserverStub
Element.prototype.scrollIntoView = () => {}
// Same preload stub pattern as use-models.test.tsx: invoke routes by channel.
let handlers: Record<string, (args: unknown) => Promise<unknown>> = {}
;(window as unknown as { ipc: unknown }).ipc = {
on: () => () => undefined,
invoke: (channel: string, args: unknown) => {
const handler = handlers[channel]
return handler ? handler(args) : Promise.reject(new Error(`no handler: ${channel}`))
},
}
function serveTwoProviders(): void {
handlers['oauth:getState'] = async () => ({ config: { rowboat: { connected: false } } })
handlers['llm:getDefaultModel'] = async () => ({ provider: 'openai', model: 'gpt-5.4' })
handlers['models:list'] = async () => ({
providers: [
{ id: 'openai', name: 'OpenAI', models: [{ id: 'gpt-5.4' }] },
{ id: 'anthropic', name: 'Anthropic', models: [{ id: 'claude-opus-4-8' }] },
],
})
handlers['workspace:readFile'] = async () => ({
data: JSON.stringify({
provider: { flavor: 'openai' },
model: 'gpt-5.4',
providers: {
openai: { apiKey: 'sk-a', model: 'gpt-5.4' },
anthropic: { apiKey: 'sk-b', model: 'claude-opus-4-8' },
},
}),
})
}
async function openMenu(): Promise<void> {
const trigger = screen.getByRole('button')
// Radix opens the menu from the trigger's keydown handler in jsdom
// (pointerdown would ALSO toggle — one gesture only).
fireEvent.keyDown(trigger, { key: 'Enter' })
await waitFor(() => expect(document.querySelector('[role="menu"]')).not.toBeNull())
}
beforeEach(() => {
__resetModelsForTests()
handlers = {}
})
afterEach(cleanup)
describe('ModelSelector', () => {
it('renders the defaultOption label when value is null and round-trips null through onChange', async () => {
serveTwoProviders()
const onChange = vi.fn()
render(
<ModelSelector
variant="field"
value={{ provider: 'openai', model: 'gpt-5.4' }}
onChange={onChange}
defaultOption={{ label: 'Rowboat default' }}
/>,
)
await waitFor(() => expect(screen.getByRole('button')).toHaveTextContent('gpt-5.4'))
await openMenu()
const sentinel = await screen.findByText('Rowboat default')
fireEvent.click(sentinel)
expect(onChange).toHaveBeenCalledWith(null)
})
it('shows the sentinel as the trigger label when value is null', async () => {
serveTwoProviders()
render(
<ModelSelector
variant="field"
value={null}
onChange={() => {}}
defaultOption={{ label: 'Same as assistant' }}
/>,
)
expect(screen.getByRole('button')).toHaveTextContent('Same as assistant')
})
it('providerFilter restricts the list to that provider', async () => {
serveTwoProviders()
render(
<ModelSelector
variant="field"
value={null}
onChange={() => {}}
providerFilter="anthropic"
defaultOption={{ label: 'Same as assistant' }}
/>,
)
await openMenu()
await screen.findByText('claude-opus-4-8')
expect(screen.queryByText('gpt-5.4')).toBeNull()
})
it('inheritDefault renders its label muted in the trigger when value is null', () => {
serveTwoProviders()
render(
<ModelSelector
variant="field"
value={null}
onChange={() => {}}
inheritDefault={{ label: '(global default)' }}
/>,
)
const label = screen.getByText('(global default)')
expect(label.className).toContain('text-muted-foreground')
})
it('un-scoped allowCustom splits "provider/model" on the first slash', async () => {
serveTwoProviders()
const onChange = vi.fn()
render(
<ModelSelector
variant="field"
value={null}
onChange={onChange}
inheritDefault={{ label: '(global default)' }}
allowCustom
/>,
)
await openMenu()
fireEvent.change(screen.getByPlaceholderText('Search models…'), {
target: { value: 'openrouter/meituan/longcat-2.0' },
})
fireEvent.click(await screen.findByText('Use "openrouter/meituan/longcat-2.0"'))
expect(onChange).toHaveBeenCalledWith({ provider: 'openrouter', model: 'meituan/longcat-2.0' })
})
it('un-scoped allowCustom pairs slash-less text with the default provider', async () => {
serveTwoProviders()
const onChange = vi.fn()
render(
<ModelSelector
variant="field"
value={null}
onChange={onChange}
inheritDefault={{ label: '(global default)' }}
allowCustom
/>,
)
await openMenu()
// Wait for the store snapshot (the default provider comes from it).
await screen.findByText('claude-opus-4-8')
fireEvent.change(screen.getByPlaceholderText('Search models…'), { target: { value: 'my-local-model' } })
fireEvent.click(await screen.findByText('Use "my-local-model"'))
expect(onChange).toHaveBeenCalledWith({ provider: 'openai', model: 'my-local-model' })
})
it('staticOptions renders only the supplied rows and round-trips ids and null', async () => {
serveTwoProviders()
const onChange = vi.fn()
render(
<ModelSelector
variant="field"
value={null}
onChange={onChange}
defaultOption={{ label: 'Default (recommended)' }}
staticOptions={[
{ id: 'opus', label: 'Opus' },
{ id: 'claude-opus-4-8', label: 'Opus' },
{ id: 'sonnet', label: 'Sonnet' },
]}
/>,
)
await openMenu()
// Only the caller's rows — nothing from the shared catalog store.
expect(screen.queryByText('gpt-5.4')).toBeNull()
expect(screen.queryByText('claude-opus-4-8', { selector: '[role="menuitemradio"] span' })).not.toBeNull()
// Colliding "Opus" labels are disambiguated by their raw id.
expect(screen.getAllByText('Opus')).toHaveLength(2)
fireEvent.click(screen.getByText('Sonnet'))
expect(onChange).toHaveBeenCalledWith({ provider: '', model: 'sonnet' })
await openMenu()
fireEvent.click(screen.getByText('Default (recommended)', { selector: '[role="menuitemradio"] span' }))
expect(onChange).toHaveBeenCalledWith(null)
})
it('allowCustom offers the typed id when nothing matches', async () => {
serveTwoProviders()
const onChange = vi.fn()
render(
<ModelSelector
variant="field"
value={null}
onChange={onChange}
providerFilter="anthropic"
allowCustom
defaultOption={{ label: 'Same as assistant' }}
/>,
)
await openMenu()
fireEvent.change(screen.getByPlaceholderText('Search models…'), { target: { value: 'my-custom-model' } })
const custom = await screen.findByText('Use "my-custom-model"')
fireEvent.click(custom)
expect(onChange).toHaveBeenCalledWith({ provider: 'anthropic', model: 'my-custom-model' })
})
})

View file

@ -0,0 +1,549 @@
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Brain, ChevronDown, LoaderIcon } from 'lucide-react'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useModels, type ModelPickerGroup, type ModelRef } from '@/hooks/use-models'
import { useProviderModels } from '@/hooks/use-provider-models'
import { cn } from '@/lib/utils'
export type { ModelRef } from '@/hooks/use-models'
export type ReasoningEffortLevel = 'low' | 'medium' | 'high'
const TOOLTIP_DELAY_MS = 1000
const providerDisplayNames: Record<string, string> = {
openai: 'OpenAI',
anthropic: 'Anthropic',
google: 'Gemini',
ollama: 'Ollama',
openrouter: 'OpenRouter',
aigateway: 'AI Gateway',
'openai-compatible': 'OpenAI-Compatible',
rowboat: 'Rowboat',
// Matches what other subscription clients call this provider; the auth
// itself is "Sign in with ChatGPT" (Plus/Pro subscription).
codex: 'OpenAI Codex',
}
// '' = auto (provider default). Ordered as shown in the picker.
const REASONING_EFFORT_OPTIONS: Array<{ value: '' | ReasoningEffortLevel; label: string; hint: string }> = [
{ value: '', label: 'Auto', hint: 'Provider default' },
{ value: 'low', label: 'Fast', hint: 'Minimal thinking' },
{ value: 'medium', label: 'Balanced', hint: 'Moderate thinking' },
{ value: 'high', label: 'Thorough', hint: 'Deep thinking, costs more' },
]
function getModelDisplayName(model: string) {
return model.split('/').pop() || model
}
// Rendered inside the dropdown's radio group: each live provider fetches its
// own list, so groups load and fail independently. Pinned models (the saved
// default / app default) render first — the model that actually runs is
// always pickable even while the fetch is pending or failed. Live-fetched
// ids carry no reasoning metadata, so the effort control stays hidden for
// them (reasoningByKey lookup misses default to off).
//
// The group owns its header so it can hide itself when the search filter
// matches none of its rows. Loading/error rows are status, not models — they
// render (with the header) regardless of the filter, and don't count toward
// the parent's "No models match" check (which is what gets reported up).
function LiveProviderGroupItems({ group, label, pinnedModels, filter, onModelRowsChange }: {
group: Extract<ModelPickerGroup, { kind: 'live' }>
label: string
pinnedModels: string[]
filter: string
onModelRowsChange: (flavor: string, hasModelRows: boolean) => void
}) {
const { status, models, error, refetch } = useProviderModels({
flavor: group.flavor,
apiKey: group.apiKey,
baseURL: group.baseURL,
})
const items = [...pinnedModels, ...models.filter((m) => !pinnedModels.includes(m))]
const visible = filter ? items.filter((m) => m.toLowerCase().includes(filter)) : items
const showStatus = status === 'loading' || status === 'error'
const hasModelRows = visible.length > 0
useEffect(() => {
onModelRowsChange(group.flavor, hasModelRows)
}, [group.flavor, hasModelRows, onModelRowsChange])
if (!hasModelRows && !showStatus) return null
return (
<>
<DropdownMenuLabel className="text-xs text-muted-foreground">{label}</DropdownMenuLabel>
{visible.map((m) => {
const key = `${group.flavor}/${m}`
return (
<DropdownMenuRadioItem key={key} value={key}>
<span className="truncate">{m}</span>
</DropdownMenuRadioItem>
)
})}
{status === 'loading' && (
<div className="flex items-center gap-2 px-2 py-1.5 text-xs text-muted-foreground">
<LoaderIcon className="h-3 w-3 animate-spin" />
Loading models
</div>
)}
{status === 'error' && (
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault()
refetch()
}}
className="text-xs"
>
<span className="truncate text-destructive">{error || 'Failed to load models'}</span>
<span className="ml-auto shrink-0 text-muted-foreground">Retry</span>
</DropdownMenuItem>
)}
</>
)
}
// The standardized model picker (model-selection consolidation), mounted
// everywhere models are chosen. One controlled value/onChange contract with
// per-surface modes layered on as optional props: the composer's full
// catalog picker (provider groups, live fetches, search, reasoning effort),
// settings' default sentinel / field trigger / provider scoping / typed-id
// escape hatch, per-task "(global default)" inheritance, and caller-supplied
// restricted lists (coding-agent options).
export interface ModelSelectorProps {
/** Current selection; null follows the app default / the sentinel. */
value: ModelRef | null
/** null only ever fires when defaultOption is set (sentinel picked). */
onChange: (value: ModelRef | null) => void
/**
* Pinned top entry ("Rowboat default", "Same as assistant") that selects
* null. When set, a null value renders this label instead of the app
* default model.
*/
defaultOption?: { label: string }
/**
* Inheritance flavor of defaultOption for per-task overrides
* ("(global default)"): same sentinel row and null semantics, but null
* means "inherit the global default at runtime" and the trigger renders
* the label muted, so an un-overridden field reads like a placeholder.
* Mutually exclusive with defaultOption (defaultOption wins).
*/
inheritDefault?: { label: string }
/**
* 'pill' is the composer's compact rounded trigger; 'field' is a
* full-width bordered Select-style trigger for forms.
*/
variant?: 'pill' | 'field'
/**
* Restrict the picker to one provider's group (catalog or live). Providers
* not configured in models.json fall back to their static models:list
* catalog so a provider mid-setup still lists models.
*/
providerFilter?: string
/**
* When the search text matches no rows, offer a `Use "<text>"` row that
* selects the typed id arbitrary ids for ollama / openai-compatible.
* With providerFilter the typed id attaches to that provider. Without it,
* "provider/model" splits on the FIRST slash (so an OpenRouter id must be
* typed provider-qualified: "openrouter/meituan/longcat-2.0"); text with
* no slash attaches to the global default's provider.
*/
allowCustom?: boolean
/**
* Caller-supplied restricted list (e.g. a coding agent's own model
* options): the picker renders ONLY these rows plus the defaultOption
* sentinel no catalog groups, no live fetches. Entries are opaque
* engine ids, not provider/model pairs, so the selected ref is
* {provider: '', model: id} the id travels in .model and provider is
* meaningless. Search filters on label and id; rows whose label differs
* from their id show the id as secondary text (labels can collide, e.g.
* Claude lists both the 'opus' alias and the concrete id as "Opus").
*/
staticOptions?: Array<{ id: string; label?: string }>
/** Optional title attribute for the trigger button (header tooltips). */
triggerTitle?: string
/** Frozen selection: renders a static label + tooltip instead of the dropdown. */
lockedModel?: ModelRef | null
/**
* Reasoning effort ('' = auto). The control renders only for models the
* catalog flags as reasoning-capable. When the effective model loses
* reasoning support, '' is reported up so a stale effort never outlives
* its model. Omit onEffortChange to hide the effort control entirely.
*/
effort?: '' | ReasoningEffortLevel
onEffortChange?: (effort: '' | ReasoningEffortLevel) => void
}
// Radio value for the defaultOption sentinel row. Never a valid model key
// (real keys always contain "provider/").
const DEFAULT_OPTION_KEY = '__default__'
// Un-scoped custom entries can't know their provider, so the rule is:
// scoped → the scoped provider; "provider/model" → split on the FIRST
// slash; no slash → the global default's provider (matching how the
// runtime pairs a provider-less model override).
function parseCustomModel(text: string, providerFilter: string | undefined, defaultModel: ModelRef | null): ModelRef {
if (providerFilter) return { provider: providerFilter, model: text }
const slash = text.indexOf('/')
if (slash > 0 && slash < text.length - 1) {
return { provider: text.slice(0, slash), model: text.slice(slash + 1) }
}
return { provider: defaultModel?.provider ?? '', model: text }
}
// Adapters for surfaces that persist a per-item override as two optional
// strings (BackgroundTask.model/provider, LiveNote.model/provider) where
// unset = inherit the global default. A model without a provider is legal
// (the runtime pairs it with the default provider), so '' round-trips to
// undefined and a null ref clears both fields.
export function modelOverrideToRef(model: string | undefined, provider: string | undefined): ModelRef | null {
return model ? { provider: provider ?? '', model } : null
}
export function refToModelOverride(ref: ModelRef | null): { model: string | undefined; provider: string | undefined } {
return { model: ref?.model || undefined, provider: ref?.provider || undefined }
}
export function ModelSelector({
value,
onChange,
defaultOption,
inheritDefault,
variant = 'pill',
providerFilter,
allowCustom = false,
staticOptions,
triggerTitle,
lockedModel = null,
effort = '',
onEffortChange,
}: ModelSelectorProps) {
const { groups: allGroups, reasoningByKey, defaultModel, catalogByProvider } = useModels()
// inheritDefault is defaultOption with placeholder styling — one sentinel
// code path, not two.
const sentinel = defaultOption ?? inheritDefault
const sentinelMuted = !defaultOption && Boolean(inheritDefault)
const groups = useMemo<ModelPickerGroup[]>(() => {
if (!providerFilter) return allGroups
const scoped = allGroups.filter((g) => g.flavor === providerFilter)
if (scoped.length > 0) return scoped
const catalogModels = catalogByProvider[providerFilter] || []
return catalogModels.length > 0 ? [{ kind: 'catalog', flavor: providerFilter, models: catalogModels }] : []
}, [allGroups, providerFilter, catalogByProvider])
// Search filter for the model dropdown. Reset each time the menu opens;
// matching is a case-insensitive substring test on the model id. Live
// groups filter themselves and report whether they still have rows, so the
// parent can render the global "No models match" row.
const [modelFilter, setModelFilter] = useState('')
const modelFilterInputRef = useRef<HTMLInputElement>(null)
const [liveGroupHasRows, setLiveGroupHasRows] = useState<Record<string, boolean>>({})
const modelFilterValue = modelFilter.trim().toLowerCase()
const handleLiveGroupRows = useCallback((flavor: string, hasRows: boolean) => {
setLiveGroupHasRows((prev) => (prev[flavor] === hasRows ? prev : { ...prev, [flavor]: hasRows }))
}, [])
// The effective default always renders even when no group carries it (the
// gateway list failed, or its provider was removed from config) — the
// picker must never be missing the model that actually runs. Live groups
// pin the default themselves, so a flavor match is enough there. A
// provider-scoped picker only shows it when it belongs to that provider.
const standaloneDefault = useMemo<ModelRef | null>(() => {
if (!defaultModel) return null
if (providerFilter && defaultModel.provider !== providerFilter) return null
const covered = groups.some((g) =>
g.flavor === defaultModel.provider &&
(g.kind === 'live' || g.models.includes(defaultModel.model)))
return covered ? null : defaultModel
}, [groups, defaultModel, providerFilter])
const standaloneVisible = standaloneDefault !== null &&
(!modelFilterValue || standaloneDefault.model.toLowerCase().includes(modelFilterValue))
// Static mode replaces all store-driven rows with the caller's list.
const staticVisible = useMemo(() => {
if (!staticOptions) return null
if (!modelFilterValue) return staticOptions
return staticOptions.filter((o) =>
(o.label ?? o.id).toLowerCase().includes(modelFilterValue) || o.id.toLowerCase().includes(modelFilterValue))
}, [staticOptions, modelFilterValue])
const staticLabelFor = (id: string) => staticOptions?.find((o) => o.id === id)?.label ?? id
// Nothing matches anywhere → "No models match". Live groups that haven't
// reported yet (first render after opening) count as having rows so the
// empty row never flashes.
const anyModelRowVisible = staticVisible
? staticVisible.length > 0
: standaloneVisible || groups.some((g) =>
g.kind === 'catalog'
? g.models.some((m) => m.toLowerCase().includes(modelFilterValue))
: liveGroupHasRows[g.flavor] !== false)
const handleModelChange = useCallback((key: string) => {
if (lockedModel) return
if (key === DEFAULT_OPTION_KEY) {
onChange(null)
return
}
// Static keys are opaque engine ids — no provider/model split (ids may
// themselves contain slashes).
if (staticOptions) {
onChange({ provider: '', model: key })
return
}
const slash = key.indexOf('/')
if (slash <= 0 || slash === key.length - 1) return
onChange({ provider: key.slice(0, slash), model: key.slice(slash + 1) })
}, [lockedModel, onChange, staticOptions])
// Reasoning effort applies to the model the next message will actually use:
// the frozen model when locked, else the picker selection, else the app
// default. Only known-reasoning models show the control.
const effectiveModelKey = lockedModel
? `${lockedModel.provider}/${lockedModel.model}`
: (value ? `${value.provider}/${value.model}` : '')
|| (defaultModel ? `${defaultModel.provider}/${defaultModel.model}` : '')
const reasoningAvailable = reasoningByKey[effectiveModelKey] === true
const handleEffortChange = useCallback((raw: string) => {
onEffortChange?.(raw === 'low' || raw === 'medium' || raw === 'high' ? raw : '')
}, [onEffortChange])
// Switching to a model without reasoning support drops a stale selection —
// otherwise the next message would carry an effort the model rejects.
useEffect(() => {
if (!reasoningAvailable && effort !== '') {
onEffortChange?.('')
}
}, [reasoningAvailable, effort, onEffortChange])
return (
<>
{reasoningAvailable && onEffortChange && (
<DropdownMenu>
<Tooltip delayDuration={TOOLTIP_DELAY_MS}>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex h-7 shrink-0 items-center gap-1 rounded-full px-2 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<Brain className="h-3 w-3 shrink-0" />
{effort !== '' && (
<span>{REASONING_EFFORT_OPTIONS.find((o) => o.value === effort)?.label}</span>
)}
<ChevronDown className="h-3 w-3 shrink-0" />
</button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="top">Reasoning effort applies to your next message</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end">
<DropdownMenuRadioGroup value={effort} onValueChange={handleEffortChange}>
{REASONING_EFFORT_OPTIONS.map((option) => (
<DropdownMenuRadioItem key={option.value || 'auto'} value={option.value}>
<span>{option.label}</span>
<span className="ml-2 text-xs text-muted-foreground">{option.hint}</span>
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)}
{lockedModel ? (
<Tooltip delayDuration={TOOLTIP_DELAY_MS}>
<TooltipTrigger asChild>
<span className="flex h-7 min-w-0 items-center gap-1 rounded-full px-2 text-xs text-muted-foreground">
<span className="min-w-0 truncate">{getModelDisplayName(lockedModel.model)}</span>
</span>
</TooltipTrigger>
<TooltipContent side="top">
{providerDisplayNames[lockedModel.provider] || lockedModel.provider} fixed for this chat
</TooltipContent>
</Tooltip>
) : (
<DropdownMenu
onOpenChange={(open) => {
// The filter is per-opening, never sticky. Focus the search
// input once the content has mounted and Radix has run its own
// open-focus (DropdownMenu.Content has no onOpenAutoFocus).
if (open) {
setModelFilter('')
setLiveGroupHasRows({})
setTimeout(() => modelFilterInputRef.current?.focus(), 0)
}
}}
>
<DropdownMenuTrigger asChild>
{variant === 'field' ? (
// Styled after ui/select's SelectTrigger so it sits naturally
// in forms next to real Select fields.
<button
type="button"
title={triggerTitle}
className="flex h-9 w-full items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 dark:bg-input/30 dark:hover:bg-input/50"
>
<span className="flex min-w-0 items-center gap-2">
<span className={cn('truncate', !value && sentinelMuted && 'text-muted-foreground')}>
{value
? (staticOptions ? staticLabelFor(value.model) : value.model)
: (sentinel?.label || defaultModel?.model || 'Select a model')}
</span>
{value && !providerFilter && !staticOptions && (
<span className="shrink-0 text-xs text-muted-foreground">
{providerDisplayNames[value.provider] || value.provider}
</span>
)}
</span>
<ChevronDown className="size-4 shrink-0 opacity-50" />
</button>
) : (
<button
type="button"
title={triggerTitle}
className="flex h-7 min-w-0 items-center gap-1 rounded-full px-2 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<span className="min-w-0 truncate">
{staticOptions
? (value ? staticLabelFor(value.model) : (sentinel?.label ?? 'Model'))
: getModelDisplayName(value?.model || defaultModel?.model || 'Model')}
</span>
<ChevronDown className="h-3 w-3 shrink-0" />
</button>
)}
</DropdownMenuTrigger>
<DropdownMenuContent
align={variant === 'field' ? 'start' : 'end'}
className={cn('p-0 overflow-hidden', variant === 'field' && 'min-w-[var(--radix-dropdown-menu-trigger-width)]')}
>
{!staticOptions && groups.length === 0 && !standaloneDefault && !sentinel && !allowCustom ? (
<div className="p-1">
<DropdownMenuItem disabled>Connect a provider in Settings</DropdownMenuItem>
</div>
) : (
<>
{/* Fixed search header lives OUTSIDE the scroll area (the
inner div below scrolls), so it's flush at the very top
and always visible without any scroll. */}
<div className="bg-popover p-1">
<input
ref={modelFilterInputRef}
value={modelFilter}
onChange={(e) => setModelFilter(e.target.value)}
onKeyDown={(e) => {
// Printable keys belong to the input, not the menu's
// typeahead; arrows and Escape stay with the menu.
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp' && e.key !== 'Escape') {
e.stopPropagation()
}
}}
placeholder="Search models…"
className="h-7 w-full rounded-sm border border-input bg-transparent px-2 text-xs outline-none placeholder:text-muted-foreground"
/>
</div>
<div className="max-h-80 overflow-y-auto p-1 pt-0">
<DropdownMenuRadioGroup
value={
value
? (staticOptions ? value.model : `${value.provider}/${value.model}`)
: sentinel
? DEFAULT_OPTION_KEY
: (defaultModel ? `${defaultModel.provider}/${defaultModel.model}` : '')
}
onValueChange={handleModelChange}
>
{sentinel && (
<DropdownMenuRadioItem value={DEFAULT_OPTION_KEY}>
<span className="truncate">{sentinel.label}</span>
</DropdownMenuRadioItem>
)}
{staticVisible?.map((o) => (
<DropdownMenuRadioItem key={o.id} value={o.id}>
<span className="truncate">{o.label ?? o.id}</span>
{o.label && o.label !== o.id && (
<span className="ml-2 shrink-0 text-xs text-muted-foreground">{o.id}</span>
)}
</DropdownMenuRadioItem>
))}
{!staticOptions && standaloneDefault && standaloneVisible && (
<DropdownMenuRadioItem value={`${standaloneDefault.provider}/${standaloneDefault.model}`}>
<span className="truncate">{standaloneDefault.model}</span>
<span className="ml-2 text-xs text-muted-foreground">
{providerDisplayNames[standaloneDefault.provider] || standaloneDefault.provider}
</span>
</DropdownMenuRadioItem>
)}
{!staticOptions && groups.map((g) => {
const label = providerDisplayNames[g.flavor] || g.flavor
if (g.kind === 'live') {
// The app default leads its live group; the group's
// own saved model follows (both stay pickable through
// fetch loading/failure).
const pinned: string[] = []
if (defaultModel && defaultModel.provider === g.flavor) pinned.push(defaultModel.model)
if (g.savedModel && !pinned.includes(g.savedModel)) pinned.push(g.savedModel)
return (
<LiveProviderGroupItems
key={g.flavor}
group={g}
label={label}
pinnedModels={pinned}
filter={modelFilterValue}
onModelRowsChange={handleLiveGroupRows}
/>
)
}
const visibleModels = modelFilterValue
? g.models.filter((m) => m.toLowerCase().includes(modelFilterValue))
: g.models
if (visibleModels.length === 0) return null
return (
<Fragment key={g.flavor}>
<DropdownMenuLabel className="text-xs text-muted-foreground">
{label}
</DropdownMenuLabel>
{visibleModels.map((m) => {
const key = `${g.flavor}/${m}`
return (
<DropdownMenuRadioItem key={key} value={key}>
<span className="truncate">{m}</span>
</DropdownMenuRadioItem>
)
})}
</Fragment>
)
})}
{modelFilterValue && !anyModelRowVisible && (
allowCustom ? (
// Escape hatch for ids the lists don't carry (local
// servers, brand-new models): select exactly what was
// typed. Scoped pickers attach it to their provider;
// un-scoped ones split "provider/model" on the first
// slash, else pair the text with the default provider.
<DropdownMenuItem
onSelect={() => onChange(parseCustomModel(modelFilter.trim(), providerFilter, defaultModel))}
>
<span className="truncate">Use &quot;{modelFilter.trim()}&quot;</span>
</DropdownMenuItem>
) : (
<div className="px-2 py-1.5 text-xs text-muted-foreground">No models match</div>
)
)}
</DropdownMenuRadioGroup>
</div>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</>
)
}

File diff suppressed because it is too large Load diff

View file

@ -34,6 +34,7 @@ import type { ipc as ipcShared } from "@x/shared"
import { startProvisioning, useProvisioning, enabledOptimistic, type AgentStatus, type CodeModeAgentStatus } from "@/lib/code-mode-provisioning"
import { useProviderModels } from "@/hooks/use-provider-models"
import { useChatGPT } from "@/hooks/useChatGPT"
import { ModelSelector, type ModelRef } from "@/components/model-selector"
type ConfigTab = "account" | "connections" | "mobile" | "models" | "mcp" | "security" | "code-mode" | "appearance" | "notifications" | "note-tagging" | "advanced" | "help"
@ -485,12 +486,6 @@ function AppearanceSettings() {
type LlmProviderFlavor = "openai" | "anthropic" | "google" | "openrouter" | "aigateway" | "ollama" | "openai-compatible"
interface LlmModelOption {
id: string
name?: string
release_date?: string
}
const primaryProviders: Array<{ id: LlmProviderFlavor; name: string; description: string; icon: React.ElementType }> = [
{ id: "openai", name: "OpenAI", description: "GPT models", icon: OpenAIIcon },
{ id: "anthropic", name: "Anthropic", description: "Claude models", icon: AnthropicIcon },
@ -540,8 +535,6 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
ollama: { apiKey: "", baseURL: "http://localhost:11434", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
})
const [modelsCatalog, setModelsCatalog] = useState<Record<string, LlmModelOption[]>>({})
const [modelsLoading, setModelsLoading] = useState(false)
const [testState, setTestState] = useState<{ status: "idle" | "testing" | "success" | "error"; error?: string }>({ status: "idle" })
const [configLoading, setConfigLoading] = useState(true)
const [showMoreProviders, setShowMoreProviders] = useState(false)
@ -570,8 +563,6 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
const showBaseURL = provider === "ollama" || provider === "openai-compatible" || provider === "aigateway"
const requiresBaseURL = provider === "ollama" || provider === "openai-compatible"
const isLocalProvider = provider === "ollama" || provider === "openai-compatible"
const modelsForProvider = modelsCatalog[provider] || []
const showModelInput = isLocalProvider || modelsForProvider.length === 0
const isMoreProvider = moreProviders.some(p => p.id === provider)
const primaryModel = activeConfig.models[0] || ""
@ -711,29 +702,6 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
}
}, [])
// Load models catalog
useEffect(() => {
if (!dialogOpen) return
async function loadModels() {
try {
setModelsLoading(true)
const result = await window.ipc.invoke("models:list", null)
const catalog: Record<string, LlmModelOption[]> = {}
for (const p of result.providers || []) {
catalog[p.id] = p.models || []
}
setModelsCatalog(catalog)
} catch {
setModelsCatalog({})
} finally {
setModelsLoading(false)
}
}
loadModels()
}, [dialogOpen])
// A saved openai-compatible model that the server's list doesn't confirm
// (not listed, or /models unreachable) belongs in the visible Model field,
// where it stays editable and wins over the silent pick.
@ -1232,144 +1200,33 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
</div>
)}
{/* Per-function model overrides */}
{/* Per-function model overrides. Persisted as bare model-id strings
inside providers[flavor] ('' = "Same as assistant"), so the
ModelRef picker value is adapted at this boundary: string
{provider, model} with the active card's flavor. allowCustom keeps
arbitrary ids typeable (local servers, unlisted models). */}
<div className="grid grid-cols-2 gap-3">
{!rowboatConnected && (<>
{/* Knowledge graph model (right column) */}
<div className="space-y-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Knowledge graph model</span>
{modelsLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading...
{(
[
{ label: "Knowledge graph model", field: "knowledgeGraphModel" },
{ label: "Meeting notes model", field: "meetingNotesModel" },
{ label: "Track block model", field: "liveNoteAgentModel" },
{ label: "Auto-permission model", field: "autoPermissionDecisionModel" },
] as const
).map(({ label, field }) => (
<div key={field} className="space-y-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">{label}</span>
<ModelSelector
variant="field"
providerFilter={provider}
allowCustom
defaultOption={{ label: "Same as assistant" }}
value={activeConfig[field] ? { provider, model: activeConfig[field] } : null}
onChange={(ref) => updateConfig(provider, { [field]: ref ? ref.model : "" })}
/>
</div>
) : showModelInput ? (
<Input
value={activeConfig.knowledgeGraphModel}
onChange={(e) => updateConfig(provider, { knowledgeGraphModel: e.target.value })}
placeholder={primaryModel || "Enter model"}
/>
) : (
<Select
value={activeConfig.knowledgeGraphModel || "__same__"}
onValueChange={(value) => updateConfig(provider, { knowledgeGraphModel: value === "__same__" ? "" : value })}
>
<SelectTrigger>
<SelectValue placeholder="Select a model" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__same__">Same as assistant</SelectItem>
{modelsForProvider.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.name || m.id}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{/* Meeting notes model */}
<div className="space-y-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Meeting notes model</span>
{modelsLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading...
</div>
) : showModelInput ? (
<Input
value={activeConfig.meetingNotesModel}
onChange={(e) => updateConfig(provider, { meetingNotesModel: e.target.value })}
placeholder={primaryModel || "Enter model"}
/>
) : (
<Select
value={activeConfig.meetingNotesModel || "__same__"}
onValueChange={(value) => updateConfig(provider, { meetingNotesModel: value === "__same__" ? "" : value })}
>
<SelectTrigger>
<SelectValue placeholder="Select a model" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__same__">Same as assistant</SelectItem>
{modelsForProvider.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.name || m.id}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{/* Track block model */}
<div className="space-y-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Track block model</span>
{modelsLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading...
</div>
) : showModelInput ? (
<Input
value={activeConfig.liveNoteAgentModel}
onChange={(e) => updateConfig(provider, { liveNoteAgentModel: e.target.value })}
placeholder={primaryModel || "Enter model"}
/>
) : (
<Select
value={activeConfig.liveNoteAgentModel || "__same__"}
onValueChange={(value) => updateConfig(provider, { liveNoteAgentModel: value === "__same__" ? "" : value })}
>
<SelectTrigger>
<SelectValue placeholder="Select a model" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__same__">Same as assistant</SelectItem>
{modelsForProvider.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.name || m.id}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{/* Auto-permission model */}
<div className="space-y-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Auto-permission model</span>
{modelsLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading...
</div>
) : showModelInput ? (
<Input
value={activeConfig.autoPermissionDecisionModel}
onChange={(e) => updateConfig(provider, { autoPermissionDecisionModel: e.target.value })}
placeholder={primaryModel || "Enter model"}
/>
) : (
<Select
value={activeConfig.autoPermissionDecisionModel || "__same__"}
onValueChange={(value) => updateConfig(provider, { autoPermissionDecisionModel: value === "__same__" ? "" : value })}
>
<SelectTrigger>
<SelectValue placeholder="Select a model" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__same__">Same as assistant</SelectItem>
{modelsForProvider.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.name || m.id}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
))}
</>)}
</div>
@ -1734,44 +1591,19 @@ function ToolsLibrarySettings({ dialogOpen, rowboatConnected }: { dialogOpen: bo
// --- Rowboat Model Settings (when signed in via Rowboat) ---
//
// Hybrid mode: every dropdown lists the gateway catalog PLUS any models from
// BYOK providers configured below. Values are provider-qualified
// ("provider::model") and saved via models:updateConfig as {provider, model}
// refs, so a signed-in user can e.g. keep the gateway assistant while
// running background agents on a local Ollama model.
interface HybridModelOption {
provider: string
model: string
label: string
}
const providerDisplayNames: Record<string, string> = {
openai: 'OpenAI',
anthropic: 'Anthropic',
google: 'Gemini',
ollama: 'Ollama',
openrouter: 'OpenRouter',
aigateway: 'AI Gateway',
'openai-compatible': 'OpenAI-Compatible',
rowboat: 'Rowboat',
}
const HYBRID_SEP = "::"
const hybridKey = (provider: string, model: string) => `${provider}${HYBRID_SEP}${model}`
function parseHybridKey(key: string): { provider: string; model: string } | null {
const index = key.indexOf(HYBRID_SEP)
if (index <= 0) return null
return { provider: key.slice(0, index), model: key.slice(index + HYBRID_SEP.length) }
}
// Hybrid mode: every picker lists the gateway catalog PLUS any BYOK
// providers configured below (ModelSelector renders the shared store's
// groups, live-fetching list-less providers inside the open dropdown).
// Saved via models:updateConfig as {provider, model} refs, so a signed-in
// user can e.g. keep the gateway assistant while running background agents
// on a local Ollama model. Selections stay local until Save — unlike the
// composer, picking here must not write anything by itself.
function RowboatModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
const [options, setOptions] = useState<HybridModelOption[]>([])
const [selectedDefault, setSelectedDefault] = useState("")
const [selectedKg, setSelectedKg] = useState("")
const [selectedLiveNote, setSelectedLiveNote] = useState("")
const [selectedAutoPermission, setSelectedAutoPermission] = useState("")
const [selectedDefault, setSelectedDefault] = useState<ModelRef | null>(null)
const [selectedKg, setSelectedKg] = useState<ModelRef | null>(null)
const [selectedLiveNote, setSelectedLiveNote] = useState<ModelRef | null>(null)
const [selectedAutoPermission, setSelectedAutoPermission] = useState<ModelRef | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
@ -1781,63 +1613,29 @@ function RowboatModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
async function load() {
setLoading(true)
try {
const collected: HybridModelOption[] = []
const seen = new Set<string>()
const push = (provider: string, model: string, label?: string) => {
if (!model) return
const key = hybridKey(provider, model)
if (seen.has(key)) return
seen.add(key)
collected.push({ provider, model, label: label || model })
}
const catalog: Record<string, LlmModelOption[]> = {}
try {
const listResult = await window.ipc.invoke("models:list", null)
for (const p of listResult.providers || []) {
catalog[p.id] = p.models || []
}
} catch { /* offline — BYOK entries below still load */ }
for (const m of catalog["rowboat"] || []) push("rowboat", m.id, m.name || m.id)
let parsed: Record<string, unknown> = {}
try {
const configResult = await window.ipc.invoke("workspace:readFile", { path: "config/models.json" })
parsed = JSON.parse(configResult.data)
} catch { /* no BYOK config yet */ }
const providersMap = (parsed.providers ?? {}) as Record<string, Record<string, unknown>>
for (const [flavor, entry] of Object.entries(providersMap)) {
const hasKey = typeof entry.apiKey === "string" && (entry.apiKey as string).trim().length > 0
const hasBaseURL = typeof entry.baseURL === "string" && (entry.baseURL as string).trim().length > 0
if (!hasKey && !hasBaseURL) continue
push(flavor, typeof entry.model === "string" ? entry.model : "")
const catalogModels = catalog[flavor] || []
if (catalogModels.length > 0) {
for (const m of catalogModels) push(flavor, m.id, m.name || m.id)
} else {
for (const m of Array.isArray(entry.models) ? entry.models as string[] : []) push(flavor, m)
}
}
setOptions(collected)
} catch { /* no config yet */ }
// Current selections. Legacy string overrides pair with the BYOK
// top-level flavor (mirrors core/models/defaults.ts).
const legacyFlavor = (parsed.provider as Record<string, unknown> | undefined)?.flavor
const toKey = (value: unknown): string => {
if (!value) return ""
const toRef = (value: unknown): ModelRef | null => {
if (!value) return null
if (typeof value === "string") {
return typeof legacyFlavor === "string" ? hybridKey(legacyFlavor, value) : ""
return typeof legacyFlavor === "string" ? { provider: legacyFlavor, model: value } : null
}
const ref = value as { provider?: unknown; model?: unknown }
return typeof ref.provider === "string" && typeof ref.model === "string"
? hybridKey(ref.provider, ref.model)
: ""
? { provider: ref.provider, model: ref.model }
: null
}
setSelectedDefault(toKey(parsed.defaultSelection))
setSelectedKg(toKey(parsed.knowledgeGraphModel))
setSelectedLiveNote(toKey(parsed.liveNoteAgentModel))
setSelectedAutoPermission(toKey(parsed.autoPermissionDecisionModel))
setSelectedDefault(toRef(parsed.defaultSelection))
setSelectedKg(toRef(parsed.knowledgeGraphModel))
setSelectedLiveNote(toRef(parsed.liveNoteAgentModel))
setSelectedAutoPermission(toRef(parsed.autoPermissionDecisionModel))
} catch {
toast.error("Failed to load models")
} finally {
@ -1851,12 +1649,11 @@ function RowboatModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
const handleSave = useCallback(async () => {
setSaving(true)
try {
const toRef = (key: string) => (key ? parseHybridKey(key) : null)
await window.ipc.invoke("models:updateConfig", {
defaultSelection: toRef(selectedDefault),
knowledgeGraphModel: toRef(selectedKg),
liveNoteAgentModel: toRef(selectedLiveNote),
autoPermissionDecisionModel: toRef(selectedAutoPermission),
defaultSelection: selectedDefault,
knowledgeGraphModel: selectedKg,
liveNoteAgentModel: selectedLiveNote,
autoPermissionDecisionModel: selectedAutoPermission,
})
window.dispatchEvent(new Event("models-config-changed"))
toast.success("Model configuration saved")
@ -1867,33 +1664,19 @@ function RowboatModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
}
}, [selectedDefault, selectedKg, selectedLiveNote, selectedAutoPermission])
const renderSelect = (
const renderModelField = (
label: string,
value: string,
onChange: (v: string) => void,
defaultLabel: string,
value: ModelRef | null,
onChange: (v: ModelRef | null) => void,
) => (
<div className="space-y-2">
<label className="text-sm font-medium">{label}</label>
<Select value={value || "__default__"} onValueChange={(v) => onChange(v === "__default__" ? "" : v)}>
<SelectTrigger className="w-full">
<SelectValue placeholder={defaultLabel} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__default__">{defaultLabel}</SelectItem>
{options.map((o) => {
const key = hybridKey(o.provider, o.model)
return (
<SelectItem key={key} value={key}>
{o.label}
<span className="ml-2 text-xs text-muted-foreground">
{providerDisplayNames[o.provider] || o.provider}
</span>
</SelectItem>
)
})}
</SelectContent>
</Select>
<ModelSelector
variant="field"
value={value}
onChange={onChange}
defaultOption={{ label: "Rowboat default" }}
/>
</div>
)
@ -1911,10 +1694,10 @@ function RowboatModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
Select the models Rowboat uses. Rowboat models are provided through your account; models from your own providers route through your keys or local runtimes.
</p>
{renderSelect("Assistant model", selectedDefault, setSelectedDefault, "Rowboat default")}
{renderSelect("Knowledge graph model", selectedKg, setSelectedKg, "Rowboat default")}
{renderSelect("Background agents model", selectedLiveNote, setSelectedLiveNote, "Rowboat default")}
{renderSelect("Permission checks model", selectedAutoPermission, setSelectedAutoPermission, "Rowboat default")}
{renderModelField("Assistant model", selectedDefault, setSelectedDefault)}
{renderModelField("Knowledge graph model", selectedKg, setSelectedKg)}
{renderModelField("Background agents model", selectedLiveNote, setSelectedLiveNote)}
{renderModelField("Permission checks model", selectedAutoPermission, setSelectedAutoPermission)}
{/* Save */}
<Button onClick={handleSave} disabled={saving}>

View file

@ -0,0 +1,100 @@
import { act, renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it } from 'vitest'
import { __resetModelsForTests, useModels } from './use-models'
// The hook wires a module-level store to window.ipc, so the tests stub the
// preload surface: `invoke` routes by channel through a per-test handler map
// and counts calls per channel to observe the store's fetch dedupe.
let handlers: Record<string, (args: unknown) => Promise<unknown>> = {}
let invokeCounts: Record<string, number> = {}
;(window as unknown as { ipc: unknown }).ipc = {
on: () => () => undefined,
invoke: (channel: string, args: unknown) => {
invokeCounts[channel] = (invokeCounts[channel] ?? 0) + 1
const handler = handlers[channel]
return handler ? handler(args) : Promise.reject(new Error(`no handler: ${channel}`))
},
}
function serveConfig(providers: Record<string, unknown>): void {
handlers['oauth:getState'] = async () => ({ config: { rowboat: { connected: false } } })
handlers['llm:getDefaultModel'] = async () => ({ provider: 'openai', model: 'gpt-5.4' })
handlers['models:list'] = async () => ({
providers: [
{ id: 'openai', name: 'OpenAI', models: [{ id: 'gpt-5.4', reasoning: true }, { id: 'gpt-5.4-mini' }] },
],
})
handlers['workspace:readFile'] = async () => ({
data: JSON.stringify({ provider: { flavor: 'openai' }, model: 'gpt-5.4', providers }),
})
}
beforeEach(() => {
__resetModelsForTests()
handlers = {}
invokeCounts = {}
})
describe('useModels', () => {
it('shares one fetch across concurrently mounted consumers', async () => {
serveConfig({ openai: { apiKey: 'sk-test', model: 'gpt-5.4' } })
const first = renderHook(() => useModels())
const second = renderHook(() => useModels())
await waitFor(() => expect(first.result.current.groups.length).toBeGreaterThan(0))
await waitFor(() => expect(second.result.current.groups.length).toBeGreaterThan(0))
expect(invokeCounts['models:list']).toBe(1)
expect(invokeCounts['workspace:readFile']).toBe(1)
expect(first.result.current.groups).toEqual([
{ kind: 'catalog', flavor: 'openai', models: ['gpt-5.4', 'gpt-5.4-mini'] },
])
expect(first.result.current.reasoningByKey).toEqual({ 'openai/gpt-5.4': true })
expect(first.result.current.defaultModel).toEqual({ provider: 'openai', model: 'gpt-5.4' })
// Raw catalog is exposed for provider-scoped pickers (unconfigured
// providers have no group but may have a catalog).
expect(first.result.current.catalogByProvider).toEqual({ openai: ['gpt-5.4', 'gpt-5.4-mini'] })
// Both consumers see the same store snapshot, not copies.
expect(second.result.current.groups).toBe(first.result.current.groups)
})
it('serves the cache to late mounts without refetching', async () => {
serveConfig({ openai: { apiKey: 'sk-test', model: 'gpt-5.4' } })
const first = renderHook(() => useModels())
await waitFor(() => expect(first.result.current.groups.length).toBeGreaterThan(0))
const late = renderHook(() => useModels())
// Loaded synchronously from the module cache — no loading flash, no IPC.
expect(late.result.current.groups.length).toBeGreaterThan(0)
expect(invokeCounts['models:list']).toBe(1)
})
it('refetches on models-config-changed and updates every consumer', async () => {
serveConfig({ openai: { apiKey: 'sk-test', model: 'gpt-5.4' } })
const { result } = renderHook(() => useModels())
await waitFor(() => expect(result.current.groups.length).toBe(1))
serveConfig({
openai: { apiKey: 'sk-test', model: 'gpt-5.4' },
ollama: { baseURL: 'http://localhost:11434' },
})
// The settings Save path: models:updateConfig lands first, then the
// event fires — the refetch must see the new default (this is what
// moves a fresh composer tab's trigger label without a restart).
handlers['llm:getDefaultModel'] = async () => ({ provider: 'ollama', model: 'llama3' })
act(() => {
window.dispatchEvent(new Event('models-config-changed'))
})
await waitFor(() => expect(result.current.groups.length).toBe(2))
expect(result.current.groups[1]).toEqual({
kind: 'live', flavor: 'ollama', apiKey: '', baseURL: 'http://localhost:11434', savedModel: '',
})
expect(result.current.defaultModel).toEqual({ provider: 'ollama', model: 'llama3' })
expect(invokeCounts['models:list']).toBe(2)
})
})

View file

@ -0,0 +1,247 @@
import { useMemo, useSyncExternalStore } from 'react'
import type { ProviderModelsFlavor } from './use-provider-models'
export interface ModelRef {
provider: string
model: string
}
// One picker group per connected provider. Catalog groups carry a resolved
// model list (models:list / saved config); live groups carry credentials and
// fetch their list from the provider inside the dropdown via
// useProviderModels (models:listForProvider).
export type ModelPickerGroup =
| { kind: 'catalog'; flavor: string; models: string[] }
| { kind: 'live'; flavor: ProviderModelsFlavor; apiKey: string; baseURL: string; savedModel: string }
const LIVE_PICKER_FLAVORS = new Set<string>(['openrouter', 'aigateway', 'ollama', 'openai-compatible'])
// Catalog-preferred flavors that degrade to a live fetch when models:list has
// no catalog for them (signed-in mode returns only the rowboat provider, or
// the models.dev cache is empty).
const LIVE_FALLBACK_FLAVORS = new Set<string>(['openai', 'anthropic', 'google'])
export interface ModelsSnapshot {
groups: ModelPickerGroup[]
// Per-model reasoning capability ("provider/model" → flag) from models:list.
// Live-fetched ids carry no reasoning metadata, so lookups miss → treated
// as non-reasoning.
reasoningByKey: Record<string, boolean>
// The effective runtime default (what a run actually uses when the user
// hasn't picked a model) — shown in pickers instead of guessing from list
// order, which can disagree with the real default.
defaultModel: ModelRef | null
isRowboatConnected: boolean
// Raw models:list catalog per provider id. Groups only cover providers
// configured in models.json; provider-scoped pickers fall back to this so
// a provider mid-setup (key typed, not saved) still lists its catalog.
catalogByProvider: Record<string, string[]>
}
export interface UseModelsResult extends ModelsSnapshot {
/** Force a refetch now (e.g. a composer tab becoming active). */
refresh: () => void
}
const EMPTY_SNAPSHOT: ModelsSnapshot = {
groups: [],
reasoningByKey: {},
defaultModel: null,
isRowboatConnected: false,
catalogByProvider: {},
}
// Module-level store: every mounted consumer shares one snapshot and one
// in-flight fetch, so N pickers on screen never fan out into N identical
// IPC round-trips.
let snapshot: ModelsSnapshot = EMPTY_SNAPSHOT
let loaded = false
let fetching = false
let fetchSeq = 0
let wired = false
let wiredCleanups: Array<() => void> = []
const subscribers = new Set<() => void>()
// Hybrid mode: signed-in users get the gateway list AND every BYOK provider
// configured in models.json (selecting a BYOK model routes that message
// through the user's own key / local server). Signed-out users get BYOK only.
async function buildSnapshot(): Promise<ModelsSnapshot> {
let isRowboatConnected = false
try {
const state = await window.ipc.invoke('oauth:getState', null)
isRowboatConnected = state.config?.rowboat?.connected ?? false
} catch { /* treat as signed out */ }
let defaultModel: ModelRef | null = null
try {
const def = await window.ipc.invoke('llm:getDefaultModel', null)
defaultModel = { provider: def.provider, model: def.model }
} catch { /* no default resolvable */ }
const groups: ModelPickerGroup[] = []
const reasoningByKey: Record<string, boolean> = {}
// Full catalog per provider (gateway + models.dev cloud providers).
const catalog: Record<string, string[]> = {}
try {
const listResult = await window.ipc.invoke('models:list', null)
for (const p of listResult.providers || []) {
catalog[p.id] = (p.models || []).map((m: { id: string }) => m.id)
for (const m of p.models || []) {
if (typeof m.reasoning === 'boolean') {
reasoningByKey[`${p.id}/${m.id}`] = m.reasoning
}
}
}
} catch { /* offline / no catalog — groups fall back to saved config below */ }
if (isRowboatConnected && (catalog['rowboat'] || []).length > 0) {
groups.push({ kind: 'catalog', flavor: 'rowboat', models: catalog['rowboat'] })
}
// ChatGPT subscription (codex): models:list only carries this catalog
// while signed in with ChatGPT, so presence is the gate.
if ((catalog['codex'] || []).length > 0) {
groups.push({ kind: 'catalog', flavor: 'codex', models: catalog['codex'] })
}
try {
const result = await window.ipc.invoke('workspace:readFile', { path: 'config/models.json' })
const parsed = JSON.parse(result.data)
// List the default provider's group first.
const defaultFlavor = typeof parsed?.provider?.flavor === 'string' ? parsed.provider.flavor : ''
const flavors = Object.keys(parsed?.providers || {})
.sort((a, b) => (a === defaultFlavor ? -1 : b === defaultFlavor ? 1 : 0))
for (const flavor of flavors) {
const e = (parsed.providers[flavor] || {}) as Record<string, unknown>
const apiKey = typeof e.apiKey === 'string' ? e.apiKey.trim() : ''
const baseURL = typeof e.baseURL === 'string' ? e.baseURL.trim() : ''
if (!apiKey && !baseURL) continue // provider not configured
const savedModel = typeof e.model === 'string' ? e.model : ''
// Live flavors fetch their list from the provider inside the
// dropdown, with the credentials saved in config. Catalog flavors
// degrade to the same live fetch when models:list carried no
// catalog for them (signed in, or empty models.dev cache).
const catalogModels = catalog[flavor] || []
if (LIVE_PICKER_FLAVORS.has(flavor) || (catalogModels.length === 0 && LIVE_FALLBACK_FLAVORS.has(flavor))) {
groups.push({ kind: 'live', flavor: flavor as ProviderModelsFlavor, apiKey, baseURL, savedModel })
continue
}
// Catalog group: the saved default model leads, then the catalog.
// Saved models[] survives as the fallback for unknown flavors the
// live fetch doesn't support.
const models: string[] = []
const push = (model: string) => {
if (model && !models.includes(model)) models.push(model)
}
push(savedModel)
if (catalogModels.length > 0) {
for (const m of catalogModels) push(m)
} else {
const saved = Array.isArray(e.models) ? e.models as string[] : []
for (const m of saved) push(m)
}
groups.push({ kind: 'catalog', flavor, models })
}
// The user's explicit default selection leads the picker: its group
// first and, within a catalog group, the model itself first. (Live
// groups pin the default at the top themselves.)
const sel = parsed?.defaultSelection
if (sel && typeof sel.provider === 'string' && typeof sel.model === 'string') {
const index = groups.findIndex((g) => g.flavor === sel.provider)
if (index >= 0) {
const [group] = groups.splice(index, 1)
groups.unshift(group)
if (group.kind === 'catalog') {
const mi = group.models.indexOf(sel.model)
if (mi > 0) {
group.models.splice(mi, 1)
group.models.unshift(sel.model)
}
}
}
}
} catch { /* no BYOK config yet */ }
return { groups, reasoningByKey, defaultModel, isRowboatConnected, catalogByProvider: catalog }
}
function startFetch(): void {
// Concurrent fetches race (an event can fire while one is in flight) —
// only the newest run may write the snapshot, else a slow stale run can
// clobber the fresh list.
const seq = ++fetchSeq
fetching = true
void buildSnapshot()
.then((next) => {
if (seq !== fetchSeq) return
snapshot = next
loaded = true
for (const notify of subscribers) notify()
})
.catch((err) => {
// No config yet — but surface unexpected failures for diagnosis.
console.error('[use-models] failed to load model list', err)
})
.finally(() => {
if (seq === fetchSeq) fetching = false
})
}
function refreshModels(): void {
startFetch()
}
function ensureLoaded(): void {
if (!loaded && !fetching) startFetch()
}
function wireGlobalEvents(): void {
if (wired) return
wired = true
// Config edits anywhere in the app (settings dialog, composer pick,
// onboarding) announce themselves on this window event.
window.addEventListener('models-config-changed', refreshModels)
wiredCleanups = [
() => window.removeEventListener('models-config-changed', refreshModels),
// Rowboat sign-in swaps the whole hybrid list.
window.ipc.on('oauth:didConnect', refreshModels),
// ChatGPT subscription models appear/disappear with the ChatGPT session.
window.ipc.on('chatgpt:statusChanged', refreshModels),
]
}
function subscribe(onStoreChange: () => void): () => void {
wireGlobalEvents()
subscribers.add(onStoreChange)
ensureLoaded()
return () => {
subscribers.delete(onStoreChange)
}
}
function getSnapshot(): ModelsSnapshot {
return snapshot
}
export function useModels(): UseModelsResult {
const data = useSyncExternalStore(subscribe, getSnapshot)
return useMemo(() => ({ ...data, refresh: refreshModels }), [data])
}
// Test-only: drop the shared cache and event wiring so each test starts from
// a cold store (the seq bump also invalidates any in-flight fetch).
export function __resetModelsForTests(): void {
snapshot = EMPTY_SNAPSHOT
loaded = false
fetching = false
fetchSeq++
subscribers.clear()
for (const cleanup of wiredCleanups) cleanup()
wiredCleanups = []
wired = false
}

View file

@ -113,7 +113,7 @@ export function useConnectors(active: boolean) {
// but those handlers are no longer reachable in the UI (the gating
// condition `useComposioForGoogle` stays false).
// TODO follow-up: drop these flags entirely and prune the dead UI branches
// in connectors-popover, connected-accounts-settings, and onboarding-modal.
// in connectors-popover and connected-accounts-settings.
const [useComposioForGoogle] = useState(false)
const [gmailConnected, setGmailConnected] = useState(false)
const [gmailLoading, setGmailLoading] = useState(false)