From c695ddf1c15b2d189a09ac7f374ca30ae0de2b30 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:16:21 +0530 Subject: [PATCH] =?UTF-8?q?feat(x):=20one=20model-selection=20experience?= =?UTF-8?q?=20=E2=80=94=20unified=20settings,=20provider=20lifecycle,=20sp?= =?UTF-8?q?lit-view=20picker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/x/apps/main/src/ipc.ts | 24 + .../components/chat-input-with-mentions.tsx | 14 +- .../src/components/model-selector.test.tsx | 90 +- .../src/components/model-selector.tsx | 597 ++++++------ .../src/components/onboarding/index.tsx | 5 +- .../components/onboarding/step-indicator.tsx | 21 +- .../onboarding/steps/llm-setup-step.tsx | 308 +------ .../onboarding/use-onboarding-state.ts | 256 +----- .../src/components/settings-dialog.tsx | 867 +----------------- .../settings/model-selection-section.test.tsx | 90 ++ .../settings/model-selection-section.tsx | 169 ++++ .../components/settings/providers-section.tsx | 747 +++++++++++++++ .../renderer/src/hooks/use-models.test.tsx | 8 +- apps/x/apps/renderer/src/hooks/use-models.ts | 17 +- .../renderer/src/hooks/use-provider-models.ts | 147 --- apps/x/packages/core/src/models/catalog.ts | 2 +- apps/x/packages/core/src/models/defaults.ts | 20 +- .../packages/core/src/models/migrate.test.ts | 15 + apps/x/packages/core/src/models/migrate.ts | 8 +- apps/x/packages/core/src/models/repo.ts | 14 +- .../src/runtime/assembly/spawn-agent.test.ts | 40 +- .../core/src/runtime/assembly/spawn-agent.ts | 20 +- apps/x/packages/shared/src/ipc.ts | 31 +- apps/x/packages/shared/src/models.ts | 6 +- 24 files changed, 1599 insertions(+), 1917 deletions(-) create mode 100644 apps/x/apps/renderer/src/components/settings/model-selection-section.test.tsx create mode 100644 apps/x/apps/renderer/src/components/settings/model-selection-section.tsx create mode 100644 apps/x/apps/renderer/src/components/settings/providers-section.tsx delete mode 100644 apps/x/apps/renderer/src/hooks/use-provider-models.ts diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 1007f93d..cd8e586f 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -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('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('modelConfigRepo'); await repo.setProvider(args.id, args.provider); diff --git a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx index de7d7004..464648b6 100644 --- a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx +++ b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx @@ -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 diff --git a/apps/x/apps/renderer/src/components/model-selector.test.tsx b/apps/x/apps/renderer/src/components/model-selector.test.tsx index 7fea033f..9381eb99 100644 --- a/apps/x/apps/renderer/src/components/model-selector.test.tsx +++ b/apps/x/apps/renderer/src/components/model-selector.test.tsx @@ -34,11 +34,8 @@ function serveTwoProviders(): void { } async function openMenu(): Promise { - 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() + 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( - , + {}} 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( {}} 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' }) diff --git a/apps/x/apps/renderer/src/components/model-selector.tsx b/apps/x/apps/renderer/src/components/model-selector.tsx index 768765b2..cb63dd7a 100644 --- a/apps/x/apps/renderer/src/components/model-selector.tsx +++ b/apps/x/apps/renderer/src/components/model-selector.tsx @@ -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 = { +export const providerDisplayNames: Record = { 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 ( - <> - {label} - {visible.map((m) => { - const key = `${flavor}/${m}` - return ( - - {m} - - ) - })} - {status === 'loading' && ( -
- - Loading models… -
- )} - {status === 'error' && ( - { - e.preventDefault() - refetch() - }} - className="text-xs" - > - {error || 'Failed to load models'} - Retry - - )} - - ) -} - // 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 ""` 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(() => { - 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(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(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(() => { - 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 ( + select({ provider: providerId, model })} + > + + {model} + {secondary && {secondary}} + + ) + } + + const renderSentinelItem = () => sentinel && ( + select(null)}> + + {sentinel.label} + + ) + + const renderErrorItem = (g: ModelPickerGroup) => ( + refresh(g.id)} + className="text-xs" + > + {g.error || 'Failed to load models'} + Retry + + ) + return ( <> {reasoningAvailable && onEffortChange && ( @@ -390,19 +377,14 @@ export function ModelSelector({ ) : ( - { - // 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) - } - }} - > - + // 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). + + {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({ )} - - + - {!staticOptions && !probeActive && groups.length === 0 && !standaloneDefault && !sentinel && !allowCustom ? ( -
- Connect a provider in Settings -
- ) : ( - <> - {/* 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. */} -
- 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" - /> -
-
- - {sentinel && ( - - {sentinel.label} - - )} - {staticVisible?.map((o) => ( - - {o.label ?? o.id} - {o.label && o.label !== o.id && ( - {o.id} - )} - - ))} - {!staticOptions && standaloneDefault && standaloneVisible && ( - - {standaloneDefault.model} - - {providerDisplayNames[standaloneDefault.provider] || standaloneDefault.provider} - - - )} - {!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 ( - - - {label} - - {visibleModels.map((m) => { - const key = `${g.id}/${m}` - return ( - - {m} - - ) - })} - {showError && ( - { - e.preventDefault() - refresh(g.id) - }} - className="text-xs" - > - {g.error || 'Failed to load models'} - Retry - - )} - - ) - })} - {probeActive && liveFlavor && ( - - )} - {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. - onChange(parseCustomModel(modelFilter.trim(), providerFilter, defaultModel))} - > - Use "{modelFilter.trim()}" - - ) : ( -
No models match
- ) - )} -
-
- + className={cn( + 'p-0 overflow-hidden', + splitMode + ? 'w-[480px]' + : variant === 'field' + ? 'w-[var(--radix-popover-trigger-width)] min-w-[300px]' + : 'w-[320px]', )} -
-
+ > + {!staticOptions && groups.length === 0 && !standaloneDefault && !sentinel && !allowCustom ? ( +
Connect a provider in Settings
+ ) : ( + { + // 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]) + }} + > + + {splitMode && activeGroup ? ( +
+ {/* Provider column — tab-like: click or ←/→. */} +
+ {groups.map((g) => ( + + ))} +
+ + + {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 && ( +
No models reported
+ )} +
+
+
+ ) : ( + + {sentinel && !queryValue && ( + {renderSentinelItem()} + )} + {staticVisible && staticVisible.length > 0 && ( + + {staticVisible.map((o) => ( + select({ provider: '', model: o.id })}> + + {o.label ?? o.id} + {o.label && o.label !== o.id && ( + {o.id} + )} + + ))} + + )} + {!staticOptions && standaloneDefault && standaloneVisible && ( + + {renderModelItem( + standaloneDefault.provider, + standaloneDefault.model, + providerDisplayNames[standaloneDefault.provider] || standaloneDefault.provider, + )} + + )} + {!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 ( + + {visibleModels.map((m) => renderModelItem(g.id, m))} + {showError && renderErrorItem(g)} + + ) + })} + {queryValue && !anyModelRowVisible && ( + allowCustom ? ( + // Escape hatch for ids the lists don't carry (local + // servers, brand-new models): select exactly what was + // typed. + + select(parseCustomModel(query.trim(), providerFilter, defaultModel))} + > + Use "{query.trim()}" + + + ) : ( +
No models match
+ ) + )} +
+ )} +
+ )} + + )} ) diff --git a/apps/x/apps/renderer/src/components/onboarding/index.tsx b/apps/x/apps/renderer/src/components/onboarding/index.tsx index ae83e4fa..38c9c519 100644 --- a/apps/x/apps/renderer/src/components/onboarding/index.tsx +++ b/apps/x/apps/renderer/src/components/onboarding/index.tsx @@ -62,10 +62,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { onEscapeKeyDown={(e) => e.preventDefault()} >
- + s.step === currentStep) return ( diff --git a/apps/x/apps/renderer/src/components/onboarding/steps/llm-setup-step.tsx b/apps/x/apps/renderer/src/components/onboarding/steps/llm-setup-step.tsx index fa7ee93c..898fbc84 100644 --- a/apps/x/apps/renderer/src/components/onboarding/steps/llm-setup-step.tsx +++ b/apps/x/apps/renderer/src/components/onboarding/steps/llm-setup-step.tsx @@ -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: }, - { id: "anthropic", name: "Anthropic", description: "Claude models", color: "bg-orange-500/10 text-orange-600 dark:text-orange-400", icon: }, - { id: "google", name: "Gemini", description: "Google AI Studio", color: "bg-blue-500/10 text-blue-600 dark:text-blue-400", icon: }, - { id: "ollama", name: "Ollama", description: "Local models", color: "bg-purple-500/10 text-purple-600 dark:text-purple-400", icon: }, -] - -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: }, - { id: "aigateway", name: "AI Gateway", description: "Vercel AI Gateway", color: "bg-sky-500/10 text-sky-600 dark:text-sky-400", icon: }, - { id: "openai-compatible", name: "OpenAI-Compatible", description: "Custom endpoint", color: "bg-gray-500/10 text-gray-600 dark:text-gray-400", icon: }, -] - +// 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 ( - { - 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" - )} - > -
-
- {provider.icon} -
-
-
{provider.name}
-
{provider.description}
-
- {connectedFlavors.has(provider.id) && ( - - )} -
-
- ) - } + const { handleNext, handleBack } = state + const { defaultModel, isRowboatConnected } = useModels() + const hasAssistant = defaultModel !== null return (
{/* Title */}

- Choose your provider + {isRowboatConnected ? "Add more providers" : "Connect a model provider"}

- 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."}

- {/* Inline Rowboat upsell callout */} - {!upsellDismissed && ( - - -
-

- Tip: 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. -

- -
- -
- )} - - {/* Provider selection */} -
- Provider -
- {primaryProviders.map((p, i) => renderProviderCard(p, i))} -
- {(showMoreProviders || isMoreProvider) ? ( -
- {moreProviders.map((p, i) => renderProviderCard(p, i + 4))} -
- ) : ( - - )} -
- - {/* Separator */} -
- - {/* Provider configuration */} -
- {/* 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 && ( -
- - {modelsLoading ? ( -
- - Loading... -
- ) : ( - updateProviderConfig(llmProvider, { model: e.target.value })} - placeholder="Model ID (leave empty to auto-select)" - /> - )} - {modelsError && ( -
{modelsError}
- )} -
- )} - - {showApiKey && ( -
- - updateProviderConfig(llmProvider, { apiKey: e.target.value })} - placeholder="Paste your API key" - className="font-mono" - /> -
- )} - - {/* 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" && ( -
- - {chatgpt.status.signedIn ? ( -
-
- - - Connected as {chatgpt.status.email ?? "your ChatGPT account"} - -
- -
- ) : chatgpt.isSigningIn ? ( -
-
- - Waiting for browser… -
- -
- ) : ( -
- - Use your ChatGPT Plus/Pro subscription - - -
- )} -
- )} - - {showBaseURL && ( -
- - 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" - /> -
- )} -
+ {/* Footer */}
@@ -270,44 +39,9 @@ export function LlmSetupStep({ state }: LlmSetupStepProps) { Back - -
- {testState.status === "success" && ( - - - Connected - - )} - {testState.status === "error" && ( - - {testState.error} - - )} - - -
+
) diff --git a/apps/x/apps/renderer/src/components/onboarding/use-onboarding-state.ts b/apps/x/apps/renderer/src/components/onboarding/use-onboarding-state.ts index e95427b8..ce3f9dac 100644 --- a/apps/x/apps/renderer/src/components/onboarding/use-onboarding-state.ts +++ b/apps/x/apps/renderer/src/components/onboarding/use-onboarding-state.ts @@ -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(0) const [onboardingPath, setOnboardingPath] = useState(null) - // LLM setup state - const [llmProvider, setLlmProvider] = useState("openai") - const [modelsCatalog, setModelsCatalog] = useState>({}) - const [modelsLoading, setModelsLoading] = useState(false) - const [modelsError, setModelsError] = useState(null) - const [providerConfigs, setProviderConfigs] = useState>({ - 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>(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([]) 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(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 = {} - 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> = { - 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 => { - 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, } } diff --git a/apps/x/apps/renderer/src/components/settings-dialog.tsx b/apps/x/apps/renderer/src/components/settings-dialog.tsx index 927aaff1..c09496ee 100644 --- a/apps/x/apps/renderer/src/components/settings-dialog.tsx +++ b/apps/x/apps/renderer/src/components/settings-dialog.tsx @@ -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> = { - openai: "gpt-5.4", - anthropic: "claude-opus-4-8", -} - -const defaultBaseURLs: Partial> = { - 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("openai") - const [defaultProvider, setDefaultProvider] = useState(null) - // Flavors present in the saved providers map — drives each card's - // "Connected" indicator, independent of which card is active. - const [savedProviders, setSavedProviders] = useState>(new Set()) - const [providerConfigs, setProviderConfigs] = useState>({ - 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) => { - 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([...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 - 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 ( - - ) - } - - if (configLoading) { - return ( -
- - Loading... -
- ) - } - - return ( -
- {/* Provider selection */} -
- Provider -
- {primaryProviders.map(renderProviderCard)} -
- {(showMoreProviders || isMoreProvider) ? ( -
- {moreProviders.map(renderProviderCard)} -
- ) : ( - - )} -
- - {/* API Key — key-first: the model list is fetched from it */} - {showApiKey && ( -
- - {provider === "openai-compatible" ? "API Key (optional)" : "API Key"} - - updateConfig(provider, { apiKey: e.target.value })} - onBlur={() => providerModels.refetch()} - placeholder="Paste your API key" - /> -
- )} - - {/* 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" && ( -
- - ChatGPT Subscription - - {chatgpt.status.signedIn ? ( -
-
- - - Connected as {chatgpt.status.email ?? "your ChatGPT account"} - -
- -
- ) : chatgpt.isSigningIn ? ( -
-
- - Waiting for browser… -
- -
- ) : ( -
- - Use your ChatGPT Plus/Pro subscription - - -
- )} -
- )} - - {/* Base URL */} - {showBaseURL && ( -
- Base URL - 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" - } - /> -
- )} - - {/* Connection status — the model itself is resolved silently on save */} -
- {providerModels.status === "idle" ? ( -
- {isLocalProvider - ? "Enter your base URL to connect" - : "Enter your API key to connect"} -
- ) : providerModels.status === "loading" ? ( -
- - Checking connection… -
- ) : providerModels.status === "error" ? ( -
-
- {providerModels.error || "Connection check failed"} -
- - {provider !== "openai-compatible" && ( - setPrimaryModel(provider, e.target.value)} - placeholder="Enter a model to connect anyway" - /> - )} -
- ) : providerModels.models.length === 0 && provider !== "openai-compatible" ? ( -
-
- Connected, but the provider reported no models — enter one manually -
- setPrimaryModel(provider, e.target.value)} - placeholder="Enter model" - /> -
- ) : ( -
- - Connected · {providerModels.models.length} model{providerModels.models.length === 1 ? "" : "s"} available -
- )} - {savedProviders.has(provider) && ( - - )} -
- - {/* 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. */} -
- - {rowboatConnected ? "Model" : "Assistant model"} - - setPrimaryModel(provider, ref ? ref.model : "")} - /> -
- - {/* 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). */} -
- {!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 }) => ( -
- {label} - updateConfig(provider, { [field]: ref ? ref.model : "" })} - /> -
- ))} - )} -
- - {/* Test status */} - {testState.status === "error" && ( -
- {testState.error || "Connection test failed"} -
- )} - {testState.status === "success" && ( -
- - Connected and saved -
- )} - - {/* Defer background tasks while chatting */} -
-
-
Defer background tasks while chatting
-
- Background agents (knowledge sync, live notes, tasks) wait until your chat finishes. Recommended for local models. -
-
- -
- - {/* Test & Save button */} - -
- ) -} - -// --- 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(null) - const [selectedKg, setSelectedKg] = useState(null) - const [selectedLiveNote, setSelectedLiveNote] = useState(null) - const [selectedAutoPermission, setSelectedAutoPermission] = useState(null) - const [loading, setLoading] = useState(true) - const [saving, setSaving] = useState(false) - - useEffect(() => { - if (!dialogOpen) return - - async function load() { - setLoading(true) - try { - let parsed: Record = {} - 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 - 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, - ) => ( -
- - -
- ) - - if (loading) { - return ( -
- -
- ) - } - - return ( -
-

- Select the models Rowboat uses. Rowboat models are provided through your account; models from your own providers route through your keys or local runtimes. -

- - {renderModelField("Assistant model", selectedDefault, setSelectedDefault)} - {renderModelField("Knowledge graph model", selectedKg, setSelectedKg)} - {renderModelField("Background agents model", selectedLiveNote, setSelectedLiveNote)} - {renderModelField("Permission checks model", selectedAutoPermission, setSelectedAutoPermission)} - - {/* Save */} - -
- ) -} - // --- Note Tagging Settings --- interface TagDef { @@ -2719,8 +1893,8 @@ export function SettingsDialog({ children, defaultTab = "account", open: control

