feat(x): one model-selection experience — unified settings, provider lifecycle, split-view picker

Task slots (config + runtime):
- taskModels gains backgroundTask and subagent; getBackgroundTaskAgentModel
  resolves its own override instead of mirroring live-notes (migration
  materializes the old mirror rule so effective models are unchanged)
- spawn-agent precedence: explicit per-spawn args > configured subagent
  override > parent turn's model
- repo.setProvider rejects auth-derived flavors (rowboat/codex) and merges
  over existing entries so key rotation keeps contextLength/reasoningEffort

Models settings, rebuilt:
- ModelSelectionSection: ONE Assistant model picker (with an Unavailable
  badge when the saved model drops off its provider's live list) + all
  seven tasks defaulting to "Same as Assistant" with resolve subtext and a
  clear-override link. Replaces both the signed-in and BYOK screens; the
  "Auto (recommended)" sentinel and both hardcoded preferredDefaults maps
  are gone — connect-time resolution uses backend recommendations
- ProvidersSection replaces the old ModelSettings card grid: status cards
  (Connected · N models / error + Retry), an add-provider dialog
  (choose → creds → loading → first-vs-more result states → error with
  manual-model fallback), and a manage dialog (masked key + Replace,
  endpoint, Refresh models, Used-by list, disconnect with consequences
  spelled out). Rowboat and ChatGPT are cards in the same list
- connecting a provider seeds the assistant ONLY when none is configured —
  never replaces a saved choice
- models:getConfig IPC serves selections + credential-free provider
  metadata; the probe machinery (use-provider-models, liveCredentials)
  is deleted — the add flow validates credentials click-driven

Onboarding:
- screen 2 is the same ProvidersSection (onboarding variant) for BOTH
  paths: "Add more providers" after sign-in, "Connect a model provider"
  otherwise; Continue gates on an assistant model existing. The tile grid,
  per-provider key forms, and ~350 lines of duplicate state are deleted;
  one step sequence replaces the two path-dependent indicators

Picker:
- split-view browse (Popover + cmdk Command): providers left with counts
  and error dots, selected provider's models right, ←/→ switches provider,
  ↑/↓ navigates, typing collapses to a flat cross-provider list that
  matches provider NAMES as well as model ids (searching "rowboat" now
  works). Scoped/static pickers stay flat; public API unchanged

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Ramnique Singh 2026-07-24 16:16:21 +05:30
parent f39daa9f5d
commit c695ddf1c1
24 changed files with 1599 additions and 1917 deletions

View file

@ -1271,6 +1271,30 @@ export function setupIpcHandlers() {
console.log(`[llm:generate] -> provider=${result.provider ?? '?'} model=${result.model ?? '?'} chars=${result.text?.length ?? 0}${result.error ? ` error=${result.error}` : ''}`);
return result;
},
'models:getConfig': async () => {
const repo = container.resolve<IModelConfigRepo>('modelConfigRepo');
const cfg = await repo.getConfig().catch(() => null);
const tasks = cfg?.taskModels ?? {};
return {
providers: Object.entries(cfg?.providers ?? {}).map(([id, entry]) => ({
id,
flavor: entry.flavor,
...(entry.baseURL ? { baseURL: entry.baseURL } : {}),
hasApiKey: Boolean(entry.apiKey),
})),
assistantModel: cfg?.assistantModel ?? null,
taskModels: {
knowledgeGraph: tasks.knowledgeGraph ?? null,
meetingNotes: tasks.meetingNotes ?? null,
liveNoteAgent: tasks.liveNoteAgent ?? null,
autoPermissionDecision: tasks.autoPermissionDecision ?? null,
chatTitle: tasks.chatTitle ?? null,
backgroundTask: tasks.backgroundTask ?? null,
subagent: tasks.subagent ?? null,
},
deferBackgroundTasks: cfg?.deferBackgroundTasks === true,
};
},
'models:setProvider': async (_event, args) => {
const repo = container.resolve<IModelConfigRepo>('modelConfigRepo');
await repo.setProvider(args.id, args.provider);

View file

@ -549,12 +549,11 @@ function ChatInputInner({
checkSearch()
}, [isActive, isRowboatConnected])
// 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-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.
// Selecting a model here is PER-CHAT: it affects the next run created
// from this tab (frozen once a run exists) and nothing else. The config's
// assistantModel is the durable default — new tabs and background work
// always start from it, and only the settings Assistant picker (or a
// provider connect's initial selection) writes it.
const handleModelChange = useCallback((model: SelectedModel | null) => {
if (lockedModel) return
// null = the sentinel row, which the composer never renders (no
@ -562,9 +561,6 @@ function ChatInputInner({
if (!model) return
setSelectedModel(model)
onSelectedModelChange?.(model)
void window.ipc.invoke('models:updateConfig', { assistantModel: { provider: model.provider, model: model.model } })
.then(() => { window.dispatchEvent(new Event('models-config-changed')) })
.catch(() => { toast.error('Failed to save default model') })
}, [lockedModel, onSelectedModelChange])
// Effort is per-turn and unpersisted; ModelSelector reports '' when the

View file

@ -34,11 +34,8 @@ function serveTwoProviders(): void {
}
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())
fireEvent.click(screen.getByRole('button'))
await waitFor(() => expect(document.querySelector('[cmdk-root]')).not.toBeNull())
}
beforeEach(() => {
@ -124,7 +121,7 @@ describe('ModelSelector', () => {
/>,
)
await openMenu()
fireEvent.change(screen.getByPlaceholderText('Search models…'), {
fireEvent.change(screen.getByPlaceholderText('Search models and providers…'), {
target: { value: 'openrouter/meituan/longcat-2.0' },
})
fireEvent.click(await screen.findByText('Use "openrouter/meituan/longcat-2.0"'))
@ -144,39 +141,68 @@ describe('ModelSelector', () => {
/>,
)
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' } })
// Wait for the store snapshot (split mode opens on the default
// provider's group, so its model is what renders first).
await screen.findByText('gpt-5.4')
fireEvent.change(screen.getByPlaceholderText('Search models and providers…'), { target: { value: 'my-local-model' } })
fireEvent.click(await screen.findByText('Use "my-local-model"'))
expect(onChange).toHaveBeenCalledWith({ provider: 'openai', model: 'my-local-model' })
})
it('liveCredentials live-fetches a provider that is not saved anywhere', async () => {
it('split view: ArrowRight switches provider and Enter selects from the new group', async () => {
serveTwoProviders()
// openrouter is absent from models.json AND the static catalog — only
// the form's typed credentials can produce its list.
handlers['models:listForProvider'] = async () => ({
success: true,
models: ['meituan/longcat-2.0', 'qwen/qwen-3'],
})
const onChange = vi.fn()
render(<ModelSelector variant="field" value={null} onChange={onChange} defaultOption={{ label: 'x' }} />)
await openMenu()
await screen.findByText('gpt-5.4')
// Regression: swapping the provider column used to orphan cmdk's
// internal highlight, making Enter a no-op until ↑/↓ was pressed.
const input = screen.getByPlaceholderText('Search models and providers…')
fireEvent.keyDown(input, { key: 'ArrowRight' })
await screen.findByText('claude-opus-4-8')
fireEvent.keyDown(input, { key: 'Enter' })
expect(onChange).toHaveBeenCalledWith({ provider: 'anthropic', model: 'claude-opus-4-8' })
})
it('search matches provider names and shows that provider\'s whole group', async () => {
serveTwoProviders()
render(
<ModelSelector
variant="field"
value={null}
onChange={onChange}
providerFilter="openrouter"
liveCredentials={{ flavor: 'openrouter', apiKey: 'sk-or-typed', baseURL: '' }}
allowCustom
defaultOption={{ label: 'Auto (recommended)' }}
/>,
<ModelSelector variant="field" value={null} onChange={() => {}} defaultOption={{ label: 'x' }} />,
)
await openMenu()
// 600ms debounce in useProviderModels before the fetch fires.
const row = await screen.findByText('meituan/longcat-2.0', undefined, { timeout: 3000 })
await screen.findByText('gpt-5.4')
// "anthropic" matches no model id — but it matches the provider, so its
// group stays visible in full while others filter away.
fireEvent.change(screen.getByPlaceholderText('Search models and providers…'), { target: { value: 'anthropic' } })
await screen.findByText('claude-opus-4-8')
expect(screen.queryByText('gpt-5.4')).toBeNull()
fireEvent.click(row)
expect(onChange).toHaveBeenCalledWith({ provider: 'openrouter', model: 'meituan/longcat-2.0' })
expect(screen.queryByText('No models match')).toBeNull()
})
it('renders a failed provider group as an error row with Retry (refreshing that provider)', async () => {
handlers['models:list'] = async (args) => {
const refreshed = (args as { refreshProvider?: string } | null)?.refreshProvider === 'ollama'
return {
providers: [
{ id: 'openai', flavor: 'openai', status: 'ok', models: [{ id: 'gpt-5.4' }] },
refreshed
? { id: 'ollama', flavor: 'ollama', status: 'ok', models: [{ id: 'llama3' }] }
: { id: 'ollama', flavor: 'ollama', status: 'error', error: 'connection refused', models: [] },
],
defaultModel: { provider: 'openai', model: 'gpt-5.4' },
}
}
render(<ModelSelector variant="field" value={null} onChange={() => {}} defaultOption={{ label: 'x' }} />)
await openMenu()
// Split mode opens on the default provider (openai); the failed
// provider shows an error dot in the column — select it to see the row.
fireEvent.click(await screen.findByRole('tab', { name: /Ollama/ }))
const retry = await screen.findByText('Retry')
expect(screen.getByText('connection refused')).toBeInTheDocument()
fireEvent.click(retry)
// The retried fetch recovers the group's models.
await screen.findByText('llama3')
})
it('staticOptions renders only the supplied rows and round-trips ids and null', async () => {
@ -198,7 +224,7 @@ describe('ModelSelector', () => {
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()
expect(screen.queryByText('claude-opus-4-8', { selector: '[role="option"] span' })).not.toBeNull()
// Colliding "Opus" labels are disambiguated by their raw id.
expect(screen.getAllByText('Opus')).toHaveLength(2)
@ -206,7 +232,7 @@ describe('ModelSelector', () => {
expect(onChange).toHaveBeenCalledWith({ provider: '', model: 'sonnet' })
await openMenu()
fireEvent.click(screen.getByText('Default (recommended)', { selector: '[role="menuitemradio"] span' }))
fireEvent.click(screen.getByText('Default (recommended)', { selector: '[role="option"] span' }))
expect(onChange).toHaveBeenCalledWith(null)
})
@ -224,7 +250,7 @@ describe('ModelSelector', () => {
/>,
)
await openMenu()
fireEvent.change(screen.getByPlaceholderText('Search models…'), { target: { value: 'my-custom-model' } })
fireEvent.change(screen.getByPlaceholderText('Search models and providers…'), { 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

@ -1,18 +1,23 @@
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Brain, ChevronDown, LoaderIcon } from 'lucide-react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Brain, Check, ChevronDown } 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 { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import {
Command,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import { useModels, type ModelPickerGroup, type ModelRef } from '@/hooks/use-models'
import { useProviderModels, type ProviderModelsFlavor } from '@/hooks/use-provider-models'
import { cn } from '@/lib/utils'
export type { ModelRef } from '@/hooks/use-models'
@ -21,7 +26,7 @@ export type ReasoningEffortLevel = 'low' | 'medium' | 'high'
const TOOLTIP_DELAY_MS = 1000
const providerDisplayNames: Record<string, string> = {
export const providerDisplayNames: Record<string, string> = {
openai: 'OpenAI',
anthropic: 'Anthropic',
google: 'Gemini',
@ -47,94 +52,34 @@ function getModelDisplayName(model: string) {
return model.split('/').pop() || model
}
// The one remaining renderer-side fetch: probing a provider that is being
// configured RIGHT NOW (credentials typed into a form but not yet saved).
// Connected providers all come pre-listed through the unified catalog
// (useModels); this path exists only because unsaved credentials, by
// definition, aren't in that catalog. Pinned models (the app default) render
// first so the model that actually runs stays pickable while the fetch is
// pending or failed. Probe-fetched ids carry no reasoning metadata, so the
// effort control stays hidden for them.
//
// 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 ProbeProviderGroupItems({ flavor, apiKey, baseURL, label, pinnedModels, filter, onModelRowsChange }: {
flavor: ProviderModelsFlavor
apiKey: string
baseURL: string
label: string
pinnedModels: string[]
filter: string
onModelRowsChange: (flavor: string, hasModelRows: boolean) => void
}) {
const { status, models, error, refetch } = useProviderModels({ flavor, apiKey, 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(flavor, hasModelRows)
}, [flavor, hasModelRows, onModelRowsChange])
if (!hasModelRows && !showStatus) return null
return (
<>
<DropdownMenuLabel className="text-xs text-muted-foreground">{label}</DropdownMenuLabel>
{visible.map((m) => {
const key = `${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).
// everywhere models are chosen — the composer pill, every settings field,
// per-task overrides, and the coding-agent restricted lists. One controlled
// value/onChange contract with per-surface modes layered on as optional
// props.
//
// The dropdown is a Popover + cmdk Command. With the search empty and more
// than one provider connected, it browses as a SPLIT VIEW: providers on the
// left (the assistant's provider pre-selected), the chosen provider's models
// on the right — ←/→ switches provider, ↑/↓ navigates models. Typing
// collapses to one flat list filtered across ALL providers (model ids and
// provider names both match). Scoped/static pickers stay flat.
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.
* Pinned top entry ("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).
* Inheritance flavor of defaultOption for per-task overrides: same
* sentinel row and null semantics, but null means "inherit 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 }
/**
@ -143,22 +88,9 @@ export interface ModelSelectorProps {
*/
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.
* Restrict the picker to one connected provider's group.
*/
providerFilter?: string
/**
* Live-fetch credentials for a provider being configured right now (typed
* but possibly unsaved). Store groups only exist for providers saved in
* models.json and the catalog fallback only covers openai/anthropic/
* google this synthesizes the scoped live group from the form's current
* inputs instead (and wins over a saved group, whose stored key may be
* stale). Requires providerFilter set to the same flavor; ignored until
* some credential is typed. useProviderModels debounces + caches, so
* keystrokes don't spray fetches.
*/
liveCredentials?: { flavor: ProviderModelsFlavor; apiKey: string; baseURL: string }
/**
* When the search text matches no rows, offer a `Use "<text>"` row that
* selects the typed id arbitrary ids for ollama / openai-compatible.
@ -171,12 +103,11 @@ export interface ModelSelectorProps {
/**
* 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").
* sentinel no catalog groups. Entries are opaque engine ids, not
* provider/model pairs, so the selected ref is {provider: '', model: id}.
* 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). */
@ -193,8 +124,8 @@ export interface ModelSelectorProps {
onEffortChange?: (effort: '' | ReasoningEffortLevel) => void
}
// Radio value for the defaultOption sentinel row. Never a valid model key
// (real keys always contain "provider/").
// cmdk item 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:
@ -230,7 +161,6 @@ export function ModelSelector({
inheritDefault,
variant = 'pill',
providerFilter,
liveCredentials,
allowCustom = false,
staticOptions,
triggerTitle,
@ -245,15 +175,7 @@ export function ModelSelector({
const sentinel = defaultOption ?? inheritDefault
const sentinelMuted = !defaultOption && Boolean(inheritDefault)
// Probe mode: the form's typed credentials trump anything saved — an
// unsaved provider isn't in the catalog, so its list is probe-fetched.
const liveFlavor = liveCredentials?.flavor
const liveApiKey = liveCredentials?.apiKey.trim() ?? ''
const liveBaseURL = liveCredentials?.baseURL.trim() ?? ''
const probeActive = Boolean(providerFilter && liveFlavor === providerFilter && (liveApiKey || liveBaseURL))
const groups = useMemo<ModelPickerGroup[]>(() => {
if (probeActive) return []
if (!providerFilter) return allGroups
const scoped = allGroups.filter((g) => g.id === providerFilter)
if (scoped.length > 0) return scoped
@ -261,68 +183,97 @@ export function ModelSelector({
return catalogModels.length > 0
? [{ id: providerFilter, flavor: providerFilter, models: catalogModels, status: 'ok' }]
: []
}, [allGroups, providerFilter, catalogByProvider, probeActive])
}, [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. The probe
// group filters itself and reports whether it still has rows, so the
// parent can render the global "No models match" row.
const [modelFilter, setModelFilter] = useState('')
const modelFilterInputRef = useRef<HTMLInputElement>(null)
const [probeHasRows, setProbeHasRows] = useState(true)
const modelFilterValue = modelFilter.trim().toLowerCase()
const handleProbeRows = useCallback((_flavor: string, hasRows: boolean) => {
setProbeHasRows(hasRows)
}, [])
const [open, setOpen] = useState(false)
// cmdk's highlighted-item value, controlled: when the split view swaps the
// provider column, the previous group's items unmount and cmdk's internal
// highlight is left pointing at a value with no item — ↵ becomes a no-op.
// Driving the value ourselves re-anchors the highlight on the new group.
const [commandValue, setCommandValue] = useState('')
// Search text; case-insensitive substring test on the model id AND the
// provider name — typing "rowboat" surfaces the whole Rowboat group.
const [query, setQuery] = useState('')
const queryValue = query.trim().toLowerCase()
const groupMatchesFilter = useCallback((g: ModelPickerGroup) =>
(providerDisplayNames[g.flavor] || g.flavor).toLowerCase().includes(queryValue)
|| g.id.toLowerCase().includes(queryValue), [queryValue])
// Split view only where browsing across providers is meaningful.
const splitMode = !staticOptions && !providerFilter && !queryValue && groups.length > 1
const [activeProviderId, setActiveProviderId] = useState<string | null>(null)
const activeGroup = splitMode
? (groups.find((g) => g.id === activeProviderId) ?? groups[0])
: null
const handleOpenChange = useCallback((next: boolean) => {
setOpen(next)
if (next) {
// Per-opening state: fresh search, provider column on the selection's
// (else the assistant's) provider — groups[0] is already the
// assistant's group by store ordering. Empty commandValue lets cmdk
// highlight the first rendered item itself.
setQuery('')
setCommandValue('')
setActiveProviderId(value?.provider ?? defaultModel?.provider ?? null)
}
}, [value, defaultModel])
// Switch the split view's provider column and re-anchor the keyboard
// highlight on the new group's first row (see commandValue above).
const switchProvider = useCallback((g: ModelPickerGroup) => {
setActiveProviderId(g.id)
setCommandValue(
g.models.length > 0
? `${g.id}/${g.models[0]}`
: g.status === 'error'
? `__retry__:${g.id}`
: sentinel ? DEFAULT_OPTION_KEY : '',
)
}, [sentinel])
// The effective default always renders even when no group carries it (the
// provider's list failed, or its provider was removed from config) — the
// picker must never be missing the model that actually runs. The probe
// group pins the default itself, so it opts out here. A provider-scoped
// picker only shows it when it belongs to that provider.
// picker must never be missing the model that actually runs. A
// provider-scoped picker only shows it when it belongs to that provider.
const standaloneDefault = useMemo<ModelRef | null>(() => {
if (!defaultModel || probeActive) return null
if (!defaultModel) return null
if (providerFilter && defaultModel.provider !== providerFilter) return null
const covered = groups.some((g) =>
g.id === defaultModel.provider && g.models.includes(defaultModel.model))
return covered ? null : defaultModel
}, [groups, defaultModel, providerFilter, probeActive])
}, [groups, defaultModel, providerFilter])
const standaloneVisible = standaloneDefault !== null &&
(!modelFilterValue || standaloneDefault.model.toLowerCase().includes(modelFilterValue))
(!queryValue || standaloneDefault.model.toLowerCase().includes(queryValue))
// Static mode replaces all store-driven rows with the caller's list.
const staticVisible = useMemo(() => {
if (!staticOptions) return null
if (!modelFilterValue) return staticOptions
if (!queryValue) return staticOptions
return staticOptions.filter((o) =>
(o.label ?? o.id).toLowerCase().includes(modelFilterValue) || o.id.toLowerCase().includes(modelFilterValue))
}, [staticOptions, modelFilterValue])
(o.label ?? o.id).toLowerCase().includes(queryValue) || o.id.toLowerCase().includes(queryValue))
}, [staticOptions, queryValue])
const staticLabelFor = (id: string) => staticOptions?.find((o) => o.id === id)?.label ?? id
// Nothing matches anywhere → "No models match". A probe that hasn't
// reported yet (first render after opening) counts as having rows so the
// empty row never flashes.
// Nothing matches anywhere → "No models match".
const anyModelRowVisible = staticVisible
? staticVisible.length > 0
: standaloneVisible
|| groups.some((g) => g.models.some((m) => m.toLowerCase().includes(modelFilterValue)))
|| (probeActive && probeHasRows)
|| groups.some((g) =>
groupMatchesFilter(g) ? g.models.length > 0
: g.models.some((m) => m.toLowerCase().includes(queryValue)))
const handleModelChange = useCallback((key: string) => {
// The cmdk value of the current selection, for check indicators.
const selectedKey = value
? (staticOptions ? value.model : `${value.provider}/${value.model}`)
: sentinel
? DEFAULT_OPTION_KEY
: (defaultModel ? `${defaultModel.provider}/${defaultModel.model}` : '')
const select = useCallback((ref: ModelRef | null) => {
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])
setOpen(false)
onChange(ref)
}, [lockedModel, onChange])
// Reasoning effort applies to the model the next message will actually use:
// the frozen model when locked, else the picker selection, else the app
@ -345,6 +296,42 @@ export function ModelSelector({
}
}, [reasoningAvailable, effort, onEffortChange])
const renderModelItem = (providerId: string, model: string, secondary?: string) => {
const key = `${providerId}/${model}`
return (
<CommandItem
key={key}
value={key}
onSelect={() => select({ provider: providerId, model })}
>
<Check className={cn('size-3.5 shrink-0', selectedKey === key ? 'opacity-100' : 'opacity-0')} />
<span className="truncate">{model}</span>
{secondary && <span className="ml-auto shrink-0 text-xs text-muted-foreground">{secondary}</span>}
</CommandItem>
)
}
const renderSentinelItem = () => sentinel && (
<CommandItem value={DEFAULT_OPTION_KEY} onSelect={() => select(null)}>
<Check className={cn('size-3.5 shrink-0', selectedKey === DEFAULT_OPTION_KEY ? 'opacity-100' : 'opacity-0')} />
<span className="truncate">{sentinel.label}</span>
</CommandItem>
)
const renderErrorItem = (g: ModelPickerGroup) => (
<CommandItem
key={`__retry__:${g.id}`}
value={`__retry__:${g.id}`}
// Retry refreshes in place — the popover stays open and the group
// re-renders when the store updates.
onSelect={() => refresh(g.id)}
className="text-xs"
>
<span className="truncate text-destructive">{g.error || 'Failed to load models'}</span>
<span className="ml-auto shrink-0 text-muted-foreground">Retry</span>
</CommandItem>
)
return (
<>
{reasoningAvailable && onEffortChange && (
@ -390,19 +377,14 @@ export function ModelSelector({
</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('')
setProbeHasRows(true)
setTimeout(() => modelFilterInputRef.current?.focus(), 0)
}
}}
>
<DropdownMenuTrigger asChild>
// modal: the settings Dialog's scroll-lock cancels wheel events over
// content portalled outside its subtree — a modal popover brings its
// own lock layer that permits scrolling within (Radix's supported
// fix for popover-inside-dialog; matches the old DropdownMenu's
// modality). Keyboard scrolling was never affected (cmdk uses
// programmatic scrollIntoView).
<Popover open={open} onOpenChange={handleOpenChange} modal>
<PopoverTrigger asChild>
{variant === 'field' ? (
// Styled after ui/select's SelectTrigger so it sits naturally
// in forms next to real Select fields.
@ -439,139 +421,154 @@ export function ModelSelector({
<ChevronDown className="h-3 w-3 shrink-0" />
</button>
)}
</DropdownMenuTrigger>
<DropdownMenuContent
</PopoverTrigger>
<PopoverContent
align={variant === 'field' ? 'start' : 'end'}
className={cn('p-0 overflow-hidden', variant === 'field' && 'min-w-[var(--radix-dropdown-menu-trigger-width)]')}
>
{!staticOptions && !probeActive && 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
const visibleModels = modelFilterValue
? g.models.filter((m) => m.toLowerCase().includes(modelFilterValue))
: g.models
// Error rows are status, not models: they render (with
// the header) regardless of the filter and don't count
// toward "No models match".
const showError = g.status === 'error'
if (visibleModels.length === 0 && !showError) return null
return (
<Fragment key={g.id}>
<DropdownMenuLabel className="text-xs text-muted-foreground">
{label}
</DropdownMenuLabel>
{visibleModels.map((m) => {
const key = `${g.id}/${m}`
return (
<DropdownMenuRadioItem key={key} value={key}>
<span className="truncate">{m}</span>
</DropdownMenuRadioItem>
)
})}
{showError && (
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault()
refresh(g.id)
}}
className="text-xs"
>
<span className="truncate text-destructive">{g.error || 'Failed to load models'}</span>
<span className="ml-auto shrink-0 text-muted-foreground">Retry</span>
</DropdownMenuItem>
)}
</Fragment>
)
})}
{probeActive && liveFlavor && (
<ProbeProviderGroupItems
flavor={liveFlavor}
apiKey={liveApiKey}
baseURL={liveBaseURL}
label={providerDisplayNames[liveFlavor] || liveFlavor}
pinnedModels={defaultModel && defaultModel.provider === liveFlavor ? [defaultModel.model] : []}
filter={modelFilterValue}
onModelRowsChange={handleProbeRows}
/>
)}
{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>
</>
className={cn(
'p-0 overflow-hidden',
splitMode
? 'w-[480px]'
: variant === 'field'
? 'w-[var(--radix-popover-trigger-width)] min-w-[300px]'
: 'w-[320px]',
)}
</DropdownMenuContent>
</DropdownMenu>
>
{!staticOptions && groups.length === 0 && !standaloneDefault && !sentinel && !allowCustom ? (
<div className="px-3 py-2 text-sm text-muted-foreground">Connect a provider in Settings</div>
) : (
<Command
// Filtering is ours (provider-name matching, the custom-id
// escape hatch, split-mode layout) — cmdk only does keyboard
// navigation and selection over what we render, with the
// highlighted value controlled (see commandValue).
shouldFilter={false}
value={commandValue}
onValueChange={setCommandValue}
onKeyDown={(e) => {
// Split mode: ←/→ cycles the provider column (tabs
// semantics); ↑/↓ stays on the model list via cmdk.
if (!splitMode || groups.length === 0) return
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return
e.preventDefault()
const index = groups.findIndex((g) => g.id === (activeGroup?.id ?? ''))
const next = e.key === 'ArrowRight'
? (index + 1) % groups.length
: (index - 1 + groups.length) % groups.length
switchProvider(groups[next])
}}
>
<CommandInput
autoFocus
value={query}
onValueChange={setQuery}
placeholder="Search models and providers…"
/>
{splitMode && activeGroup ? (
<div className="flex">
{/* Provider column — tab-like: click or ←/→. */}
<div className="w-40 shrink-0 border-r max-h-80 overflow-y-auto p-1" role="tablist" aria-label="Providers">
{groups.map((g) => (
<button
key={g.id}
type="button"
role="tab"
aria-selected={g.id === activeGroup.id}
tabIndex={-1}
onClick={() => switchProvider(g)}
className={cn(
'flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm',
g.id === activeGroup.id ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/50',
)}
>
<span className="min-w-0 flex-1 truncate">{providerDisplayNames[g.flavor] || g.flavor}</span>
{g.status === 'error' ? (
<span className="size-2 shrink-0 rounded-full bg-destructive" />
) : (
<span className="shrink-0 text-[10px] text-muted-foreground">{g.models.length}</span>
)}
</button>
))}
</div>
<CommandList className="max-h-80 flex-1">
<CommandGroup>
{renderSentinelItem()}
{standaloneDefault && standaloneDefault.provider === activeGroup.id &&
renderModelItem(standaloneDefault.provider, standaloneDefault.model)}
{activeGroup.models.map((m) => renderModelItem(activeGroup.id, m))}
{activeGroup.status === 'error' && renderErrorItem(activeGroup)}
{activeGroup.status === 'ok' && activeGroup.models.length === 0 && (
<div className="px-2 py-1.5 text-xs text-muted-foreground">No models reported</div>
)}
</CommandGroup>
</CommandList>
</div>
) : (
<CommandList className="max-h-80">
{sentinel && !queryValue && (
<CommandGroup>{renderSentinelItem()}</CommandGroup>
)}
{staticVisible && staticVisible.length > 0 && (
<CommandGroup>
{staticVisible.map((o) => (
<CommandItem key={o.id} value={o.id} onSelect={() => select({ provider: '', model: o.id })}>
<Check className={cn('size-3.5 shrink-0', selectedKey === o.id ? 'opacity-100' : 'opacity-0')} />
<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>
)}
</CommandItem>
))}
</CommandGroup>
)}
{!staticOptions && standaloneDefault && standaloneVisible && (
<CommandGroup>
{renderModelItem(
standaloneDefault.provider,
standaloneDefault.model,
providerDisplayNames[standaloneDefault.provider] || standaloneDefault.provider,
)}
</CommandGroup>
)}
{!staticOptions && groups.map((g) => {
// A provider-name match shows the whole group.
const visibleModels = queryValue && !groupMatchesFilter(g)
? g.models.filter((m) => m.toLowerCase().includes(queryValue))
: g.models
// Error rows are status, not models: they render (with
// the header) regardless of the filter and don't count
// toward "No models match".
const showError = g.status === 'error'
if (visibleModels.length === 0 && !showError) return null
return (
<CommandGroup key={g.id} heading={providerDisplayNames[g.flavor] || g.flavor}>
{visibleModels.map((m) => renderModelItem(g.id, m))}
{showError && renderErrorItem(g)}
</CommandGroup>
)
})}
{queryValue && !anyModelRowVisible && (
allowCustom ? (
// Escape hatch for ids the lists don't carry (local
// servers, brand-new models): select exactly what was
// typed.
<CommandGroup>
<CommandItem
value="__custom__"
onSelect={() => select(parseCustomModel(query.trim(), providerFilter, defaultModel))}
>
<span className="truncate">Use &quot;{query.trim()}&quot;</span>
</CommandItem>
</CommandGroup>
) : (
<div className="px-2 py-1.5 text-xs text-muted-foreground">No models match</div>
)
)}
</CommandList>
)}
</Command>
)}
</PopoverContent>
</Popover>
)}
</>
)

View file

@ -62,10 +62,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
onEscapeKeyDown={(e) => e.preventDefault()}
>
<div className="flex flex-col h-full max-h-[85vh] overflow-y-auto p-8 md:p-10">
<StepIndicator
currentStep={state.currentStep}
path={state.onboardingPath}
/>
<StepIndicator currentStep={state.currentStep} />
<AnimatePresence mode="wait">
<motion.div
key={state.currentStep}

View file

@ -1,18 +1,14 @@
import * as React from "react"
import { CheckCircle2 } from "lucide-react"
import { cn } from "@/lib/utils"
import type { Step, OnboardingPath } from "./use-onboarding-state"
import type { Step } from "./use-onboarding-state"
const ROWBOAT_STEPS = [
// Both paths share one sequence now: screen 1 handles Rowboat sign-in, and
// screen 2 ("Providers") serves everyone — first provider for key users,
// optional extras for signed-in users.
const STEPS = [
{ step: 0 as Step, label: "Welcome" },
{ step: 2 as Step, label: "Connect" },
{ step: 3 as Step, label: "Code" },
{ step: 4 as Step, label: "Done" },
]
const BYOK_STEPS = [
{ step: 0 as Step, label: "Welcome" },
{ step: 1 as Step, label: "Model" },
{ step: 1 as Step, label: "Providers" },
{ step: 2 as Step, label: "Connect" },
{ step: 3 as Step, label: "Code" },
{ step: 4 as Step, label: "Done" },
@ -20,11 +16,10 @@ const BYOK_STEPS = [
interface StepIndicatorProps {
currentStep: Step
path: OnboardingPath
}
export function StepIndicator({ currentStep, path }: StepIndicatorProps) {
const steps = path === 'byok' ? BYOK_STEPS : ROWBOAT_STEPS
export function StepIndicator({ currentStep }: StepIndicatorProps) {
const steps = STEPS
const currentIndex = steps.findIndex(s => s.step === currentStep)
return (

View file

@ -1,268 +1,37 @@
import { Loader2, CheckCircle2, ArrowLeft, X, Lightbulb } from "lucide-react"
import { motion } from "motion/react"
import { ArrowLeft } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { cn } from "@/lib/utils"
import {
OpenAIIcon,
AnthropicIcon,
GoogleIcon,
OllamaIcon,
OpenRouterIcon,
VercelIcon,
GenericApiIcon,
} from "../provider-icons"
import type { OnboardingState, LlmProviderFlavor } from "../use-onboarding-state"
import { ProvidersSection } from "@/components/settings/providers-section"
import { useModels } from "@/hooks/use-models"
import type { OnboardingState } from "../use-onboarding-state"
interface LlmSetupStepProps {
state: OnboardingState
}
const primaryProviders: Array<{ id: LlmProviderFlavor; name: string; description: string; color: string; icon: React.ReactNode }> = [
{ id: "openai", name: "OpenAI", description: "GPT models", color: "bg-green-500/10 text-green-600 dark:text-green-400", icon: <OpenAIIcon /> },
{ id: "anthropic", name: "Anthropic", description: "Claude models", color: "bg-orange-500/10 text-orange-600 dark:text-orange-400", icon: <AnthropicIcon /> },
{ id: "google", name: "Gemini", description: "Google AI Studio", color: "bg-blue-500/10 text-blue-600 dark:text-blue-400", icon: <GoogleIcon /> },
{ id: "ollama", name: "Ollama", description: "Local models", color: "bg-purple-500/10 text-purple-600 dark:text-purple-400", icon: <OllamaIcon /> },
]
const moreProviders: Array<{ id: LlmProviderFlavor; name: string; description: string; color: string; icon: React.ReactNode }> = [
{ id: "openrouter", name: "OpenRouter", description: "Multiple models, one key", color: "bg-pink-500/10 text-pink-600 dark:text-pink-400", icon: <OpenRouterIcon /> },
{ id: "aigateway", name: "AI Gateway", description: "Vercel AI Gateway", color: "bg-sky-500/10 text-sky-600 dark:text-sky-400", icon: <VercelIcon /> },
{ id: "openai-compatible", name: "OpenAI-Compatible", description: "Custom endpoint", color: "bg-gray-500/10 text-gray-600 dark:text-gray-400", icon: <GenericApiIcon /> },
]
// Screen 2 of onboarding: the SAME provider surface as Settings (connected
// list + add-provider flow), reframed for first-run. Users who signed in on
// screen 1 already have a working assistant (initial selection runs at
// sign-in) and see "Add more providers"; users who skipped sign-in connect
// their first provider here. Continue gates on an assistant model existing —
// the one thing chat can't run without.
export function LlmSetupStep({ state }: LlmSetupStepProps) {
const {
llmProvider, setLlmProvider, modelsLoading, modelsError,
activeConfig, testState, setTestState, showApiKey,
showBaseURL, canTest, showMoreProviders, setShowMoreProviders,
updateProviderConfig, handleTestAndSaveLlmConfig, handleTestAndAddAnother,
connectedFlavors, handleNext, handleBack,
upsellDismissed, setUpsellDismissed, handleSwitchToRowboat,
chatgpt,
} = state
const isMoreProvider = moreProviders.some(p => p.id === llmProvider)
// "Sign in with ChatGPT" (below the OpenAI card) is an alternative to a key:
// signing in counts as a connected provider alongside any saved BYOK one,
// which is what unlocks Continue when no API key was entered.
const hasConnectedProvider = connectedFlavors.size > 0 || chatgpt.status.signedIn
// Connect-only, mirroring Settings: entering a key (or base URL) is enough
// and the model is resolved silently at save. openai-compatible is the sole
// exception with a visible Model field — its /models endpoint often doesn't
// exist, so a typed value must be able to win.
const showModelInput = llmProvider === "openai-compatible"
const renderProviderCard = (provider: typeof primaryProviders[0], index: number) => {
const isSelected = llmProvider === provider.id
return (
<motion.button
key={provider.id}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.05 }}
onClick={() => {
setLlmProvider(provider.id)
setTestState({ status: "idle" })
}}
className={cn(
"rounded-xl border-2 p-4 text-left transition-all",
isSelected
? "border-primary bg-primary/5 shadow-sm"
: "border-transparent bg-muted/50 hover:bg-muted"
)}
>
<div className="flex items-center gap-3">
<div className={cn("size-10 rounded-lg flex items-center justify-center shrink-0", provider.color)}>
{provider.icon}
</div>
<div>
<div className="text-sm font-semibold">{provider.name}</div>
<div className="text-xs text-muted-foreground">{provider.description}</div>
</div>
{connectedFlavors.has(provider.id) && (
<CheckCircle2 className="size-4 text-green-600 dark:text-green-400 ml-auto shrink-0" />
)}
</div>
</motion.button>
)
}
const { handleNext, handleBack } = state
const { defaultModel, isRowboatConnected } = useModels()
const hasAssistant = defaultModel !== null
return (
<div className="flex flex-col flex-1">
{/* Title */}
<h2 className="text-3xl font-bold tracking-tight text-center mb-2">
Choose your provider
{isRowboatConnected ? "Add more providers" : "Connect a model provider"}
</h2>
<p className="text-base text-muted-foreground text-center mb-6">
Select a provider and configure your API key
{isRowboatConnected
? "Rowboat is ready to use. Optionally connect your own API keys or local models — their models appear alongside your Rowboat models."
: "Connect an API key or a local model to power the Assistant."}
</p>
{/* Inline Rowboat upsell callout */}
{!upsellDismissed && (
<motion.div
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, height: 0 }}
className="rounded-xl bg-primary/5 border border-primary/20 p-4 mb-6 flex items-start gap-3"
>
<Lightbulb className="size-5 text-primary shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<p className="text-sm text-foreground">
<span className="font-medium">Tip:</span> Hosted models recommended. Locally run LLMs can struggle with Rowboat's parallel background agents. Bring your own API keys below, or sign in for instant access.
</p>
<button
onClick={handleSwitchToRowboat}
className="text-sm text-primary font-medium hover:underline mt-1 inline-block"
>
Sign in instead
</button>
</div>
<button
onClick={() => setUpsellDismissed(true)}
className="text-muted-foreground hover:text-foreground transition-colors shrink-0"
>
<X className="size-4" />
</button>
</motion.div>
)}
{/* Provider selection */}
<div className="space-y-3 mb-4">
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Provider</span>
<div className="grid gap-2 sm:grid-cols-2">
{primaryProviders.map((p, i) => renderProviderCard(p, i))}
</div>
{(showMoreProviders || isMoreProvider) ? (
<div className="grid gap-2 sm:grid-cols-2 mt-2">
{moreProviders.map((p, i) => renderProviderCard(p, i + 4))}
</div>
) : (
<button
onClick={() => setShowMoreProviders(true)}
className="text-xs text-muted-foreground hover:text-foreground transition-colors mt-1"
>
More providers...
</button>
)}
</div>
{/* Separator */}
<div className="h-px bg-border my-4" />
{/* Provider configuration */}
<div className="space-y-4">
{/* Every provider resolves its model silently at save. openai-compatible
alone keeps this field, since its /models is unreliable and a typed
value must win; leaving it blank auto-selects from the fetched list. */}
{showModelInput && (
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">
Model
</label>
{modelsLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading...
</div>
) : (
<Input
value={activeConfig.model}
onChange={(e) => updateProviderConfig(llmProvider, { model: e.target.value })}
placeholder="Model ID (leave empty to auto-select)"
/>
)}
{modelsError && (
<div className="text-xs text-destructive">{modelsError}</div>
)}
</div>
)}
{showApiKey && (
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">
API Key {!state.requiresApiKey && "(optional)"}
</label>
<Input
type="password"
value={activeConfig.apiKey}
onChange={(e) => updateProviderConfig(llmProvider, { apiKey: e.target.value })}
placeholder="Paste your API key"
className="font-mono"
/>
</div>
)}
{/* ChatGPT subscription OAuth sign-in shown under the OpenAI card,
mirroring Settings (settings-dialog.tsx, gated on provider ===
"openai"). Alternative to an API key: signs in via the shared
chatgpt:* path, independent of models.json (the Codex model client
consumes the token in core). */}
{llmProvider === "openai" && (
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">
ChatGPT Subscription
</label>
{chatgpt.status.signedIn ? (
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5 text-sm text-green-600 dark:text-green-400 min-w-0">
<CheckCircle2 className="size-4 shrink-0" />
<span className="truncate">
Connected as {chatgpt.status.email ?? "your ChatGPT account"}
</span>
</div>
<Button
variant="outline"
size="sm"
className="shrink-0 border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={chatgpt.signOut}
>
Sign Out
</Button>
</div>
) : chatgpt.isSigningIn ? (
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Waiting for browser
</div>
<Button variant="outline" size="sm" className="shrink-0" onClick={chatgpt.cancelSignIn}>
Cancel
</Button>
</div>
) : (
<div className="flex items-center justify-between gap-2">
<span className="text-sm text-muted-foreground">
Use your ChatGPT Plus/Pro subscription
</span>
<Button variant="outline" size="sm" className="shrink-0" onClick={chatgpt.signIn}>
Sign In
</Button>
</div>
)}
</div>
)}
{showBaseURL && (
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">
Base URL
</label>
<Input
value={activeConfig.baseURL}
onChange={(e) => updateProviderConfig(llmProvider, { baseURL: e.target.value })}
placeholder={
llmProvider === "ollama"
? "http://localhost:11434"
: llmProvider === "openai-compatible"
? "http://localhost:1234/v1"
: "https://ai-gateway.vercel.sh/v1"
}
className="font-mono"
/>
</div>
)}
</div>
<ProvidersSection dialogOpen variant="onboarding" />
{/* Footer */}
<div className="flex items-center justify-between mt-6 pt-4 border-t">
@ -270,44 +39,9 @@ export function LlmSetupStep({ state }: LlmSetupStepProps) {
<ArrowLeft className="size-4" />
Back
</Button>
<div className="flex items-center gap-3">
{testState.status === "success" && (
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
className="flex items-center gap-1.5 text-sm text-green-600 dark:text-green-400"
>
<CheckCircle2 className="size-4" />
Connected
</motion.div>
)}
{testState.status === "error" && (
<span className="text-sm text-destructive max-w-[200px] truncate">
{testState.error}
</span>
)}
<Button
variant="outline"
onClick={handleTestAndAddAnother}
disabled={!canTest || testState.status === "testing"}
>
Save & add another
</Button>
<Button
onClick={canTest ? handleTestAndSaveLlmConfig : handleNext}
disabled={testState.status === "testing" || (!canTest && !hasConnectedProvider)}
className="min-w-[140px]"
>
{testState.status === "testing" ? (
<><Loader2 className="size-4 animate-spin mr-2" />Testing...</>
) : (canTest || !hasConnectedProvider) ? (
"Test & Continue"
) : (
"Continue"
)}
</Button>
</div>
<Button onClick={handleNext} disabled={!hasAssistant} className="min-w-[140px]">
Continue
</Button>
</div>
</div>
)

View file

@ -1,6 +1,5 @@
import { useState, useEffect, useCallback } from "react"
import { setGoogleCredentials } from "@/lib/google-credentials-store"
import { useChatGPT } from "@/hooks/useChatGPT"
import { toast } from "sonner"
export interface ProviderState {
@ -13,45 +12,10 @@ export type Step = 0 | 1 | 2 | 3 | 4
export type OnboardingPath = 'rowboat' | 'byok' | null
export type LlmProviderFlavor = "openai" | "anthropic" | "google" | "openrouter" | "aigateway" | "ollama" | "openai-compatible"
export interface LlmModelOption {
id: string
name?: string
release_date?: string
}
export function useOnboardingState(open: boolean, onComplete: (opts?: { startTour?: boolean }) => void) {
const [currentStep, setCurrentStep] = useState<Step>(0)
const [onboardingPath, setOnboardingPath] = useState<OnboardingPath>(null)
// LLM setup state
const [llmProvider, setLlmProvider] = useState<LlmProviderFlavor>("openai")
const [modelsCatalog, setModelsCatalog] = useState<Record<string, LlmModelOption[]>>({})
const [modelsLoading, setModelsLoading] = useState(false)
const [modelsError, setModelsError] = useState<string | null>(null)
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string; meetingNotesModel: string; liveNoteAgentModel: string }>>({
openai: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
anthropic: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
google: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
openrouter: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
aigateway: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
})
const [testState, setTestState] = useState<{ status: "idle" | "testing" | "success" | "error"; error?: string }>({
status: "idle",
})
const [connectedFlavors, setConnectedFlavors] = useState<Set<LlmProviderFlavor>>(new Set())
const [showMoreProviders, setShowMoreProviders] = useState(false)
// "Sign in with ChatGPT" (subscription OAuth) is offered below the OpenAI
// card, mirroring Settings. It isn't a LlmProviderFlavor — no API key, no
// models.json entry — it signs in via the dedicated chatgpt:* IPC (same
// path Settings uses) and the Codex model client consumes the token in
// core, leaving the BYOK config machinery untouched.
const chatgpt = useChatGPT()
// OAuth provider states
const [providers, setProviders] = useState<string[]>([])
const [providersLoading, setProvidersLoading] = useState(true)
@ -72,9 +36,6 @@ export function useOnboardingState(open: boolean, onComplete: (opts?: { startTou
const [slackDiscovering, setSlackDiscovering] = useState(false)
const [slackDiscoverError, setSlackDiscoverError] = useState<string | null>(null)
// Inline upsell callout dismissed
const [upsellDismissed, setUpsellDismissed] = useState(false)
// Composio Gmail/Calendar sync was removed — flags are seeded false and
// never flipped. Kept here so legacy gating expressions still type-check.
const [useComposioForGoogle] = useState(false)
@ -89,27 +50,6 @@ export function useOnboardingState(open: boolean, onComplete: (opts?: { startTou
const [googleCalendarLoading, setGoogleCalendarLoading] = useState(true)
const [googleCalendarConnecting, setGoogleCalendarConnecting] = useState(false)
const updateProviderConfig = useCallback(
(provider: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string; meetingNotesModel: string; liveNoteAgentModel: string }>) => {
setProviderConfigs(prev => ({
...prev,
[provider]: { ...prev[provider], ...updates },
}))
setTestState({ status: "idle" })
},
[]
)
const activeConfig = providerConfigs[llmProvider]
const showApiKey = llmProvider === "openai" || llmProvider === "anthropic" || llmProvider === "google" || llmProvider === "openrouter" || llmProvider === "aigateway" || llmProvider === "openai-compatible"
const requiresApiKey = llmProvider === "openai" || llmProvider === "anthropic" || llmProvider === "google" || llmProvider === "openrouter" || llmProvider === "aigateway"
const requiresBaseURL = llmProvider === "ollama" || llmProvider === "openai-compatible"
const showBaseURL = llmProvider === "ollama" || llmProvider === "openai-compatible" || llmProvider === "aigateway"
const isLocalProvider = llmProvider === "ollama" || llmProvider === "openai-compatible"
const canTest =
(!requiresApiKey || activeConfig.apiKey.trim().length > 0) &&
(!requiresBaseURL || activeConfig.baseURL.trim().length > 0)
// Track connected providers for the completion step
const connectedProviders = Object.entries(providerStates)
.filter(([, state]) => state.isConnected)
@ -135,56 +75,6 @@ export function useOnboardingState(open: boolean, onComplete: (opts?: { startTou
loadProviders()
}, [open])
// Load LLM models catalog on open
useEffect(() => {
if (!open) return
async function loadModels() {
try {
setModelsLoading(true)
setModelsError(null)
const result = await window.ipc.invoke("models:list", null)
const catalog: Record<string, LlmModelOption[]> = {}
for (const provider of result.providers || []) {
catalog[provider.id] = provider.models || []
}
setModelsCatalog(catalog)
} catch (error) {
console.error("Failed to load models catalog:", error)
setModelsError("Failed to load models list")
setModelsCatalog({})
} finally {
setModelsLoading(false)
}
}
loadModels()
}, [open])
// Preferred default models for each provider
const preferredDefaults: Partial<Record<LlmProviderFlavor, string>> = {
openai: "gpt-5.4",
anthropic: "claude-opus-4-8",
}
// Initialize default models from catalog
useEffect(() => {
if (Object.keys(modelsCatalog).length === 0) return
setProviderConfigs(prev => {
const next = { ...prev }
const cloudProviders: LlmProviderFlavor[] = ["openai", "anthropic", "google"]
for (const provider of cloudProviders) {
const models = modelsCatalog[provider]
if (models?.length && !next[provider].model) {
const preferredModel = preferredDefaults[provider]
const hasPreferred = preferredModel && models.some(m => m.id === preferredModel)
next[provider] = { ...next[provider], model: hasPreferred ? preferredModel : (models[0]?.id || "") }
}
}
return next
})
}, [modelsCatalog])
// Load Granola config
const refreshGranolaConfig = useCallback(async () => {
try {
@ -389,11 +279,7 @@ export function useOnboardingState(open: boolean, onComplete: (opts?: { startTou
// BYOK path: 0 (welcome) → 1 (llm setup) → 2 (connect) → 3 (code mode) → 4 (done)
const handleNext = useCallback(() => {
if (currentStep === 0) {
if (onboardingPath === 'byok') {
setCurrentStep(1)
} else {
setCurrentStep(2)
}
setCurrentStep(1)
} else if (currentStep === 1) {
setCurrentStep(2)
} else if (currentStep === 2) {
@ -408,11 +294,7 @@ export function useOnboardingState(open: boolean, onComplete: (opts?: { startTou
setCurrentStep(0)
setOnboardingPath(null)
} else if (currentStep === 2) {
if (onboardingPath === 'rowboat') {
setCurrentStep(0)
} else {
setCurrentStep(1)
}
setCurrentStep(1)
} else if (currentStep === 3) {
setCurrentStep(2)
}
@ -429,103 +311,6 @@ export function useOnboardingState(open: boolean, onComplete: (opts?: { startTou
onComplete({ startTour: true })
}, [onComplete])
// Test the active provider's credentials and persist its config. Returns
// whether it succeeded so callers can decide whether to advance or stay.
const testAndSaveActiveProvider = useCallback(async (): Promise<boolean> => {
if (!canTest) return false
setTestState({ status: "testing" })
try {
const apiKey = activeConfig.apiKey.trim() || undefined
const baseURL = activeConfig.baseURL.trim() || undefined
const provider = { flavor: llmProvider, apiKey, baseURL }
// Fetch the provider's models from the key — this both validates the
// credentials and gives us the list to populate the chat picker.
const result = await window.ipc.invoke("models:listForProvider", { provider })
if (!result.success) {
setTestState({ status: "error", error: result.error })
toast.error(result.error || "Connection test failed")
return false
}
const catalog: string[] = result.models ?? []
const typed = activeConfig.model.trim()
// Hosted providers hide the model field (it holds an auto-seeded
// default), so only treat it as user intent where the field is shown —
// mirrors showModelInput in llm-setup-step.
const hostedProviders: LlmProviderFlavor[] = ["openai", "anthropic", "google"]
const modelInputShown = !hostedProviders.includes(llmProvider)
if (modelInputShown && typed && llmProvider === "ollama" && catalog.length > 0 && !catalog.includes(typed)) {
// Ollama's tag list is authoritative: an unlisted model isn't pulled,
// so saving it would break chat at runtime with no obvious cause.
const error = `Model '${typed}' is not available on this Ollama server. Pull it first (ollama pull ${typed}) or pick one of: ${catalog.slice(0, 5).join(", ")}${catalog.length > 5 ? ", …" : ""}`
setTestState({ status: "error", error })
toast.error(error)
return false
}
// Resolve the model silently — same precedence as the settings dialog's
// resolvedModel, so onboarding no longer asks users to type one.
// openai-compatible keeps a visible Model field (its /models is
// unreliable), so a typed value there wins; otherwise the typed/saved
// model if the fetched list still has it, else the flavor's preferred
// default, else the first fetched id. An empty list falls back to
// whatever was typed/seeded.
let model: string
if (llmProvider === "openai-compatible" && typed) {
model = typed
} else if (catalog.length > 0) {
const preferred = preferredDefaults[llmProvider]
model = (typed && catalog.includes(typed))
? typed
: ((preferred && catalog.includes(preferred)) ? preferred : catalog[0])
} else {
model = typed
}
// v2 writes: the provider entry carries credentials only; the resolved
// model becomes the assistant model (onboarding's connect doubles as
// the initial selection).
await window.ipc.invoke("models:setProvider", { id: llmProvider, provider })
if (model) {
await window.ipc.invoke("models:updateConfig", {
assistantModel: { provider: llmProvider, model },
})
}
window.dispatchEvent(new Event('models-config-changed'))
setTestState({ status: "success" })
setConnectedFlavors(prev => new Set(prev).add(llmProvider))
return true
} catch (error) {
console.error("Connection test failed:", error)
setTestState({ status: "error", error: "Connection test failed" })
toast.error("Connection test failed")
return false
}
}, [activeConfig.apiKey, activeConfig.baseURL, activeConfig.model, canTest, llmProvider])
// Save the active provider and advance to the next step.
const handleTestAndSaveLlmConfig = useCallback(async () => {
const ok = await testAndSaveActiveProvider()
if (ok) handleNext()
}, [testAndSaveActiveProvider, handleNext])
// Save the active provider but stay on the step. Switch to the next provider the
// user hasn't connected yet so the form is fresh and the buttons re-enable once
// they enter that key. (Clearing the current field instead left the buttons
// disabled on an empty form with no clear next step.)
const handleTestAndAddAnother = useCallback(async () => {
const ok = await testAndSaveActiveProvider()
if (!ok) return
// setConnectedFlavors is async, so include the just-saved provider here.
const connectedNow = new Set(connectedFlavors).add(llmProvider)
const order: LlmProviderFlavor[] = ["openai", "anthropic", "google", "openrouter", "aigateway", "ollama", "openai-compatible"]
const next = order.find(p => !connectedNow.has(p))
if (next) setLlmProvider(next)
setTestState({ status: "idle" })
}, [testAndSaveActiveProvider, connectedFlavors, llmProvider])
// Check connection status for all providers
const refreshAllStatuses = useCallback(async () => {
refreshGranolaConfig()
@ -681,12 +466,6 @@ export function useOnboardingState(open: boolean, onComplete: (opts?: { startTou
startConnect('google', { clientId, clientSecret })
}, [startConnect])
// Switch to rowboat path from BYOK inline callout
const handleSwitchToRowboat = useCallback(() => {
setOnboardingPath('rowboat')
setCurrentStep(0)
}, [])
return {
// Step state
currentStep,
@ -694,32 +473,6 @@ export function useOnboardingState(open: boolean, onComplete: (opts?: { startTou
onboardingPath,
setOnboardingPath,
// LLM state
llmProvider,
setLlmProvider,
modelsCatalog,
modelsLoading,
modelsError,
providerConfigs,
activeConfig,
testState,
setTestState,
showApiKey,
requiresApiKey,
requiresBaseURL,
showBaseURL,
isLocalProvider,
canTest,
connectedFlavors,
showMoreProviders,
setShowMoreProviders,
updateProviderConfig,
handleTestAndSaveLlmConfig,
handleTestAndAddAnother,
// ChatGPT subscription sign-in (shown below the OpenAI card)
chatgpt,
// OAuth state
providers,
providersLoading,
@ -750,10 +503,6 @@ export function useOnboardingState(open: boolean, onComplete: (opts?: { startTou
handleSlackSaveWorkspaces,
handleSlackDisable,
// Upsell
upsellDismissed,
setUpsellDismissed,
// Composio/Gmail state
useComposioForGoogle,
gmailConnected,
@ -777,7 +526,6 @@ export function useOnboardingState(open: boolean, onComplete: (opts?: { startTou
handleBack,
handleComplete,
handleCompleteWithTour,
handleSwitchToRowboat,
}
}

View file

@ -24,7 +24,7 @@ import { cn } from "@/lib/utils"
import * as analytics from "@/lib/analytics"
import { useTheme } from "@/contexts/theme-context"
import { toast } from "sonner"
import { AnthropicIcon, DiscordIcon, GenericApiIcon, GitHubIcon, GoogleIcon, OllamaIcon, OpenAIIcon, OpenRouterIcon, VercelIcon } from "@/components/onboarding/provider-icons"
import { AnthropicIcon, DiscordIcon, GitHubIcon, OpenAIIcon } from "@/components/onboarding/provider-icons"
import { AccountSettings } from "@/components/settings/account-settings"
import { ConnectedAccountsSettings } from "@/components/settings/connected-accounts-settings"
import { MobileChannelsSettings } from "@/components/settings/mobile-channels-settings"
@ -32,10 +32,8 @@ import type { ApprovalPolicy } from "@x/shared/src/code-mode.js"
import { DEFAULT_TURN_LIMITS_SETTINGS } from "@x/shared/src/turn-limits.js"
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 { useRowboatConfig } from "@/hooks/use-rowboat-config"
import { useChatGPT } from "@/hooks/useChatGPT"
import { ModelSelector, type ModelRef } from "@/components/model-selector"
import { ModelSelectionSection } from "@/components/settings/model-selection-section"
import { ProvidersSection } from "@/components/settings/providers-section"
import { useModels } from "@/hooks/use-models"
type ConfigTab = "account" | "connections" | "mobile" | "models" | "mcp" | "security" | "code-mode" | "appearance" | "notifications" | "note-tagging" | "advanced" | "help"
@ -484,708 +482,7 @@ function AppearanceSettings() {
)
}
// --- Model Settings UI ---
type LlmProviderFlavor = "openai" | "anthropic" | "google" | "openrouter" | "aigateway" | "ollama" | "openai-compatible"
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 },
{ id: "google", name: "Gemini", description: "Google AI Studio", icon: GoogleIcon },
{ id: "ollama", name: "Ollama (Local)", description: "Run models locally", icon: OllamaIcon },
]
const moreProviders: Array<{ id: LlmProviderFlavor; name: string; description: string; icon: React.ElementType }> = [
{ id: "openrouter", name: "OpenRouter", description: "Multiple models, one key", icon: OpenRouterIcon },
{ id: "aigateway", name: "AI Gateway (Vercel)", description: "Vercel's AI Gateway", icon: VercelIcon },
{ id: "openai-compatible", name: "OpenAI-Compatible", description: "Custom OpenAI-compatible API", icon: GenericApiIcon },
]
const preferredDefaults: Partial<Record<LlmProviderFlavor, string>> = {
openai: "gpt-5.4",
anthropic: "claude-opus-4-8",
}
const defaultBaseURLs: Partial<Record<LlmProviderFlavor, string>> = {
ollama: "http://localhost:11434",
"openai-compatible": "http://localhost:1234/v1",
aigateway: "https://ai-gateway.vercel.sh/v1",
}
type ProviderModelConfig = {
apiKey: string
baseURL: string
models: string[]
knowledgeGraphModel: string
meetingNotesModel: string
liveNoteAgentModel: string
autoPermissionDecisionModel: string
}
function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: boolean; rowboatConnected?: boolean }) {
const [provider, setProvider] = useState<LlmProviderFlavor>("openai")
const [defaultProvider, setDefaultProvider] = useState<LlmProviderFlavor | null>(null)
// Flavors present in the saved providers map — drives each card's
// "Connected" indicator, independent of which card is active.
const [savedProviders, setSavedProviders] = useState<Set<LlmProviderFlavor>>(new Set())
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, ProviderModelConfig>>({
openai: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
anthropic: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
google: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
openrouter: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
aigateway: { apiKey: "", baseURL: "https://ai-gateway.vercel.sh/v1", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
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 [testState, setTestState] = useState<{ status: "idle" | "testing" | "success" | "error"; error?: string }>({ status: "idle" })
const [configLoading, setConfigLoading] = useState(true)
const [showMoreProviders, setShowMoreProviders] = useState(false)
// "Defer background tasks while a chat is running" — a top-level
// models.json flag. deferExplicit tracks whether the user (or the Ollama
// auto-enable) has ever set it, so we only auto-enable once.
const [deferBackgroundTasks, setDeferBackgroundTasks] = useState(false)
const [deferExplicit, setDeferExplicit] = useState(false)
const activeConfig = providerConfigs[provider]
// Live per-key model list for the active provider — drives the primary
// model area. The per-function fields below still use the static catalog.
// Backend model recommendations (flavor-keyed) — used when a provider
// becomes the assistant without an explicit model pick.
const modelRecommendations = useRowboatConfig()?.modelRecommendations
const providerModels = useProviderModels({
flavor: provider,
apiKey: activeConfig.apiKey,
baseURL: activeConfig.baseURL,
})
// "Sign in with ChatGPT" subscription state — only rendered on the OpenAI
// card; independent of the API-key providerConfigs state above.
const chatgpt = useChatGPT()
const showApiKey = provider === "openai" || provider === "anthropic" || provider === "google" || provider === "openrouter" || provider === "aigateway" || provider === "openai-compatible"
const requiresApiKey = provider === "openai" || provider === "anthropic" || provider === "google" || provider === "openrouter" || provider === "aigateway"
const showBaseURL = provider === "ollama" || provider === "openai-compatible" || provider === "aigateway"
const requiresBaseURL = provider === "ollama" || provider === "openai-compatible"
const isLocalProvider = provider === "ollama" || provider === "openai-compatible"
const isMoreProvider = moreProviders.some(p => p.id === provider)
const primaryModel = activeConfig.models[0] || ""
// What "Auto" resolves to right now: the flavor's preferred default when
// the fetched list carries it, else the first fetched id. Empty while the
// live list hasn't settled — the sentinel label and handleTestAndSave's
// on-demand fetch cover that window.
const autoResolvePreview = (() => {
if (providerModels.status !== "loaded" || providerModels.models.length === 0) return ""
const preferred = preferredDefaults[provider]
return preferred && providerModels.models.includes(preferred) ? preferred : providerModels.models[0]
})()
// The model chats run on. An explicit pick (the Assistant model field,
// including its typed-id escape hatch and the offline manual inputs)
// lives in models[0] and always wins — connecting must never silently
// swap a model the user chose; models:test reports honestly if it's bad.
// Empty ("Auto") keeps the silent resolve this card has always done.
const resolvedModel = primaryModel.trim() || autoResolvePreview
// Gate Connect on credentials only, NOT on resolvedModel — that derives
// from the live fetch, which hasn't settled the instant a key is pasted, so
// gating on it made the first click a no-op (the model is resolved, fetching
// on demand if needed, inside handleTestAndSave).
const canTest =
(!requiresApiKey || activeConfig.apiKey.trim().length > 0) &&
(!requiresBaseURL || activeConfig.baseURL.trim().length > 0)
const updateConfig = useCallback(
(prov: LlmProviderFlavor, updates: Partial<ProviderModelConfig>) => {
setProviderConfigs(prev => ({
...prev,
[prov]: { ...prev[prov], ...updates },
}))
setTestState({ status: "idle" })
},
[]
)
// All primary-model writes go through here: the old `models: [value]` form
// silently dropped every saved model after the first.
const setPrimaryModel = useCallback((prov: LlmProviderFlavor, value: string) => {
setProviderConfigs(prev => {
const existing = prev[prov].models
return {
...prev,
[prov]: { ...prev[prov], models: [value, ...existing.slice(1).filter(m => m && m !== value)] },
}
})
setTestState({ status: "idle" })
}, [])
// Load current config from file
useEffect(() => {
if (!dialogOpen) return
const asString = (v: unknown): string => (typeof v === "string" ? v : "")
async function loadCurrentConfig() {
try {
setConfigLoading(true)
const result = await window.ipc.invoke("workspace:readFile", {
path: "config/models.json",
})
// models.json v2: providers carry credentials only; the assistant
// model and per-task overrides live at the top level. This screen's
// per-provider "model" state is a UI concept — hydrated from
// assistantModel for its provider, empty for the rest (the picker
// lists come from the catalog / live probe, not from config).
const parsed = JSON.parse(result.data)
setDeferBackgroundTasks(parsed?.deferBackgroundTasks === true)
setDeferExplicit(typeof parsed?.deferBackgroundTasks === "boolean")
const knownFlavors = new Set<string>([...primaryProviders, ...moreProviders].map(p => p.id))
setSavedProviders(new Set(
Object.keys(parsed?.providers ?? {}).filter((k): k is LlmProviderFlavor => knownFlavors.has(k))
))
const assistant = parsed?.assistantModel as { provider?: string; model?: string } | undefined
const taskModels = (parsed?.taskModels ?? {}) as Record<string, { provider?: string; model?: string } | undefined>
const taskStringFor = (key: string, providerId: string): string => {
const ref = taskModels[key]
return ref?.provider === providerId ? asString(ref.model) : ""
}
setProviderConfigs(prev => {
const next = { ...prev };
for (const [key, entry] of Object.entries(parsed?.providers ?? {})) {
if (!(key in next)) continue
const e = entry as { apiKey?: string; baseURL?: string };
next[key as LlmProviderFlavor] = {
apiKey: e.apiKey || "",
baseURL: e.baseURL || (defaultBaseURLs[key as LlmProviderFlavor] || ""),
models: assistant?.provider === key && assistant.model ? [assistant.model] : [""],
knowledgeGraphModel: taskStringFor("knowledgeGraph", key),
meetingNotesModel: taskStringFor("meetingNotes", key),
liveNoteAgentModel: taskStringFor("liveNoteAgent", key),
autoPermissionDecisionModel: taskStringFor("autoPermissionDecision", key),
};
}
return next;
})
if (assistant?.provider && knownFlavors.has(assistant.provider)) {
setProvider(assistant.provider as LlmProviderFlavor)
setDefaultProvider(assistant.provider as LlmProviderFlavor)
}
} catch {
// No existing config or parse error - use defaults
} finally {
setConfigLoading(false)
}
}
loadCurrentConfig()
}, [dialogOpen])
const handleDeferToggle = useCallback(async (value: boolean) => {
setDeferBackgroundTasks(value)
setDeferExplicit(true)
try {
await window.ipc.invoke("models:updateConfig", { deferBackgroundTasks: value })
window.dispatchEvent(new Event("models-config-changed"))
} catch {
toast.error("Failed to save setting")
}
}, [])
const handleTestAndSave = useCallback(async () => {
if (!canTest) return
setTestState({ status: "testing" })
try {
// Normally providerModels has loaded by click time and resolvedModel is
// set. But a Connect click right after pasting a key can beat the
// debounced key-change fetch — so when nothing is resolved yet, fetch
// the list on demand (one fetch, then save) instead of forcing a second
// click. Same silent precedence as the Auto path (this branch can only
// run when no explicit model is set — an explicit pick IS resolved).
let model = resolvedModel
if (!model) {
const listRes = await window.ipc.invoke("models:listForProvider", {
provider: {
flavor: provider,
apiKey: activeConfig.apiKey.trim() || undefined,
baseURL: activeConfig.baseURL.trim() || undefined,
},
})
if (listRes.success && listRes.models && listRes.models.length > 0) {
const preferred = preferredDefaults[provider]
model = preferred && listRes.models.includes(preferred) ? preferred : listRes.models[0]
}
}
if (!model) {
setTestState({ status: "error", error: "Enter a model to connect" })
toast.error("Enter a model to connect")
return
}
const providerEntry = {
flavor: provider,
apiKey: activeConfig.apiKey.trim() || undefined,
baseURL: activeConfig.baseURL.trim() || undefined,
}
const result = await window.ipc.invoke("models:test", { provider: providerEntry, model })
if (result.success) {
// v2 writes: the provider entry carries credentials only; the model
// choice becomes the assistant model, and the BYOK per-task strings
// become task overrides scoped to this provider ('' clears → inherit).
const taskRef = (value: string) => {
const trimmed = value.trim()
return trimmed ? { provider, model: trimmed } : null
}
await window.ipc.invoke("models:setProvider", { id: provider, provider: providerEntry })
await window.ipc.invoke("models:updateConfig", {
assistantModel: { provider, model },
...(rowboatConnected ? {} : {
taskModels: {
knowledgeGraph: taskRef(activeConfig.knowledgeGraphModel),
meetingNotes: taskRef(activeConfig.meetingNotesModel),
liveNoteAgent: taskRef(activeConfig.liveNoteAgentModel),
autoPermissionDecision: taskRef(activeConfig.autoPermissionDecisionModel),
},
}),
})
setDefaultProvider(provider)
setProviderConfigs(prev => ({
...prev,
[provider]: { ...prev[provider], models: [model] },
}))
setSavedProviders(prev => new Set(prev).add(provider))
setTestState({ status: "success" })
window.dispatchEvent(new Event('models-config-changed'))
// Local models compete with background agents for the same hardware:
// when the user connects Ollama and has never touched the defer
// flag, enable it for them (they can switch it off below).
if (provider === "ollama" && !deferExplicit && !deferBackgroundTasks) {
void handleDeferToggle(true)
}
// Capability probe caveats (local models): saved, but the user should
// know when the model can't do tools or has a too-small context.
const warnings: string[] = result.warnings ?? []
if (warnings.length > 0) {
for (const warning of warnings) {
toast.warning(warning, { duration: 12000 })
}
toast.success("Model configuration saved (with warnings)")
} else {
toast.success("Model configuration saved")
}
} else {
setTestState({ status: "error", error: result.error })
toast.error(result.error || "Connection test failed")
}
} catch {
setTestState({ status: "error", error: "Connection test failed" })
toast.error("Connection test failed")
}
}, [canTest, resolvedModel, provider, activeConfig, rowboatConnected, deferExplicit, deferBackgroundTasks, handleDeferToggle])
// "Set as default" = choose this provider's model as the assistant model.
// The UI state only knows a model for the provider that was already the
// assistant, so other providers resolve one the same way a first connect
// does: recommendation if the provider lists it, else the first listed.
const handleSetDefault = useCallback(async (prov: LlmProviderFlavor) => {
const config = providerConfigs[prov]
try {
let model = config.models[0]?.trim() || ""
if (!model) {
const listRes = await window.ipc.invoke("models:listForProvider", {
provider: {
flavor: prov,
apiKey: config.apiKey.trim() || undefined,
baseURL: config.baseURL.trim() || undefined,
},
})
const list = listRes.success ? listRes.models ?? [] : []
const recommended = modelRecommendations?.[prov]
model = (recommended && list.includes(recommended)) ? recommended : (list[0] ?? "")
}
if (!model) {
toast.error("Couldn't determine a model for this provider")
return
}
await window.ipc.invoke("models:updateConfig", { assistantModel: { provider: prov, model } })
setDefaultProvider(prov)
setProviderConfigs(prev => ({
...prev,
[prov]: { ...prev[prov], models: [model] },
}))
window.dispatchEvent(new Event('models-config-changed'))
toast.success("Default provider updated")
} catch {
toast.error("Failed to set default provider")
}
}, [providerConfigs, modelRecommendations])
const handleDeleteProvider = useCallback(async (prov: LlmProviderFlavor) => {
const isDefaultProv = defaultProvider === prov
try {
// Removes the entry AND any assistant/task selection referencing it
// (repo-side dangling cleanup).
await window.ipc.invoke("models:removeProvider", { id: prov })
// Disconnecting the assistant's provider: promote another connected
// provider — the connect-only UI has no usable set-as-default step to
// send the user to. Best-effort; with none left the composer shows
// its connect hint.
let promoted: LlmProviderFlavor | null = null
if (isDefaultProv) {
const candidate = [...savedProviders].find((p): p is LlmProviderFlavor => p !== prov)
if (candidate) {
await handleSetDefault(candidate)
promoted = candidate
}
}
setProviderConfigs(prev => ({
...prev,
[prov]: { apiKey: "", baseURL: defaultBaseURLs[prov] || "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
}))
setSavedProviders(prev => {
const next = new Set(prev)
next.delete(prov)
return next
})
if (isDefaultProv) setDefaultProvider(promoted)
setTestState({ status: "idle" })
window.dispatchEvent(new Event('models-config-changed'))
if (promoted) {
const promotedName = [...primaryProviders, ...moreProviders].find(p => p.id === promoted)?.name || promoted
toast.success(`Disconnected · ${promotedName} is now the default`)
} else {
toast.success("Provider configuration removed")
}
} catch {
toast.error("Failed to remove provider")
}
}, [defaultProvider, savedProviders, handleSetDefault])
const renderProviderCard = (p: { id: LlmProviderFlavor; name: string; description: string; icon: React.ElementType }) => {
const isDefault = defaultProvider === p.id
const isSelected = provider === p.id
const isConnected = savedProviders.has(p.id)
const hasModel = providerConfigs[p.id].models[0]?.trim().length > 0
return (
<button
key={p.id}
onClick={() => {
setProvider(p.id)
setTestState({ status: "idle" })
}}
className={cn(
"rounded-md border px-3 py-2.5 text-left transition-colors relative",
isSelected
? "border-primary bg-primary/5"
: "border-border hover:bg-accent"
)}
>
<div className="flex items-center gap-2">
<p.icon className="size-4 shrink-0" />
<span className="text-sm font-medium">{p.name}</span>
{isDefault && !rowboatConnected && (
<span className="rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium leading-none text-primary">
Default
</span>
)}
{isConnected && (
<span className="rounded-full bg-green-500/10 px-1.5 py-0.5 text-[10px] font-medium leading-none text-green-600">
Connected
</span>
)}
</div>
<div className="text-xs text-muted-foreground mt-0.5">{p.description}</div>
{!isDefault && hasModel && isSelected && (
<div className="mt-1.5 flex items-center gap-3">
{!rowboatConnected && (
<span
role="button"
onClick={(e) => {
e.stopPropagation()
handleSetDefault(p.id)
}}
className="inline-flex text-[11px] text-muted-foreground hover:text-primary transition-colors cursor-pointer"
>
Set as default
</span>
)}
<span
role="button"
onClick={(e) => {
e.stopPropagation()
handleDeleteProvider(p.id)
}}
className="inline-flex text-[11px] text-muted-foreground hover:text-destructive transition-colors cursor-pointer"
>
Remove
</span>
</div>
)}
</button>
)
}
if (configLoading) {
return (
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
<Loader2 className="size-4 animate-spin mr-2" />
Loading...
</div>
)
}
return (
<div className="space-y-4">
{/* Provider selection */}
<div className="space-y-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Provider</span>
<div className="grid gap-2 grid-cols-2">
{primaryProviders.map(renderProviderCard)}
</div>
{(showMoreProviders || isMoreProvider) ? (
<div className="grid gap-2 grid-cols-2 mt-2">
{moreProviders.map(renderProviderCard)}
</div>
) : (
<button
onClick={() => setShowMoreProviders(true)}
className="text-xs text-muted-foreground hover:text-foreground transition-colors mt-1"
>
More providers...
</button>
)}
</div>
{/* API Key — key-first: the model list is fetched from it */}
{showApiKey && (
<div className="space-y-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{provider === "openai-compatible" ? "API Key (optional)" : "API Key"}
</span>
<Input
type="password"
value={activeConfig.apiKey}
onChange={(e) => updateConfig(provider, { apiKey: e.target.value })}
onBlur={() => providerModels.refetch()}
placeholder="Paste your API key"
/>
</div>
)}
{/* ChatGPT subscription OAuth sign-in, independent of the API key and
of models.json (the Codex model client consumes the token via core) */}
{provider === "openai" && (
<div className="space-y-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
ChatGPT Subscription
</span>
{chatgpt.status.signedIn ? (
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5 text-sm text-green-600 min-w-0">
<CheckCircle2 className="size-4 shrink-0" />
<span className="truncate">
Connected as {chatgpt.status.email ?? "your ChatGPT account"}
</span>
</div>
<Button
variant="outline"
size="sm"
className="shrink-0 border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={chatgpt.signOut}
>
Sign Out
</Button>
</div>
) : chatgpt.isSigningIn ? (
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Waiting for browser
</div>
<Button variant="outline" size="sm" className="shrink-0" onClick={chatgpt.cancelSignIn}>
Cancel
</Button>
</div>
) : (
<div className="flex items-center justify-between gap-2">
<span className="text-sm text-muted-foreground">
Use your ChatGPT Plus/Pro subscription
</span>
<Button variant="outline" size="sm" className="shrink-0" onClick={chatgpt.signIn}>
Sign In
</Button>
</div>
)}
</div>
)}
{/* Base URL */}
{showBaseURL && (
<div className="space-y-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Base URL</span>
<Input
value={activeConfig.baseURL}
onChange={(e) => updateConfig(provider, { baseURL: e.target.value })}
onBlur={() => providerModels.refetch()}
placeholder={
provider === "ollama"
? "http://localhost:11434"
: provider === "openai-compatible"
? "http://localhost:1234/v1"
: "https://ai-gateway.vercel.sh/v1"
}
/>
</div>
)}
{/* Connection status — the model itself is resolved silently on save */}
<div className="space-y-2">
{providerModels.status === "idle" ? (
<div className="text-sm text-muted-foreground">
{isLocalProvider
? "Enter your base URL to connect"
: "Enter your API key to connect"}
</div>
) : providerModels.status === "loading" ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Checking connection
</div>
) : providerModels.status === "error" ? (
<div className="space-y-2">
<div className="text-xs text-destructive break-words">
{providerModels.error || "Connection check failed"}
</div>
<Button variant="outline" size="sm" onClick={() => providerModels.refetch()}>
Retry
</Button>
{provider !== "openai-compatible" && (
<Input
value={primaryModel}
onChange={(e) => setPrimaryModel(provider, e.target.value)}
placeholder="Enter a model to connect anyway"
/>
)}
</div>
) : providerModels.models.length === 0 && provider !== "openai-compatible" ? (
<div className="space-y-2">
<div className="text-sm text-muted-foreground">
Connected, but the provider reported no models enter one manually
</div>
<Input
value={primaryModel}
onChange={(e) => setPrimaryModel(provider, e.target.value)}
placeholder="Enter model"
/>
</div>
) : (
<div className="flex items-center gap-1.5 text-sm text-green-600">
<CheckCircle2 className="size-4" />
Connected · {providerModels.models.length} model{providerModels.models.length === 1 ? "" : "s"} available
</div>
)}
{savedProviders.has(provider) && (
<Button
variant="outline"
size="sm"
className="border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={() => handleDeleteProvider(provider)}
>
Disconnect
</Button>
)}
</div>
{/* The model chats run on the anchor the category fields' "Same as
assistant" points at. Auto keeps the silent resolve; the picker
live-fetches with the card's CURRENT inputs (typed, maybe unsaved),
and allowCustom subsumes the old openai-compatible free-text field
(its /models often doesn't exist type the id in the search).
Explicit picks land in models[0] via setPrimaryModel (preserving
models[1..]); the offline manual inputs above write the same slot. */}
<div className="space-y-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{rowboatConnected ? "Model" : "Assistant model"}
</span>
<ModelSelector
variant="field"
providerFilter={provider}
liveCredentials={{ flavor: provider, apiKey: activeConfig.apiKey, baseURL: activeConfig.baseURL }}
allowCustom
defaultOption={{ label: autoResolvePreview ? `Auto (currently ${autoResolvePreview})` : "Auto (recommended)" }}
value={primaryModel.trim() ? { provider, model: primaryModel.trim() } : null}
onChange={(ref) => setPrimaryModel(provider, ref ? ref.model : "")}
/>
</div>
{/* 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 && (<>
{(
[
{ 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>
))}
</>)}
</div>
{/* Test status */}
{testState.status === "error" && (
<div className="text-sm text-destructive">
{testState.error || "Connection test failed"}
</div>
)}
{testState.status === "success" && (
<div className="flex items-center gap-1.5 text-sm text-green-600">
<CheckCircle2 className="size-4" />
Connected and saved
</div>
)}
{/* Defer background tasks while chatting */}
<div className="flex items-center justify-between gap-4 rounded-md border px-3 py-2.5">
<div className="min-w-0">
<div className="text-sm font-medium">Defer background tasks while chatting</div>
<div className="text-xs text-muted-foreground mt-0.5">
Background agents (knowledge sync, live notes, tasks) wait until your chat finishes. Recommended for local models.
</div>
</div>
<Switch checked={deferBackgroundTasks} onCheckedChange={handleDeferToggle} />
</div>
{/* Test & Save button */}
<Button
onClick={handleTestAndSave}
disabled={!canTest || testState.status === "testing"}
className="w-full"
>
{testState.status === "testing" ? (
<><Loader2 className="size-4 animate-spin mr-2" />Connecting...</>
) : (
"Connect"
)}
</Button>
</div>
)
}
// --- Tools Library Settings ---
interface ToolkitInfo {
slug: string
@ -1504,129 +801,6 @@ function ToolsLibrarySettings({ dialogOpen, rowboatConnected }: { dialogOpen: bo
)
}
// --- Rowboat Model Settings (when signed in via Rowboat) ---
//
// 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 [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)
useEffect(() => {
if (!dialogOpen) return
async function load() {
setLoading(true)
try {
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 config yet */ }
// models.json v2: assistantModel + taskModels refs.
const toRef = (value: unknown): ModelRef | null => {
const ref = value as { provider?: unknown; model?: unknown } | null | undefined
return ref && typeof ref.provider === "string" && typeof ref.model === "string"
? { provider: ref.provider, model: ref.model }
: null
}
const taskModels = (parsed.taskModels ?? {}) as Record<string, unknown>
setSelectedDefault(toRef(parsed.assistantModel))
setSelectedKg(toRef(taskModels.knowledgeGraph))
setSelectedLiveNote(toRef(taskModels.liveNoteAgent))
setSelectedAutoPermission(toRef(taskModels.autoPermissionDecision))
} catch {
toast.error("Failed to load models")
} finally {
setLoading(false)
}
}
load()
}, [dialogOpen])
const handleSave = useCallback(async () => {
setSaving(true)
try {
await window.ipc.invoke("models:updateConfig", {
// The "Rowboat default" sentinel no longer maps to a hidden curated
// default — a null task ref simply inherits the assistant model. The
// assistant itself is only written when explicitly picked (clearing
// it would leave the app with no model at all).
...(selectedDefault ? { assistantModel: selectedDefault } : {}),
taskModels: {
knowledgeGraph: selectedKg,
liveNoteAgent: selectedLiveNote,
autoPermissionDecision: selectedAutoPermission,
},
})
window.dispatchEvent(new Event("models-config-changed"))
toast.success("Model configuration saved")
} catch {
toast.error("Failed to save model configuration")
} finally {
setSaving(false)
}
}, [selectedDefault, selectedKg, selectedLiveNote, selectedAutoPermission])
const renderModelField = (
label: string,
value: ModelRef | null,
onChange: (v: ModelRef | null) => void,
) => (
<div className="space-y-2">
<label className="text-sm font-medium">{label}</label>
<ModelSelector
variant="field"
value={value}
onChange={onChange}
defaultOption={{ label: "Rowboat default" }}
/>
</div>
)
if (loading) {
return (
<div className="flex items-center justify-center py-12 text-muted-foreground">
<Loader2 className="size-5 animate-spin" />
</div>
)
}
return (
<div className="space-y-6">
<p className="text-sm text-muted-foreground">
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>
{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}>
{saving ? (
<><Loader2 className="size-4 animate-spin mr-2" />Saving...</>
) : (
"Save"
)}
</Button>
</div>
)
}
// --- Note Tagging Settings ---
interface TagDef {
@ -2719,8 +1893,8 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
<div className="px-6 pb-4 pt-5">
<h3 className="text-lg font-semibold tracking-tight">{activeTabConfig.label}</h3>
<p className="text-sm text-muted-foreground mt-1">
{activeTab === "models" && rowboatConnected
? "Select your default models"
{activeTab === "models"
? "Choose the models Rowboat uses for chat and background work."
: activeTabConfig.description}
</p>
</div>
@ -2747,21 +1921,22 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
) : activeTab === "mobile" ? (
<MobileChannelsSettings dialogOpen={open} />
) : activeTab === "models" ? (
rowboatConnected
? (
<div className="space-y-8">
<RowboatModelSettings dialogOpen={open} />
<Separator />
<div className="space-y-2">
<h4 className="text-sm font-semibold">Your own providers</h4>
<p className="text-xs text-muted-foreground">
Connect your own API keys or local runtimes (Ollama, LM Studio). Their models appear in the model pickers above and alongside your Rowboat models, and are billed to you directly.
</p>
<ModelSettings dialogOpen={open} rowboatConnected />
</div>
</div>
)
: <ModelSettings dialogOpen={open} />
// ONE model-selection surface for signed-in and BYOK alike:
// the Assistant model + per-task overrides, then provider
// (credential) management below.
<div className="space-y-8">
<ModelSelectionSection dialogOpen={open} />
<Separator />
<div className="space-y-2">
<h4 className="text-sm font-semibold">{rowboatConnected ? "Your own providers" : "Providers"}</h4>
<p className="text-xs text-muted-foreground">
{rowboatConnected
? "Connect your own API keys or local runtimes (Ollama, LM Studio). Their models appear in every picker alongside your Rowboat models, and are billed to you directly."
: "Connect API keys or local runtimes (Ollama, LM Studio). Every connected provider's models appear in the pickers above."}
</p>
<ProvidersSection dialogOpen={open} />
</div>
</div>
) : activeTab === "note-tagging" ? (
<NoteTaggingSettings dialogOpen={open} />
) : activeTab === "appearance" ? (

View file

@ -0,0 +1,90 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { __resetModelsForTests } from '@/hooks/use-models'
import { ModelSelectionSection } from './model-selection-section'
// Same preload stub pattern as use-models.test.tsx.
let handlers: Record<string, (args: unknown) => Promise<unknown>> = {}
let updateCalls: unknown[] = []
;(window as unknown as { ipc: unknown }).ipc = {
on: () => () => undefined,
invoke: (channel: string, args: unknown) => {
if (channel === 'models:updateConfig') updateCalls.push(args)
const handler = handlers[channel]
return handler ? handler(args) : Promise.reject(new Error(`no handler: ${channel}`))
},
}
const EMPTY_TASKS = {
knowledgeGraph: null,
meetingNotes: null,
liveNoteAgent: null,
autoPermissionDecision: null,
chatTitle: null,
backgroundTask: null,
subagent: null,
}
function serve(opts: {
assistant?: { provider: string; model: string } | null
taskModels?: Record<string, { provider: string; model: string } | null>
}): void {
handlers['models:list'] = async () => ({
providers: [
{ id: 'rowboat', flavor: 'rowboat', status: 'ok', models: [{ id: 'google/gemini-3.5-flash' }] },
],
defaultModel: opts.assistant ?? null,
})
handlers['models:getConfig'] = async () => ({
assistantModel: opts.assistant ?? null,
taskModels: { ...EMPTY_TASKS, ...(opts.taskModels ?? {}) },
deferBackgroundTasks: false,
})
handlers['models:updateConfig'] = async () => ({ success: true })
}
beforeEach(() => {
__resetModelsForTests()
handlers = {}
updateCalls = []
})
afterEach(cleanup)
describe('ModelSelectionSection', () => {
it('shows the effective assistant model and "Same as Assistant" for un-overridden tasks', async () => {
serve({ assistant: { provider: 'rowboat', model: 'google/gemini-3.5-flash' } })
render(<ModelSelectionSection dialogOpen />)
// Assistant trigger shows the actual model — no "Auto" anywhere.
await waitFor(() => expect(screen.getByTitle('Assistant model')).toHaveTextContent('google/gemini-3.5-flash'))
// The old sentinel labels are gone for good.
expect(screen.queryByText(/Auto \(/)).toBeNull()
expect(screen.queryByText('Rowboat default')).toBeNull()
// All seven tasks render, inheriting.
for (const label of ['Background agents', 'Subagents', 'Knowledge graph', 'Meeting notes', 'Live notes', 'Permission checks', 'Chat titles']) {
expect(screen.getByText(label)).toBeInTheDocument()
}
expect(screen.getAllByText('Same as Assistant').length).toBe(7)
// Inherit subtext names the resolved assistant.
expect(screen.getAllByText('Currently uses Rowboat · google/gemini-3.5-flash').length).toBeGreaterThan(0)
})
it('an overridden task shows "Use Assistant model" and clicking it clears the override', async () => {
serve({
assistant: { provider: 'rowboat', model: 'google/gemini-3.5-flash' },
taskModels: { knowledgeGraph: { provider: 'rowboat', model: 'google/gemini-3.1-flash-lite' } },
})
render(<ModelSelectionSection dialogOpen />)
const clear = await screen.findByText('Use Assistant model')
fireEvent.click(clear)
await waitFor(() => expect(updateCalls).toEqual([
{ taskModels: { knowledgeGraph: null } },
]))
// Back to inheriting.
await waitFor(() => expect(screen.queryByText('Use Assistant model')).toBeNull())
})
})

View file

@ -0,0 +1,169 @@
import { useCallback, useEffect, useState } from "react"
import { toast } from "sonner"
import { ModelSelector, providerDisplayNames, type ModelRef } from "@/components/model-selector"
import { useModels } from "@/hooks/use-models"
// The unified model-selection surface (signed-in and BYOK alike): ONE
// required Assistant model plus per-task overrides that default to
// "Same as Assistant". No "Auto" rows — every choice is an explicit model;
// recommendation logic only ever picks INITIAL models at provider-connect
// time, never appears as a dropdown option.
type TaskKey =
| "backgroundTask"
| "subagent"
| "knowledgeGraph"
| "meetingNotes"
| "liveNoteAgent"
| "autoPermissionDecision"
| "chatTitle"
const TASKS: Array<{ key: TaskKey; label: string; description: string }> = [
{ key: "backgroundTask", label: "Background agents", description: "Scheduled and event-driven agents that run without a chat" },
{ key: "subagent", label: "Subagents", description: "Workers the assistant spawns during a chat" },
{ key: "knowledgeGraph", label: "Knowledge graph", description: "Note creation, email classification, knowledge sync" },
{ key: "meetingNotes", label: "Meeting notes", description: "Meeting summaries and prep briefs" },
{ key: "liveNoteAgent", label: "Live notes", description: "Self-updating notes and their routing" },
{ key: "autoPermissionDecision", label: "Permission checks", description: "Auto-approval of safe tool calls" },
{ key: "chatTitle", label: "Chat titles", description: "Naming chats from the first message" },
]
function refLabel(ref: ModelRef): string {
return `${providerDisplayNames[ref.provider] || ref.provider} · ${ref.model}`
}
export function ModelSelectionSection({ dialogOpen }: { dialogOpen: boolean }) {
// The effective assistant model — the same value every picker shows.
const { defaultModel, groups } = useModels()
const [taskModels, setTaskModels] = useState<Partial<Record<TaskKey, ModelRef | null>>>({})
// Retired-model detection: the saved assistant no longer appears in its
// provider's live list. Only trusted lists count — a failed fetch or an
// openai-compatible endpoint (whose /models is often unreliable) must not
// flag a working model.
const assistantUnavailable = (() => {
if (!defaultModel) return false
const group = groups.find((g) => g.id === defaultModel.provider)
if (!group || group.status !== "ok" || group.models.length === 0) return false
if (group.flavor === "openai-compatible") return false
return !group.models.includes(defaultModel.model)
})()
const load = useCallback(async () => {
try {
const cfg = await window.ipc.invoke("models:getConfig", null)
setTaskModels(cfg.taskModels)
} catch {
// Fresh install — everything inherits.
setTaskModels({})
}
}, [])
useEffect(() => {
if (dialogOpen) void load()
}, [dialogOpen, load])
const setAssistant = useCallback(async (ref: ModelRef | null) => {
// No sentinel row on the assistant picker, so ref is never null — the
// assistant is the one required selection.
if (!ref) return
try {
await window.ipc.invoke("models:updateConfig", { assistantModel: ref })
window.dispatchEvent(new Event("models-config-changed"))
} catch {
toast.error("Failed to save the Assistant model")
}
}, [])
const setTask = useCallback(async (key: TaskKey, ref: ModelRef | null) => {
const previous = taskModels
setTaskModels((prev) => ({ ...prev, [key]: ref }))
try {
await window.ipc.invoke("models:updateConfig", { taskModels: { [key]: ref } })
window.dispatchEvent(new Event("models-config-changed"))
} catch {
toast.error("Failed to save the model")
setTaskModels(previous)
}
}, [taskModels])
return (
<div className="space-y-6">
{/* Assistant model — the one required primary selection. */}
<div className="space-y-2">
<div>
<h4 className="text-sm font-semibold flex items-center gap-2">
Assistant model
{assistantUnavailable && (
<span className="rounded-full bg-destructive/10 px-1.5 py-0.5 text-[10px] font-medium leading-none text-destructive">
Unavailable
</span>
)}
</h4>
<p className="text-xs text-muted-foreground mt-0.5">
Used for chat and for any task without its own model selection.
</p>
</div>
<ModelSelector
variant="field"
value={defaultModel}
onChange={setAssistant}
triggerTitle="Assistant model"
/>
{assistantUnavailable && defaultModel && (
<p className="text-xs text-destructive">
This model is no longer listed by {providerDisplayNames[defaultModel.provider] || defaultModel.provider}. Choose another model to continue.
</p>
)}
</div>
{/* Per-task overrides — inherit the assistant unless picked. */}
<div className="space-y-3">
<div>
<h4 className="text-sm font-semibold">Models for other tasks</h4>
<p className="text-xs text-muted-foreground mt-0.5">
These tasks use the Assistant model unless you choose a different one.
</p>
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-4">
{TASKS.map(({ key, label, description }) => {
const override = taskModels[key] ?? null
const inheritText = key === "subagent"
? "Uses the spawning chat's model"
: defaultModel
? `Currently uses ${refLabel(defaultModel)}`
: "Uses the Assistant model"
return (
<div key={key} className="space-y-1.5 min-w-0">
<div className="flex items-baseline justify-between gap-2">
<span className="text-xs font-medium">{label}</span>
{override && (
<button
type="button"
className="text-[11px] text-muted-foreground underline underline-offset-2 hover:text-foreground shrink-0"
onClick={() => void setTask(key, null)}
>
Use Assistant model
</button>
)}
</div>
<ModelSelector
variant="field"
allowCustom
inheritDefault={{ label: "Same as Assistant" }}
value={override}
onChange={(ref) => void setTask(key, ref)}
triggerTitle={label}
/>
<p className="text-[11px] text-muted-foreground truncate" title={override ? refLabel(override) : inheritText}>
{override ? "Uses a different model from the Assistant" : inheritText}
</p>
<p className="sr-only">{description}</p>
</div>
)
})}
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,747 @@
import { useCallback, useEffect, useMemo, useState } from "react"
import { toast } from "sonner"
import { ArrowLeft, CheckCircle2, Loader2, Plus, RefreshCw } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Switch } from "@/components/ui/switch"
import { cn } from "@/lib/utils"
import { providerDisplayNames, type ModelRef } from "@/components/model-selector"
import { useModels } from "@/hooks/use-models"
import { useRowboatConfig } from "@/hooks/use-rowboat-config"
import { useChatGPT } from "@/hooks/useChatGPT"
import {
AnthropicIcon,
GenericApiIcon,
GoogleIcon,
OllamaIcon,
OpenAIIcon,
OpenRouterIcon,
VercelIcon,
} from "@/components/onboarding/provider-icons"
// Provider lifecycle: connected-provider cards (status + model counts), the
// add-provider flow, per-provider manage (replace key / endpoint / refresh
// models / used-by), and disconnect with its consequences spelled out.
// Providers manage CREDENTIALS only — model choices live in
// ModelSelectionSection above this section.
type ByokFlavor = "openai" | "anthropic" | "google" | "openrouter" | "aigateway" | "ollama" | "openai-compatible"
interface ProviderMeta {
id: string
flavor: string
baseURL?: string
hasApiKey: boolean
}
interface Selections {
assistantModel: ModelRef | null
taskModels: Record<string, ModelRef | null>
}
const TASK_LABELS: Record<string, string> = {
backgroundTask: "Background agents",
subagent: "Subagents",
knowledgeGraph: "Knowledge graph",
meetingNotes: "Meeting notes",
liveNoteAgent: "Live notes",
autoPermissionDecision: "Permission checks",
chatTitle: "Chat titles",
}
const BYOK_CATALOG: Array<{ flavor: ByokFlavor; name: string; tagline: string; icon: React.ElementType; needsKey: boolean; needsEndpoint: boolean; optionalKey?: boolean; manualModel?: boolean }> = [
{ flavor: "openai", name: "OpenAI", tagline: "GPT models", icon: OpenAIIcon, needsKey: true, needsEndpoint: false },
{ flavor: "anthropic", name: "Anthropic", tagline: "Claude models", icon: AnthropicIcon, needsKey: true, needsEndpoint: false },
{ flavor: "google", name: "Gemini", tagline: "Google AI Studio", icon: GoogleIcon, needsKey: true, needsEndpoint: false },
{ flavor: "ollama", name: "Ollama", tagline: "Run models locally", icon: OllamaIcon, needsKey: false, needsEndpoint: true },
{ flavor: "openrouter", name: "OpenRouter", tagline: "One key, many models", icon: OpenRouterIcon, needsKey: true, needsEndpoint: false },
{ flavor: "aigateway", name: "AI Gateway (Vercel)", tagline: "Vercel's AI Gateway", icon: VercelIcon, needsKey: true, needsEndpoint: false },
{ flavor: "openai-compatible", name: "OpenAI-Compatible", tagline: "Custom OpenAI-compatible endpoint", icon: GenericApiIcon, needsKey: true, optionalKey: true, needsEndpoint: true, manualModel: true },
]
const DEFAULT_BASE_URLS: Partial<Record<ByokFlavor, string>> = {
ollama: "http://localhost:11434",
"openai-compatible": "http://localhost:1234/v1",
aigateway: "https://ai-gateway.vercel.sh/v1",
}
function flavorMeta(flavor: string) {
return BYOK_CATALOG.find((c) => c.flavor === flavor)
}
export function ProvidersSection({ dialogOpen, variant = "settings" }: {
dialogOpen: boolean
/**
* "onboarding" renders the same connected-provider list + add flow but
* without the settings-only chrome (Manage buttons, defer toggle) the
* onboarding step supplies its own framing and navigation.
*/
variant?: "settings" | "onboarding"
}) {
const { groups, isRowboatConnected, refresh } = useModels()
const chatgpt = useChatGPT()
const modelRecommendations = useRowboatConfig()?.modelRecommendations
const [providersMeta, setProvidersMeta] = useState<ProviderMeta[]>([])
const [selections, setSelections] = useState<Selections>({ assistantModel: null, taskModels: {} })
const [deferBackgroundTasks, setDeferBackgroundTasks] = useState(false)
const [addOpen, setAddOpen] = useState(false)
const [manageId, setManageId] = useState<string | null>(null)
const load = useCallback(async () => {
try {
const cfg = await window.ipc.invoke("models:getConfig", null)
setProvidersMeta(cfg.providers)
setSelections({ assistantModel: cfg.assistantModel, taskModels: cfg.taskModels })
setDeferBackgroundTasks(cfg.deferBackgroundTasks)
} catch { /* fresh install */ }
}, [])
useEffect(() => {
if (dialogOpen) void load()
}, [dialogOpen, load])
useEffect(() => {
const handler = () => void load()
window.addEventListener("models-config-changed", handler)
// Main-side config writes (Rowboat sign-in seeding the assistant,
// sign-out clearing selections, ChatGPT state) announce themselves on
// the auth broadcasts, not the window event — reload on those too.
const cleanups = [
window.ipc.on("oauth:didConnect", handler),
window.ipc.on("chatgpt:statusChanged", handler),
]
return () => {
window.removeEventListener("models-config-changed", handler)
for (const cleanup of cleanups) cleanup()
}
}, [load])
// One card per connected provider, in catalog order (assistant's first).
const cards = useMemo(() => {
return groups.map((g) => {
const meta = providersMeta.find((p) => p.id === g.id)
return {
id: g.id,
flavor: g.flavor,
name: providerDisplayNames[g.flavor] || g.flavor,
status: g.status,
error: g.error,
modelCount: g.models.length,
meta,
}
})
}, [groups, providersMeta])
const usedBy = useCallback((providerId: string): Array<{ label: string; model: string }> => {
const rows: Array<{ label: string; model: string }> = []
if (selections.assistantModel?.provider === providerId) {
rows.push({ label: "Assistant model", model: selections.assistantModel.model })
}
for (const [key, ref] of Object.entries(selections.taskModels)) {
if (ref?.provider === providerId) {
rows.push({ label: TASK_LABELS[key] ?? key, model: ref.model })
}
}
return rows
}, [selections])
const handleDeferToggle = useCallback(async (value: boolean) => {
setDeferBackgroundTasks(value)
try {
await window.ipc.invoke("models:updateConfig", { deferBackgroundTasks: value })
window.dispatchEvent(new Event("models-config-changed"))
} catch {
toast.error("Failed to save setting")
}
}, [])
const manageCard = manageId ? cards.find((c) => c.id === manageId) ?? null : null
return (
<div className="space-y-4">
<div className="space-y-2">
{cards.length === 0 && (
<div className="rounded-md border border-dashed px-4 py-6 text-center text-sm text-muted-foreground">
Connect Rowboat, use your own API key, or choose a local provider to start using the Assistant.
</div>
)}
{cards.map((c) => (
<div key={c.id} className="flex items-center gap-3 rounded-md border px-3 py-2.5">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{c.name}</span>
</div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mt-0.5">
<span
className={cn(
"size-2 rounded-full shrink-0",
c.status === "ok" ? "bg-green-500" : "bg-destructive",
)}
/>
{c.status === "ok"
? `Connected · ${c.modelCount} model${c.modelCount === 1 ? "" : "s"} available`
: (c.error || "Could not load models")}
</div>
</div>
{c.status === "error" && (
<Button variant="ghost" size="sm" onClick={() => refresh(c.id)}>
Retry
</Button>
)}
{variant === "settings" && (
<Button variant="outline" size="sm" onClick={() => setManageId(c.id)}>
Manage
</Button>
)}
</div>
))}
</div>
<Button variant="outline" size="sm" onClick={() => setAddOpen(true)}>
<Plus className="size-4 mr-1" />
Add provider
</Button>
{/* Defer background tasks while chatting */}
{variant === "settings" && (
<div className="flex items-center justify-between gap-4 rounded-md border px-3 py-2.5">
<div className="min-w-0">
<div className="text-sm font-medium">Defer background tasks while chatting</div>
<div className="text-xs text-muted-foreground mt-0.5">
Background agents wait until no chat is running. Recommended for local models.
</div>
</div>
<Switch checked={deferBackgroundTasks} onCheckedChange={handleDeferToggle} />
</div>
)}
<AddProviderDialog
open={addOpen}
onOpenChange={setAddOpen}
connectedIds={cards.map((c) => c.id)}
isRowboatConnected={isRowboatConnected}
chatgptSignedIn={chatgpt.status.signedIn}
onChatGPTSignIn={chatgpt.signIn}
hadAssistant={selections.assistantModel !== null}
modelRecommendations={modelRecommendations}
/>
{manageCard && (
<ManageProviderDialog
card={manageCard}
usedBy={usedBy(manageCard.id)}
onClose={() => setManageId(null)}
onRefreshModels={async () => { await refresh(manageCard.id) }}
/>
)}
</div>
)
}
// ---------- Add provider ----------
type AddStep =
| { kind: "choose" }
| { kind: "creds"; flavor: ByokFlavor }
| { kind: "authwait"; which: "rowboat" | "chatgpt" }
| { kind: "loading"; flavor: ByokFlavor }
| { kind: "result"; name: string; first: boolean; pickedModel: string | null; modelCount: number | null }
| { kind: "error"; flavor: ByokFlavor; message: string }
function AddProviderDialog({ open, onOpenChange, connectedIds, isRowboatConnected, chatgptSignedIn, onChatGPTSignIn, hadAssistant, modelRecommendations }: {
open: boolean
onOpenChange: (open: boolean) => void
connectedIds: string[]
isRowboatConnected: boolean
chatgptSignedIn: boolean
onChatGPTSignIn: () => Promise<unknown> | void
hadAssistant: boolean
modelRecommendations: Record<string, string> | undefined
}) {
const [step, setStep] = useState<AddStep>({ kind: "choose" })
const [apiKey, setApiKey] = useState("")
const [baseURL, setBaseURL] = useState("")
const [manualModel, setManualModel] = useState("")
useEffect(() => {
if (open) {
setStep({ kind: "choose" })
setApiKey("")
setBaseURL("")
setManualModel("")
}
}, [open])
// Rowboat / ChatGPT sign-in completes out-of-band (browser); the shared
// store refreshes on the auth broadcasts, so the provider appearing in
// connectedIds is the completion signal.
useEffect(() => {
if (step.kind !== "authwait") return
const id = step.which === "rowboat" ? "rowboat" : "codex"
if (connectedIds.includes(id)) {
setStep({
kind: "result",
name: providerDisplayNames[id] || id,
// Both sign-ins seed the assistant when none was set (main-side
// initial selection) — first-provider copy applies to either.
first: !hadAssistant,
pickedModel: null,
modelCount: null,
})
}
}, [step, connectedIds, hadAssistant])
const chooseEntries = useMemo(() => {
const entries: Array<{ id: string; name: string; tagline: string; icon: React.ElementType | null; onChoose: () => void }> = []
if (!isRowboatConnected) {
entries.push({
id: "rowboat",
name: "Rowboat",
tagline: "Included with your plan",
icon: null,
onChoose: () => {
setStep({ kind: "authwait", which: "rowboat" })
void window.ipc.invoke("oauth:connect", { provider: "rowboat" }).catch(() => {
setStep({ kind: "choose" })
toast.error("Sign-in failed to start")
})
},
})
}
if (!chatgptSignedIn) {
entries.push({
id: "codex",
name: "ChatGPT",
tagline: "Use your Plus/Pro subscription",
icon: OpenAIIcon,
onChoose: () => {
setStep({ kind: "authwait", which: "chatgpt" })
void Promise.resolve(onChatGPTSignIn()).catch(() => {
setStep({ kind: "choose" })
toast.error("Sign-in failed to start")
})
},
})
}
for (const c of BYOK_CATALOG) {
if (connectedIds.includes(c.flavor)) continue
entries.push({
id: c.flavor,
name: c.name,
tagline: c.tagline,
icon: c.icon,
onChoose: () => {
setApiKey("")
setBaseURL(DEFAULT_BASE_URLS[c.flavor] ?? "")
setManualModel("")
setStep({ kind: "creds", flavor: c.flavor })
},
})
}
return entries
}, [isRowboatConnected, chatgptSignedIn, connectedIds, onChatGPTSignIn])
const connect = useCallback(async (flavor: ByokFlavor) => {
const meta = flavorMeta(flavor)
if (!meta) return
const key = apiKey.trim()
const url = baseURL.trim()
if (meta.needsKey && !meta.optionalKey && !key) {
toast.error("Enter an API key")
return
}
if (meta.needsEndpoint && !url) {
toast.error("Enter the endpoint URL")
return
}
setStep({ kind: "loading", flavor })
const providerEntry = {
flavor,
apiKey: key || undefined,
baseURL: url || undefined,
}
try {
const listRes = await window.ipc.invoke("models:listForProvider", { provider: providerEntry })
const list = listRes.success ? listRes.models ?? [] : []
const typed = manualModel.trim()
let model = typed
if (!model) {
const recommended = modelRecommendations?.[flavor]
model = recommended && list.includes(recommended) ? recommended : (list[0] ?? "")
}
if (!listRes.success && !model) {
setStep({ kind: "error", flavor, message: listRes.error || "Could not load the provider's model list." })
return
}
if (!model) {
setStep({ kind: "error", flavor, message: "The provider reported no models. Enter a model id manually and retry." })
return
}
const testRes = await window.ipc.invoke("models:test", { provider: providerEntry, model })
if (!testRes.success) {
setStep({ kind: "error", flavor, message: testRes.error || "Connection test failed" })
return
}
await window.ipc.invoke("models:setProvider", { id: flavor, provider: providerEntry })
// Initial selection only — a saved assistant is never replaced. The
// prop can be stale (a Rowboat sign-in moments ago seeds the
// assistant MAIN-side), so re-read the authoritative config at the
// moment of decision instead of trusting render-time state.
const cfgNow = await window.ipc.invoke("models:getConfig", null).catch(() => null)
const hasAssistantNow = cfgNow ? cfgNow.assistantModel !== null : hadAssistant
if (!hasAssistantNow) {
await window.ipc.invoke("models:updateConfig", { assistantModel: { provider: flavor, model } })
}
for (const warning of testRes.warnings ?? []) {
toast.warning(warning, { duration: 12000 })
}
window.dispatchEvent(new Event("models-config-changed"))
setStep({
kind: "result",
name: meta.name,
first: !hasAssistantNow,
pickedModel: !hasAssistantNow ? model : null,
modelCount: list.length > 0 ? list.length : null,
})
} catch {
setStep({ kind: "error", flavor, message: "Connection failed" })
}
}, [apiKey, baseURL, manualModel, modelRecommendations, hadAssistant])
const credsMeta = step.kind === "creds" || step.kind === "error" ? flavorMeta(step.flavor) : null
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{step.kind === "creds" && credsMeta ? `Connect ${credsMeta.name}` : "Add provider"}
</DialogTitle>
</DialogHeader>
{step.kind === "choose" && (
<div className="space-y-3">
<p className="text-xs text-muted-foreground">
Connect Rowboat, add your own API key, or run models locally. Each provider&apos;s models appear alongside the others in every picker.
</p>
<div className="grid grid-cols-2 gap-2">
{chooseEntries.map((e) => (
<button
key={e.id}
type="button"
onClick={e.onChoose}
className="rounded-md border px-3 py-2.5 text-left transition-colors hover:bg-accent"
>
<div className="flex items-center gap-2">
{e.icon && <e.icon className="size-4 shrink-0" />}
<span className="text-sm font-medium">{e.name}</span>
</div>
<div className="text-xs text-muted-foreground mt-0.5">{e.tagline}</div>
</button>
))}
</div>
</div>
)}
{step.kind === "creds" && credsMeta && (
<div className="space-y-3">
<button
type="button"
onClick={() => setStep({ kind: "choose" })}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="size-3.5" />
All providers
</button>
{credsMeta.needsKey && (
<div className="space-y-1.5">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
API key{credsMeta.optionalKey ? " (optional)" : ""}
</span>
<Input
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="Paste your API key"
/>
</div>
)}
{credsMeta.needsEndpoint && (
<div className="space-y-1.5">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Endpoint URL</span>
<Input
value={baseURL}
onChange={(e) => setBaseURL(e.target.value)}
placeholder={DEFAULT_BASE_URLS[credsMeta.flavor] ?? "https://api.example.com/v1"}
/>
</div>
)}
{credsMeta.manualModel && (
<div className="space-y-1.5">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Model id (optional)</span>
<Input
value={manualModel}
onChange={(e) => setManualModel(e.target.value)}
placeholder="Leave empty to auto-select"
/>
</div>
)}
<Button className="w-full" onClick={() => void connect(credsMeta.flavor)}>
Connect
</Button>
</div>
)}
{step.kind === "authwait" && (
<div className="flex flex-col items-center gap-3 py-6 text-center">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
<div className="text-sm font-medium">Complete sign-in in your browser</div>
<Button variant="ghost" size="sm" onClick={() => setStep({ kind: "choose" })}>
Cancel
</Button>
</div>
)}
{step.kind === "loading" && (
<div className="flex flex-col items-center gap-3 py-6 text-center">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
<div className="text-sm font-medium">Loading available models</div>
<div className="text-xs text-muted-foreground">Validating the connection and fetching the model list.</div>
</div>
)}
{step.kind === "result" && (
<div className="space-y-3">
<div className="flex items-center gap-2 text-sm font-medium text-green-600">
<CheckCircle2 className="size-4" />
{step.name} connected
</div>
{step.first && step.pickedModel && (
<p className="text-xs text-muted-foreground">
We selected <span className="font-medium text-foreground">{step.pickedModel}</span> as your Assistant model to get you started. You can change it any time above.
</p>
)}
{step.first && !step.pickedModel && (
<p className="text-xs text-muted-foreground">Your Assistant model has been set. You can change it any time above.</p>
)}
{!step.first && (
<p className="text-xs text-muted-foreground">
{step.modelCount ? `${step.modelCount} models are now available. ` : ""}Your Assistant model is unchanged pick a model from {step.name} for any task whenever you like.
</p>
)}
<div className="flex justify-end">
<Button onClick={() => onOpenChange(false)}>Done</Button>
</div>
</div>
)}
{step.kind === "error" && credsMeta && (
<div className="space-y-3">
<div className="text-sm font-medium text-destructive">Couldn&apos;t connect</div>
<p className="text-xs text-muted-foreground break-words">{step.message}</p>
{credsMeta.manualModel && (
<div className="space-y-1.5">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Model id</span>
<Input
value={manualModel}
onChange={(e) => setManualModel(e.target.value)}
placeholder="Enter a model id to connect anyway"
/>
</div>
)}
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setStep({ kind: "creds", flavor: step.flavor })}>
Review connection
</Button>
<Button onClick={() => void connect(step.flavor)}>Retry</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
)
}
// ---------- Manage provider ----------
function ManageProviderDialog({ card, usedBy, onClose, onRefreshModels }: {
card: { id: string; flavor: string; name: string; status: "ok" | "error"; error?: string; modelCount: number; meta?: ProviderMeta }
usedBy: Array<{ label: string; model: string }>
onClose: () => void
onRefreshModels: () => Promise<void>
}) {
const isAuthDerived = card.flavor === "rowboat" || card.flavor === "codex"
const meta = flavorMeta(card.flavor)
const chatgpt = useChatGPT()
const [replacingKey, setReplacingKey] = useState(false)
const [newKey, setNewKey] = useState("")
const [endpoint, setEndpoint] = useState(card.meta?.baseURL ?? "")
const [confirmDisconnect, setConfirmDisconnect] = useState(false)
const [refreshing, setRefreshing] = useState(false)
const handleRefreshModels = useCallback(async () => {
setRefreshing(true)
try {
await onRefreshModels()
toast.success("Models refreshed")
} finally {
setRefreshing(false)
}
}, [onRefreshModels])
const saveCredentials = useCallback(async (updates: { apiKey?: string; baseURL?: string }) => {
try {
await window.ipc.invoke("models:setProvider", {
id: card.id,
provider: {
flavor: card.flavor as ByokFlavor,
...(updates.apiKey !== undefined ? { apiKey: updates.apiKey } : {}),
...(updates.baseURL !== undefined ? { baseURL: updates.baseURL } : {}),
},
})
window.dispatchEvent(new Event("models-config-changed"))
toast.success("Provider updated")
setReplacingKey(false)
setNewKey("")
} catch {
toast.error("Failed to update provider")
}
}, [card.id, card.flavor])
const disconnect = useCallback(async () => {
try {
if (card.flavor === "rowboat") {
await window.ipc.invoke("oauth:disconnect", { provider: "rowboat" })
} else if (card.flavor === "codex") {
await chatgpt.signOut()
} else {
await window.ipc.invoke("models:removeProvider", { id: card.id })
}
window.dispatchEvent(new Event("models-config-changed"))
toast.success(`${card.name} disconnected`)
onClose()
} catch {
toast.error("Failed to disconnect")
}
}, [card, chatgpt, onClose])
const assistantAffected = usedBy.some((u) => u.label === "Assistant model")
return (
<Dialog open onOpenChange={(o) => { if (!o) onClose() }}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{card.name}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span className={cn("size-2 rounded-full shrink-0", card.status === "ok" ? "bg-green-500" : "bg-destructive")} />
{card.status === "ok"
? `Connected · ${card.modelCount} model${card.modelCount === 1 ? "" : "s"} available`
: (card.error || "Could not load models")}
</div>
{!isAuthDerived && meta?.needsKey && (
<div className="space-y-1.5">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">API key</span>
{replacingKey ? (
<div className="flex gap-2">
<Input
type="password"
value={newKey}
onChange={(e) => setNewKey(e.target.value)}
placeholder="Paste the new API key"
/>
<Button size="sm" disabled={!newKey.trim()} onClick={() => void saveCredentials({ apiKey: newKey.trim() })}>
Save
</Button>
</div>
) : (
<div className="flex items-center gap-2">
<Input readOnly value={card.meta?.hasApiKey ? "••••••••••••••••" : "No key saved"} className="font-mono text-xs" />
<Button variant="outline" size="sm" onClick={() => setReplacingKey(true)}>
Replace
</Button>
</div>
)}
</div>
)}
{!isAuthDerived && meta?.needsEndpoint && (
<div className="space-y-1.5">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Endpoint URL</span>
<div className="flex gap-2">
<Input value={endpoint} onChange={(e) => setEndpoint(e.target.value)} />
<Button
size="sm"
variant="outline"
disabled={!endpoint.trim() || endpoint.trim() === (card.meta?.baseURL ?? "")}
onClick={() => void saveCredentials({ baseURL: endpoint.trim() })}
>
Save
</Button>
</div>
</div>
)}
<div className="flex items-center justify-between gap-3 rounded-md border px-3 py-2.5">
<div className="text-xs text-muted-foreground">Refresh to pull the latest models from {card.name}.</div>
<Button variant="outline" size="sm" disabled={refreshing} onClick={() => void handleRefreshModels()}>
<RefreshCw className={cn("size-3.5 mr-1", refreshing && "animate-spin")} />
{refreshing ? "Refreshing…" : "Refresh models"}
</Button>
</div>
<div className="space-y-1.5">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Used by</span>
{usedBy.length === 0 ? (
<div className="text-xs text-muted-foreground">No model selections currently use this provider.</div>
) : (
<div className="space-y-1">
{usedBy.map((u) => (
<div key={u.label} className="flex items-center justify-between text-xs">
<span className="font-medium">{u.label}</span>
<span className="text-muted-foreground truncate ml-3">{u.model}</span>
</div>
))}
</div>
)}
</div>
<div className="border-t pt-3">
{!confirmDisconnect ? (
<div className="flex items-center justify-between gap-3">
<span className="text-xs text-muted-foreground">
{card.flavor === "rowboat"
? "Sign out of your Rowboat account."
: `Remove ${card.name} and its models from Rowboat.`}
</span>
<Button variant="outline" size="sm" className="text-destructive" onClick={() => setConfirmDisconnect(true)}>
Disconnect
</Button>
</div>
) : (
<div className="space-y-2">
<div className="text-sm font-medium">Disconnect {card.name}?</div>
<p className="text-xs text-muted-foreground">
{usedBy.length > 0
? `${usedBy.length} model selection${usedBy.length === 1 ? "" : "s"} use${usedBy.length === 1 ? "s" : ""} this provider. Task overrides will reset to the Assistant model${assistantAffected ? ", and you'll need to pick a new Assistant model" : ""}.`
: "Its models will no longer be available in Rowboat."}
</p>
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" onClick={() => setConfirmDisconnect(false)}>
Cancel
</Button>
<Button variant="destructive" size="sm" onClick={() => void disconnect()}>
Disconnect
</Button>
</div>
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
)
}

View file

@ -190,8 +190,12 @@ describe('useModels', () => {
const { result } = renderHook(() => useModels())
await waitFor(() => expect(result.current.groups.length).toBe(1))
act(() => result.current.refresh('ollama'))
await waitFor(() => expect(invokeCounts['models:list']).toBe(2))
// Promise-returning so callers (Manage's "Refresh models") can render
// progress and confirm completion.
await act(async () => {
await result.current.refresh('ollama')
})
expect(invokeCounts['models:list']).toBe(2)
expect(invokeArgs['models:list'][1]).toEqual({ refreshProvider: 'ollama' })
})
})

View file

@ -40,9 +40,10 @@ export interface UseModelsResult extends ModelsSnapshot {
/**
* Force a refetch now (e.g. a composer tab becoming active). With a
* provider id, that provider's cached list is dropped and refetched
* (the error-row Retry).
* (the error-row Retry, Manage's "Refresh models"). Resolves once the
* snapshot has been updated, so callers can render progress.
*/
refresh: (providerId?: string) => void
refresh: (providerId?: string) => Promise<void>
}
const EMPTY_SNAPSHOT: ModelsSnapshot = {
@ -116,13 +117,13 @@ async function buildSnapshot(refreshProvider?: string): Promise<ModelsSnapshot>
}
}
function startFetch(refreshProvider?: string): void {
function startFetch(refreshProvider?: string): Promise<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(refreshProvider)
return buildSnapshot(refreshProvider)
.then((next) => {
if (seq !== fetchSeq) return
snapshot = next
@ -138,19 +139,19 @@ function startFetch(refreshProvider?: string): void {
})
}
function refreshModels(providerId?: string): void {
startFetch(typeof providerId === 'string' ? providerId : undefined)
function refreshModels(providerId?: string): Promise<void> {
return startFetch(typeof providerId === 'string' ? providerId : undefined)
}
function ensureLoaded(): void {
if (!loaded && !fetching) startFetch()
if (!loaded && !fetching) void startFetch()
}
function wireGlobalEvents(): void {
if (wired) return
wired = true
// Event payloads must not leak into startFetch's refreshProvider arg.
const refetch = () => startFetch()
const refetch = () => void startFetch()
// Config edits anywhere in the app (settings dialog, composer pick,
// onboarding) announce themselves on this window event.
window.addEventListener('models-config-changed', refetch)

View file

@ -1,147 +0,0 @@
import { useCallback, useEffect, useRef, useState } from "react"
// Flavors the live model-list fetch (models:listForProvider) supports.
// "rowboat" (the signed-in gateway) is deliberately absent — its catalog
// comes from models:list, and core throws on the flavor.
export type ProviderModelsFlavor =
| "openai"
| "anthropic"
| "google"
| "openrouter"
| "aigateway"
| "ollama"
| "openai-compatible"
export type ProviderModelsStatus = "idle" | "loading" | "loaded" | "error"
export interface UseProviderModelsResult {
/** idle = credentials are insufficient to attempt a fetch. */
status: ProviderModelsStatus
models: string[]
error: string | null
/** Bypass the cache and fetch now (key-field blur / Retry). No-op while idle. */
refetch: () => void
}
const AIGATEWAY_DEFAULT_BASE_URL = "https://ai-gateway.vercel.sh/v1"
// The automatic fetch fires only once the credential inputs stop changing —
// never per keystroke, which would spray partial API keys at the provider.
const FETCH_DEBOUNCE_MS = 600
// Module-level so provider switches and dialog reopens don't refetch.
// Successful results only, keyed on `${flavor}|${apiKey}|${baseURL}`.
const listCache = new Map<string, string[]>()
// De-dupes concurrent requests for the same key (debounce firing + field blur).
const inFlight = new Map<string, Promise<string[]>>()
function credentialsSufficient(flavor: ProviderModelsFlavor, apiKey: string, baseURL: string): boolean {
if (flavor === "ollama" || flavor === "openai-compatible") return baseURL.length > 0
return apiKey.length > 0
}
function fetchProviderModels(
cacheKey: string,
provider: { flavor: ProviderModelsFlavor; apiKey?: string; baseURL?: string },
): Promise<string[]> {
const pending = inFlight.get(cacheKey)
if (pending) return pending
const request = window.ipc
.invoke("models:listForProvider", { provider })
.then((result) => {
if (!result.success) throw new Error(result.error || "Failed to list models")
const models = result.models ?? []
listCache.set(cacheKey, models)
return models
})
.finally(() => {
inFlight.delete(cacheKey)
})
inFlight.set(cacheKey, request)
return request
}
export function useProviderModels(input: {
flavor: ProviderModelsFlavor
apiKey: string
baseURL: string
}): UseProviderModelsResult {
const { flavor } = input
const apiKey = input.apiKey.trim()
const baseURL = input.baseURL.trim() || (flavor === "aigateway" ? AIGATEWAY_DEFAULT_BASE_URL : "")
const cacheKey = `${flavor}|${apiKey}|${baseURL}`
const sufficient = credentialsSufficient(flavor, apiKey, baseURL)
const [state, setState] = useState<{
key: string
status: ProviderModelsStatus
models: string[]
error: string | null
}>({ key: "", status: "idle", models: [], error: null })
// Bumped whenever the inputs change (and on unmount) so completions of
// superseded fetches never write state.
const epochRef = useRef(0)
const startFetch = useCallback(() => {
const epoch = ++epochRef.current
setState({ key: cacheKey, status: "loading", models: [], error: null })
fetchProviderModels(cacheKey, {
flavor,
apiKey: apiKey || undefined,
baseURL: baseURL || undefined,
})
.then((models) => {
if (epochRef.current !== epoch) return
setState({ key: cacheKey, status: "loaded", models, error: null })
})
.catch((err: unknown) => {
if (epochRef.current !== epoch) return
const message = err instanceof Error ? err.message : "Failed to list models"
setState({ key: cacheKey, status: "error", models: [], error: message })
})
}, [cacheKey, flavor, apiKey, baseURL])
useEffect(() => {
epochRef.current++
if (!sufficient) {
setState({ key: cacheKey, status: "idle", models: [], error: null })
return
}
const cached = listCache.get(cacheKey)
if (cached) {
setState({ key: cacheKey, status: "loaded", models: cached, error: null })
return
}
setState({ key: cacheKey, status: "loading", models: [], error: null })
const timer = setTimeout(() => {
// A blur-triggered refetch may have already filled the cache while the
// debounce was pending — don't fetch the same key twice.
const nowCached = listCache.get(cacheKey)
if (nowCached) {
setState({ key: cacheKey, status: "loaded", models: nowCached, error: null })
return
}
startFetch()
}, FETCH_DEBOUNCE_MS)
return () => clearTimeout(timer)
}, [cacheKey, sufficient, startFetch])
useEffect(() => () => {
epochRef.current++
}, [])
const refetch = useCallback(() => {
if (!sufficient) return
listCache.delete(cacheKey)
startFetch()
}, [sufficient, cacheKey, startFetch])
// State lags the inputs by one render (the effect above reconciles), so
// derive the answer for the *current* inputs — a provider switch must never
// flash the previous provider's list.
if (state.key !== cacheKey) {
const cached = sufficient ? listCache.get(cacheKey) : undefined
if (cached) return { status: "loaded", models: cached, error: null, refetch }
return { status: sufficient ? "loading" : "idle", models: [], error: null, refetch }
}
return { status: state.status, models: state.models, error: state.error, refetch }
}

View file

@ -29,7 +29,7 @@ export interface CatalogModelEntry {
export interface CatalogProviderEntry {
/**
* Provider INSTANCE identifier what ModelRef.provider, defaultSelection,
* Provider INSTANCE identifier what ModelRef.provider, assistantModel,
* task overrides, and refreshProvider all reference. Today one instance
* exists per flavor, so id always equals the flavor key ("openai",
* "ollama", "rowboat", ); a future multi-key setup ("openai-work" /

View file

@ -107,12 +107,18 @@ export async function getChatTitleModel(): Promise<ModelSelection> {
return getCategoryModel("chatTitle");
}
/**
* Model used by the background-task agent + routing classifier. Currently
* mirrors `getLiveNoteAgentModel()` both surfaces want a fast, reliable
* agent model. Split into its own getter so a future per-feature override
* doesn't require touching all call sites.
*/
/** Model used by the background-task agent + routing classifier. */
export async function getBackgroundTaskAgentModel(): Promise<ModelSelection> {
return getLiveNoteAgentModel();
return getCategoryModel("backgroundTask");
}
/**
* Explicit subagent model override, or null to inherit the PARENT turn's
* model (spawn-agent's default which is the assistant for a top-level
* chat). Not getCategoryModel: the no-override fallback is the parent, not
* the assistant, and the caller owns that resolution.
*/
export async function getSubagentModelOverride(): Promise<ModelSelection | null> {
const cfg = await readConfig();
return cfg?.taskModels?.subagent ?? null;
}

View file

@ -38,6 +38,8 @@ describe('migrateModelsConfig', () => {
taskModels: {
knowledgeGraph: { provider: 'rowboat', model: 'google/gemini-3.1-flash-lite' },
liveNoteAgent: { provider: 'rowboat', model: 'google/gemini-3.1-flash-lite' },
// v1 background tasks mirrored the live-note model.
backgroundTask: { provider: 'rowboat', model: 'google/gemini-3.1-flash-lite' },
autoPermissionDecision: { provider: 'rowboat', model: 'google/gemini-3.1-flash-lite' },
// chat titles used flash-lite because the assistant routes
// through the gateway; meeting notes used the curated default
@ -59,6 +61,7 @@ describe('migrateModelsConfig', () => {
expect(v2?.taskModels).toEqual({
knowledgeGraph: { provider: 'rowboat', model: 'google/gemini-3.1-flash-lite' },
liveNoteAgent: { provider: 'rowboat', model: 'google/gemini-3.1-flash-lite' },
backgroundTask: { provider: 'rowboat', model: 'google/gemini-3.1-flash-lite' },
autoPermissionDecision: { provider: 'rowboat', model: 'google/gemini-3.1-flash-lite' },
// Meeting notes used the curated gateway default, which now
// differs from the (BYOK) assistant — preserved explicitly.
@ -83,6 +86,18 @@ describe('migrateModelsConfig', () => {
});
});
it('a v1 live-note override propagates to backgroundTask (v1 bg tasks mirrored live-note)', () => {
const v1 = {
provider: { flavor: 'openai', apiKey: 'sk-a' },
model: 'gpt-5.4',
liveNoteAgentModel: { provider: 'ollama', model: 'qwen3' },
};
expect(migrateModelsConfig(v1, false)?.taskModels).toEqual({
liveNoteAgent: { provider: 'ollama', model: 'qwen3' },
backgroundTask: { provider: 'ollama', model: 'qwen3' },
});
});
it('an override equal to the assistant is dropped (inherit produces the same model)', () => {
const v1 = {
provider: { flavor: 'openai', apiKey: 'sk-a' },

View file

@ -131,11 +131,15 @@ export function migrateModelsConfig(rawInput: unknown, signedIn: boolean): V2 |
const assistantModel = v1EffectiveAssistant(raw, signedIn);
// Old effective model per task, via the deleted v1 rules.
const liveNoteEffective = v1Override(raw, "liveNoteAgentModel", signedIn)
?? (signedIn ? { provider: "rowboat", model: V1_CURATED_LITE } : assistantModel);
const oldTaskModels: Record<string, Ref | undefined> = {
knowledgeGraph: v1Override(raw, "knowledgeGraphModel", signedIn)
?? (signedIn ? { provider: "rowboat", model: V1_CURATED_LITE } : assistantModel),
liveNoteAgent: v1Override(raw, "liveNoteAgentModel", signedIn)
?? (signedIn ? { provider: "rowboat", model: V1_CURATED_LITE } : assistantModel),
liveNoteAgent: liveNoteEffective,
// v1 had no backgroundTask key — getBackgroundTaskAgentModel mirrored
// the live-note model (override included). v2 gives it its own slot.
backgroundTask: liveNoteEffective,
autoPermissionDecision: v1Override(raw, "autoPermissionDecisionModel", signedIn)
?? (signedIn ? { provider: "rowboat", model: V1_CURATED_LITE } : assistantModel),
meetingNotes: v1Override(raw, "meetingNotesModel", signedIn)

View file

@ -90,8 +90,20 @@ export class FSModelConfigRepo implements IModelConfigRepo {
}
async setProvider(id: string, provider: z.infer<typeof LlmProvider>): Promise<void> {
// The credential-less flavors are never stored: their connection IS
// their auth token store (oauth.json / chatgpt-auth.json), and the
// catalog derives their presence from auth state — a providers-map
// entry would double-list them.
if (provider.flavor === "rowboat" || provider.flavor === "codex") {
throw new Error(`Provider flavor '${provider.flavor}' is auth-derived and cannot be stored in models.json`);
}
const config = await this.read();
config.providers[id] = LlmProvider.parse(provider);
// Merge over an existing entry: replacing a key must not wipe
// hand-tuned connection prefs (contextLength, reasoningEffort).
config.providers[id] = LlmProvider.parse({
...config.providers[id],
...provider,
});
await this.write(config);
}

View file

@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { z } from "zod";
import type { TurnEvent, TurnState } from "@x/shared/dist/turns.js";
import type { ITurnRuntime } from "../turns/api.js";
@ -10,6 +10,16 @@ import type {
} from "./headless.js";
import { runSpawnedAgent } from "./spawn-agent.js";
// spawn-agent reads the configured subagent override lazily from
// models/defaults.js; stub it so tests control the whole precedence chain
// (explicit args > configured override > parent model).
const subagentOverride = vi.hoisted(() => ({
value: null as { provider: string; model: string } | null,
}));
vi.mock("../../models/defaults.js", () => ({
getSubagentModelOverride: async () => subagentOverride.value,
}));
const TS = "2026-07-07T10:00:00Z";
function parentCreated(
@ -95,6 +105,34 @@ function fakeServices(opts: {
const signal = new AbortController().signal;
describe("runSpawnedAgent", () => {
beforeEach(() => {
subagentOverride.value = null;
});
it("uses the configured subagent override when no explicit model is passed", async () => {
subagentOverride.value = { provider: "ollama", model: "qwen3" };
const { services, started } = fakeServices({});
await runSpawnedAgent(
{ task: "t", instructions: "x" },
{ parentTurnId: "parent-1", signal, services },
);
expect(started[0].agent).toMatchObject({
inline: { model: { provider: "ollama", model: "qwen3" } },
});
});
it("explicit per-spawn model args beat the configured override", async () => {
subagentOverride.value = { provider: "ollama", model: "qwen3" };
const { services, started } = fakeServices({});
await runSpawnedAgent(
{ task: "t", instructions: "x", model: "gpt-5.4", provider: "openai" },
{ parentTurnId: "parent-1", signal, services },
);
expect(started[0].agent).toMatchObject({
inline: { model: { provider: "openai", model: "gpt-5.4" } },
});
});
it("runs an inline child on the parent's model and returns the result envelope", async () => {
const { services, started } = fakeServices({});
const progress: unknown[] = [];

View file

@ -135,12 +135,15 @@ export async function runSpawnedAgent(
parentModel = undefined;
}
// Model precedence: explicit per-spawn args > the user's configured
// subagent override (taskModels.subagent) > the parent turn's model.
const subagentOverride = input.model ? null : await readSubagentOverride();
const model: z.infer<typeof ModelDescriptor> | undefined = input.model
? {
provider: input.provider ?? parentModel?.provider ?? "",
model: input.model,
}
: parentModel;
: subagentOverride ?? parentModel;
if (model && !model.provider) {
return spawnError(
"`model` was set but no provider could be determined; pass `provider` too",
@ -232,6 +235,21 @@ export async function runSpawnedAgent(
};
}
// Lazy for the same import-cycle reason as resolveServices; best-effort —
// an unreadable config means "no override", never a failed spawn.
async function readSubagentOverride(): Promise<
z.infer<typeof ModelDescriptor> | null
> {
try {
const { getSubagentModelOverride } = await import(
"../../models/defaults.js"
);
return await getSubagentModelOverride();
} catch {
return null;
}
}
async function resolveServices(): Promise<
NonNullable<SpawnedAgentCallbacks["services"]>
> {

View file

@ -656,7 +656,7 @@ const ipcSchemas = {
}).nullable(),
res: z.object({
providers: z.array(z.object({
// Provider INSTANCE id — what ModelRef.provider / defaultSelection /
// Provider INSTANCE id — what ModelRef.provider / assistantModel /
// refreshProvider reference. One instance per flavor today, so it
// always equals the flavor key; kept distinct so a future
// multi-instance setup ("openai-work") is additive.
@ -748,6 +748,33 @@ const ipcSchemas = {
success: z.literal(true),
}),
},
// Current model selections plus credential-FREE provider metadata (the
// renderer never needs keys to render pickers or provider cards). Null
// assistantModel = not configured yet.
'models:getConfig': {
req: z.null(),
res: z.object({
// Configured BYOK provider entries, secrets stripped: enough to
// render manage/edit surfaces (masked key indicator, endpoint).
providers: z.array(z.object({
id: z.string(),
flavor: z.string(),
baseURL: z.string().optional(),
hasApiKey: z.boolean(),
})),
assistantModel: ModelRef.nullable(),
taskModels: z.object({
knowledgeGraph: ModelRef.nullable(),
meetingNotes: ModelRef.nullable(),
liveNoteAgent: ModelRef.nullable(),
autoPermissionDecision: ModelRef.nullable(),
chatTitle: ModelRef.nullable(),
backgroundTask: ModelRef.nullable(),
subagent: ModelRef.nullable(),
}),
deferBackgroundTasks: z.boolean(),
}),
},
// Partial merge of model selections into models.json. Omitted keys are
// untouched; null clears a key (a cleared task override inherits the
// assistant model again). taskModels merges per-key.
@ -760,6 +787,8 @@ const ipcSchemas = {
liveNoteAgent: ModelRef.nullable().optional(),
autoPermissionDecision: ModelRef.nullable().optional(),
chatTitle: ModelRef.nullable().optional(),
backgroundTask: ModelRef.nullable().optional(),
subagent: ModelRef.nullable().optional(),
}).optional(),
deferBackgroundTasks: z.boolean().nullable().optional(),
}),

View file

@ -41,13 +41,17 @@ export const ModelRef = z.object({
model: z.string(),
});
// The per-task model override slots. Absence = inherit the assistant model.
// The per-task model override slots. Absence = inherit the assistant model
// (except `subagent`, whose default is the PARENT turn's model — which is
// the assistant for a top-level chat).
export const TaskModels = z.object({
knowledgeGraph: ModelRef.optional(),
meetingNotes: ModelRef.optional(),
liveNoteAgent: ModelRef.optional(),
autoPermissionDecision: ModelRef.optional(),
chatTitle: ModelRef.optional(),
backgroundTask: ModelRef.optional(),
subagent: ModelRef.optional(),
});
export type TaskModelKey = keyof z.infer<typeof TaskModels>;