{activeTabConfig.label}

- {activeTab === "models" && rowboatConnected - ? "Select your default models" + {activeTab === "models" + ? "Choose the models Rowboat uses for chat and background work." : activeTabConfig.description}

@@ -2747,21 +1921,22 @@ export function SettingsDialog({ children, defaultTab = "account", open: control ) : activeTab === "mobile" ? ( ) : activeTab === "models" ? ( - rowboatConnected - ? ( -
- - -
-

Your own providers

-

- 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. -

- -
-
- ) - : + // ONE model-selection surface for signed-in and BYOK alike: + // the Assistant model + per-task overrides, then provider + // (credential) management below. +
+ + +
+

{rowboatConnected ? "Your own providers" : "Providers"}

+

+ {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."} +

+ +
+
) : activeTab === "note-tagging" ? ( ) : activeTab === "appearance" ? ( diff --git a/apps/x/apps/renderer/src/components/settings/model-selection-section.test.tsx b/apps/x/apps/renderer/src/components/settings/model-selection-section.test.tsx new file mode 100644 index 00000000..065bf18e --- /dev/null +++ b/apps/x/apps/renderer/src/components/settings/model-selection-section.test.tsx @@ -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 Promise> = {} +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 +}): 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() + + // 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() + + 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()) + }) +}) diff --git a/apps/x/apps/renderer/src/components/settings/model-selection-section.tsx b/apps/x/apps/renderer/src/components/settings/model-selection-section.tsx new file mode 100644 index 00000000..627093a8 --- /dev/null +++ b/apps/x/apps/renderer/src/components/settings/model-selection-section.tsx @@ -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>>({}) + + // 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 ( +
+ {/* Assistant model — the one required primary selection. */} +
+
+

+ Assistant model + {assistantUnavailable && ( + + Unavailable + + )} +

+

+ Used for chat and for any task without its own model selection. +

+
+ + {assistantUnavailable && defaultModel && ( +

+ This model is no longer listed by {providerDisplayNames[defaultModel.provider] || defaultModel.provider}. Choose another model to continue. +

+ )} +
+ + {/* Per-task overrides — inherit the assistant unless picked. */} +
+
+

Models for other tasks

+

+ These tasks use the Assistant model unless you choose a different one. +

+
+
+ {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 ( +
+
+ {label} + {override && ( + + )} +
+ void setTask(key, ref)} + triggerTitle={label} + /> +

+ {override ? "Uses a different model from the Assistant" : inheritText} +

+

{description}

+
+ ) + })} +
+
+
+ ) +} diff --git a/apps/x/apps/renderer/src/components/settings/providers-section.tsx b/apps/x/apps/renderer/src/components/settings/providers-section.tsx new file mode 100644 index 00000000..508df12f --- /dev/null +++ b/apps/x/apps/renderer/src/components/settings/providers-section.tsx @@ -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 +} + +const TASK_LABELS: Record = { + 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> = { + 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([]) + const [selections, setSelections] = useState({ assistantModel: null, taskModels: {} }) + const [deferBackgroundTasks, setDeferBackgroundTasks] = useState(false) + const [addOpen, setAddOpen] = useState(false) + const [manageId, setManageId] = useState(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 ( +
+
+ {cards.length === 0 && ( +
+ Connect Rowboat, use your own API key, or choose a local provider to start using the Assistant. +
+ )} + {cards.map((c) => ( +
+
+
+ {c.name} +
+
+ + {c.status === "ok" + ? `Connected · ${c.modelCount} model${c.modelCount === 1 ? "" : "s"} available` + : (c.error || "Could not load models")} +
+
+ {c.status === "error" && ( + + )} + {variant === "settings" && ( + + )} +
+ ))} +
+ + + + {/* Defer background tasks while chatting */} + {variant === "settings" && ( +
+
+
Defer background tasks while chatting
+
+ Background agents wait until no chat is running. Recommended for local models. +
+
+ +
+ )} + + c.id)} + isRowboatConnected={isRowboatConnected} + chatgptSignedIn={chatgpt.status.signedIn} + onChatGPTSignIn={chatgpt.signIn} + hadAssistant={selections.assistantModel !== null} + modelRecommendations={modelRecommendations} + /> + + {manageCard && ( + setManageId(null)} + onRefreshModels={async () => { await refresh(manageCard.id) }} + /> + )} +
+ ) +} + +// ---------- 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 | void + hadAssistant: boolean + modelRecommendations: Record | undefined +}) { + const [step, setStep] = useState({ 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 ( + + + + + {step.kind === "creds" && credsMeta ? `Connect ${credsMeta.name}` : "Add provider"} + + + + {step.kind === "choose" && ( +
+

+ Connect Rowboat, add your own API key, or run models locally. Each provider's models appear alongside the others in every picker. +

+
+ {chooseEntries.map((e) => ( + + ))} +
+
+ )} + + {step.kind === "creds" && credsMeta && ( +
+ + {credsMeta.needsKey && ( +
+ + API key{credsMeta.optionalKey ? " (optional)" : ""} + + setApiKey(e.target.value)} + placeholder="Paste your API key" + /> +
+ )} + {credsMeta.needsEndpoint && ( +
+ Endpoint URL + setBaseURL(e.target.value)} + placeholder={DEFAULT_BASE_URLS[credsMeta.flavor] ?? "https://api.example.com/v1"} + /> +
+ )} + {credsMeta.manualModel && ( +
+ Model id (optional) + setManualModel(e.target.value)} + placeholder="Leave empty to auto-select" + /> +
+ )} + +
+ )} + + {step.kind === "authwait" && ( +
+ +
Complete sign-in in your browser…
+ +
+ )} + + {step.kind === "loading" && ( +
+ +
Loading available models…
+
Validating the connection and fetching the model list.
+
+ )} + + {step.kind === "result" && ( +
+
+ + {step.name} connected +
+ {step.first && step.pickedModel && ( +

+ We selected {step.pickedModel} as your Assistant model to get you started. You can change it any time above. +

+ )} + {step.first && !step.pickedModel && ( +

Your Assistant model has been set. You can change it any time above.

+ )} + {!step.first && ( +

+ {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. +

+ )} +
+ +
+
+ )} + + {step.kind === "error" && credsMeta && ( +
+
Couldn't connect
+

{step.message}

+ {credsMeta.manualModel && ( +
+ Model id + setManualModel(e.target.value)} + placeholder="Enter a model id to connect anyway" + /> +
+ )} +
+ + +
+
+ )} +
+
+ ) +} + +// ---------- 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 +}) { + 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 ( + { if (!o) onClose() }}> + + + {card.name} + +
+
+ + {card.status === "ok" + ? `Connected · ${card.modelCount} model${card.modelCount === 1 ? "" : "s"} available` + : (card.error || "Could not load models")} +
+ + {!isAuthDerived && meta?.needsKey && ( +
+ API key + {replacingKey ? ( +
+ setNewKey(e.target.value)} + placeholder="Paste the new API key" + /> + +
+ ) : ( +
+ + +
+ )} +
+ )} + + {!isAuthDerived && meta?.needsEndpoint && ( +
+ Endpoint URL +
+ setEndpoint(e.target.value)} /> + +
+
+ )} + +
+
Refresh to pull the latest models from {card.name}.
+ +
+ +
+ Used by + {usedBy.length === 0 ? ( +
No model selections currently use this provider.
+ ) : ( +
+ {usedBy.map((u) => ( +
+ {u.label} + {u.model} +
+ ))} +
+ )} +
+ +
+ {!confirmDisconnect ? ( +
+ + {card.flavor === "rowboat" + ? "Sign out of your Rowboat account." + : `Remove ${card.name} and its models from Rowboat.`} + + +
+ ) : ( +
+
Disconnect {card.name}?
+

+ {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."} +

+
+ + +
+
+ )} +
+
+
+
+ ) +} diff --git a/apps/x/apps/renderer/src/hooks/use-models.test.tsx b/apps/x/apps/renderer/src/hooks/use-models.test.tsx index f3b6b2ff..03670c1e 100644 --- a/apps/x/apps/renderer/src/hooks/use-models.test.tsx +++ b/apps/x/apps/renderer/src/hooks/use-models.test.tsx @@ -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' }) }) }) diff --git a/apps/x/apps/renderer/src/hooks/use-models.ts b/apps/x/apps/renderer/src/hooks/use-models.ts index e29238cf..6728cb10 100644 --- a/apps/x/apps/renderer/src/hooks/use-models.ts +++ b/apps/x/apps/renderer/src/hooks/use-models.ts @@ -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 } const EMPTY_SNAPSHOT: ModelsSnapshot = { @@ -116,13 +117,13 @@ async function buildSnapshot(refreshProvider?: string): Promise } } -function startFetch(refreshProvider?: string): void { +function startFetch(refreshProvider?: string): Promise { // 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 { + 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) diff --git a/apps/x/apps/renderer/src/hooks/use-provider-models.ts b/apps/x/apps/renderer/src/hooks/use-provider-models.ts deleted file mode 100644 index 4b737442..00000000 --- a/apps/x/apps/renderer/src/hooks/use-provider-models.ts +++ /dev/null @@ -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() -// De-dupes concurrent requests for the same key (debounce firing + field blur). -const inFlight = new Map>() - -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 { - 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 } -} diff --git a/apps/x/packages/core/src/models/catalog.ts b/apps/x/packages/core/src/models/catalog.ts index 1f1ecea4..7f61258f 100644 --- a/apps/x/packages/core/src/models/catalog.ts +++ b/apps/x/packages/core/src/models/catalog.ts @@ -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" / diff --git a/apps/x/packages/core/src/models/defaults.ts b/apps/x/packages/core/src/models/defaults.ts index 2b35a541..c2b97f53 100644 --- a/apps/x/packages/core/src/models/defaults.ts +++ b/apps/x/packages/core/src/models/defaults.ts @@ -107,12 +107,18 @@ export async function getChatTitleModel(): Promise { 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 { - 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 { + const cfg = await readConfig(); + return cfg?.taskModels?.subagent ?? null; } diff --git a/apps/x/packages/core/src/models/migrate.test.ts b/apps/x/packages/core/src/models/migrate.test.ts index 9c97e1e1..a8a8dce4 100644 --- a/apps/x/packages/core/src/models/migrate.test.ts +++ b/apps/x/packages/core/src/models/migrate.test.ts @@ -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' }, diff --git a/apps/x/packages/core/src/models/migrate.ts b/apps/x/packages/core/src/models/migrate.ts index 99d19e8f..2a03a7ef 100644 --- a/apps/x/packages/core/src/models/migrate.ts +++ b/apps/x/packages/core/src/models/migrate.ts @@ -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 = { 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) diff --git a/apps/x/packages/core/src/models/repo.ts b/apps/x/packages/core/src/models/repo.ts index a3c200c2..6936c447 100644 --- a/apps/x/packages/core/src/models/repo.ts +++ b/apps/x/packages/core/src/models/repo.ts @@ -90,8 +90,20 @@ export class FSModelConfigRepo implements IModelConfigRepo { } async setProvider(id: string, provider: z.infer): Promise { + // 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); } diff --git a/apps/x/packages/core/src/runtime/assembly/spawn-agent.test.ts b/apps/x/packages/core/src/runtime/assembly/spawn-agent.test.ts index 7b61abd4..194d71e2 100644 --- a/apps/x/packages/core/src/runtime/assembly/spawn-agent.test.ts +++ b/apps/x/packages/core/src/runtime/assembly/spawn-agent.test.ts @@ -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[] = []; diff --git a/apps/x/packages/core/src/runtime/assembly/spawn-agent.ts b/apps/x/packages/core/src/runtime/assembly/spawn-agent.ts index 2b7ad77d..24b25b4b 100644 --- a/apps/x/packages/core/src/runtime/assembly/spawn-agent.ts +++ b/apps/x/packages/core/src/runtime/assembly/spawn-agent.ts @@ -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 | 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 | null +> { + try { + const { getSubagentModelOverride } = await import( + "../../models/defaults.js" + ); + return await getSubagentModelOverride(); + } catch { + return null; + } +} + async function resolveServices(): Promise< NonNullable > { diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 7baa6285..5661a75f 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -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(), }), diff --git a/apps/x/packages/shared/src/models.ts b/apps/x/packages/shared/src/models.ts index e376bee4..9fa56f5a 100644 --- a/apps/x/packages/shared/src/models.ts +++ b/apps/x/packages/shared/src/models.ts @@ -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;