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 15ddcd48..c4fda45d 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 @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { ArrowUp, @@ -38,6 +38,7 @@ import { DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, + DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSub, @@ -45,6 +46,7 @@ import { DropdownMenuSubTrigger, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' +import { useProviderModels, type ProviderModelsFlavor } from '@/hooks/use-provider-models' import { type AttachmentIconKind, getAttachmentDisplayName, @@ -101,6 +103,61 @@ interface ConfiguredModel { model: string } +// One picker group per connected provider. Catalog groups carry a resolved +// model list (models:list / saved config); live groups carry credentials and +// fetch their list from the provider inside the dropdown via +// useProviderModels (models:listForProvider). +const LIVE_PICKER_FLAVORS = new Set(['openrouter', 'aigateway', 'ollama', 'openai-compatible']) + +type ModelPickerGroup = + | { kind: 'catalog'; flavor: string; models: string[] } + | { kind: 'live'; flavor: ProviderModelsFlavor; apiKey: string; baseURL: string; savedModel: string } + +// Rendered inside the dropdown's radio group: each live provider fetches its +// own list, so groups load and fail independently. Pinned models (the saved +// default / app default) render first — the model that actually runs is +// always pickable even while the fetch is pending or failed. Live-fetched +// ids carry no reasoning metadata, so the effort control stays hidden for +// them (reasoningByKey lookup misses default to off). +function LiveProviderGroupItems({ group, pinnedModels }: { group: Extract; pinnedModels: string[] }) { + const { status, models, error, refetch } = useProviderModels({ + flavor: group.flavor, + apiKey: group.apiKey, + baseURL: group.baseURL, + }) + const items = [...pinnedModels, ...models.filter((m) => !pinnedModels.includes(m))] + return ( + <> + {items.map((m) => { + const key = `${group.flavor}/${m}` + return ( + + {m} + + ) + })} + {status === 'loading' && ( +
+ + Loading models… +
+ )} + {status === 'error' && ( + { + e.preventDefault() + refetch() + }} + className="text-xs" + > + {error || 'Failed to load models'} + Retry + + )} + + ) +} + type RecentWorkDir = { path: string lastUsedAt: number @@ -318,7 +375,7 @@ function ChatInputInner({ const fileInputRef = useRef(null) const canSubmit = (Boolean(message.trim()) || attachments.length > 0) && !isProcessing - const [configuredModels, setConfiguredModels] = useState([]) + const [modelGroups, setModelGroups] = useState([]) const [activeModelKey, setActiveModelKey] = useState('') // The effective runtime default (what a run actually uses when the user // hasn't picked a model) — shown in the picker instead of guessing from @@ -438,19 +495,9 @@ function ChatInputInner({ if (loadModelConfigEpoch.current === epoch) setDefaultModel(null) } try { - const models: ConfiguredModel[] = [] - const seen = new Set() - const push = (provider: string, model: string) => { - if (!model) return - const key = `${provider}/${model}` - if (seen.has(key)) return - seen.add(key) - models.push({ provider: provider as ProviderName, model }) - } + const groups: ModelPickerGroup[] = [] - // Full catalog per provider (gateway + cloud). Providers with no - // catalog (Ollama, OpenAI-compatible) fall back to the models saved in - // config below. + // Full catalog per provider (gateway + models.dev cloud providers). const catalog: Record = {} const reasoningFlags: Record = {} try { @@ -463,55 +510,76 @@ function ChatInputInner({ } } } - } catch { /* offline / no catalog — fall back to saved config below */ } + } catch { /* offline / no catalog — groups fall back to saved config below */ } if (loadModelConfigEpoch.current === epoch) setReasoningByKey(reasoningFlags) - if (isRowboatConnected) { - for (const m of catalog['rowboat'] || []) push('rowboat', m) + if (isRowboatConnected && (catalog['rowboat'] || []).length > 0) { + groups.push({ kind: 'catalog', flavor: 'rowboat', models: catalog['rowboat'] }) } try { const result = await window.ipc.invoke('workspace:readFile', { path: 'config/models.json' }) const parsed = JSON.parse(result.data) - // List the default provider first so its default model leads the - // BYOK section of the picker. + // List the default provider's group first. const defaultFlavor = typeof parsed?.provider?.flavor === 'string' ? parsed.provider.flavor : '' const flavors = Object.keys(parsed?.providers || {}) .sort((a, b) => (a === defaultFlavor ? -1 : b === defaultFlavor ? 1 : 0)) for (const flavor of flavors) { const e = (parsed.providers[flavor] || {}) as Record - const hasKey = typeof e.apiKey === 'string' && (e.apiKey as string).trim().length > 0 - const hasBaseURL = typeof e.baseURL === 'string' && (e.baseURL as string).trim().length > 0 - if (!hasKey && !hasBaseURL) continue // provider not configured + const apiKey = typeof e.apiKey === 'string' ? e.apiKey.trim() : '' + const baseURL = typeof e.baseURL === 'string' ? e.baseURL.trim() : '' + if (!apiKey && !baseURL) continue // provider not configured + const savedModel = typeof e.model === 'string' ? e.model : '' - // The provider's saved default model leads, then the rest of its catalog. - push(flavor, typeof e.model === 'string' ? e.model : '') + // Live flavors fetch their list from the provider inside the + // dropdown, with the credentials saved in config. + if (LIVE_PICKER_FLAVORS.has(flavor)) { + groups.push({ kind: 'live', flavor: flavor as ProviderModelsFlavor, apiKey, baseURL, savedModel }) + continue + } + + // Catalog flavors: the saved default model leads, then the catalog. + // Saved models[] is the no-catalog fallback (models.dev cache + // empty, or signed in — the gateway list has no per-flavor catalogs). + const models: string[] = [] + const push = (model: string) => { + if (model && !models.includes(model)) models.push(model) + } + push(savedModel) const catalogModels = catalog[flavor] || [] if (catalogModels.length > 0) { - for (const m of catalogModels) push(flavor, m) + for (const m of catalogModels) push(m) } else { - // No catalog (local provider) — fall back to whatever is saved. const saved = Array.isArray(e.models) ? e.models as string[] : [] - for (const m of saved) push(flavor, m) + for (const m of saved) push(m) } + groups.push({ kind: 'catalog', flavor, models }) } - // The user's explicit default selection leads the whole picker. + // The user's explicit default selection leads the picker: its group + // first and, within a catalog group, the model itself first. (Live + // groups pin the default at the top themselves.) const sel = parsed?.defaultSelection if (sel && typeof sel.provider === 'string' && typeof sel.model === 'string') { - const selKey = `${sel.provider}/${sel.model}` - const index = models.findIndex((m) => `${m.provider}/${m.model}` === selKey) - if (index > 0) { - const [entry] = models.splice(index, 1) - models.unshift(entry) + const index = groups.findIndex((g) => g.flavor === sel.provider) + if (index >= 0) { + const [group] = groups.splice(index, 1) + groups.unshift(group) + if (group.kind === 'catalog') { + const mi = group.models.indexOf(sel.model) + if (mi > 0) { + group.models.splice(mi, 1) + group.models.unshift(sel.model) + } + } } } } catch { /* no BYOK config yet */ } if (loadModelConfigEpoch.current !== epoch) return - setConfiguredModels(models) + setModelGroups(groups) } catch (err) { // No config yet — but surface unexpected failures for diagnosis. console.error('[chat-input] failed to load model list', err) @@ -701,25 +769,36 @@ function ChatInputInner({ checkSearch() }, [isActive, isRowboatConnected]) - // The dropdown's items: always include the effective default so the picker - // is never empty (and never missing the model that actually runs) even - // while the full list is still loading. - const pickerModels = useMemo(() => { - if (!defaultModel) return configuredModels - const defaultKey = `${defaultModel.provider}/${defaultModel.model}` - if (configuredModels.some((m) => `${m.provider}/${m.model}` === defaultKey)) return configuredModels - return [defaultModel, ...configuredModels] - }, [configuredModels, defaultModel]) + // The effective default always renders even when no group carries it (the + // gateway list failed, or its provider was removed from config) — the + // picker must never be missing the model that actually runs. Live groups + // pin the default themselves, so a flavor match is enough there. + const standaloneDefault = useMemo(() => { + if (!defaultModel) return null + const covered = modelGroups.some((g) => + g.flavor === defaultModel.provider && + (g.kind === 'live' || g.models.includes(defaultModel.model))) + return covered ? null : defaultModel + }, [modelGroups, defaultModel]) - // Selecting a model affects only the *next* run created from this tab. - // Once a run exists, model is frozen on the run and the dropdown is read-only. + // Selecting a model affects the *next* run created from this tab (frozen + // once a run exists) AND persists as the app default so background agents + // and new tabs follow the last pick. The models-config-changed dispatch + // re-runs loadModelConfig here too — that re-read lands on the same + // selection (activeModelKey is untouched, the live lists come from the + // hook's cache), so it's visually a no-op. const handleModelChange = useCallback((key: string) => { if (lockedModel) return - const entry = pickerModels.find((m) => `${m.provider}/${m.model}` === key) - if (!entry) return + const slash = key.indexOf('/') + if (slash <= 0 || slash === key.length - 1) return + const provider = key.slice(0, slash) + const model = key.slice(slash + 1) setActiveModelKey(key) - onSelectedModelChange?.({ provider: entry.provider, model: entry.model }) - }, [pickerModels, lockedModel, onSelectedModelChange]) + onSelectedModelChange?.({ provider, model }) + void window.ipc.invoke('models:updateConfig', { defaultSelection: { provider, model } }) + .then(() => { window.dispatchEvent(new Event('models-config-changed')) }) + .catch(() => { toast.error('Failed to save default model') }) + }, [lockedModel, onSelectedModelChange]) // Reasoning effort applies to the model the next message will actually use: // the run's frozen model once one exists, else the picker selection, else @@ -1378,7 +1457,7 @@ function ChatInputInner({ {providerDisplayNames[lockedModel.provider] || lockedModel.provider} — fixed for this chat - ) : pickerModels.length > 0 ? ( + ) : ( - - - {pickerModels.map((m) => { - const key = `${m.provider}/${m.model}` - return ( - - {m.model} - {providerDisplayNames[m.provider] || m.provider} + + {modelGroups.length === 0 && !standaloneDefault ? ( + Connect a provider in Settings + ) : ( + + {standaloneDefault && ( + + {standaloneDefault.model} + + {providerDisplayNames[standaloneDefault.provider] || standaloneDefault.provider} + - ) - })} - + )} + {modelGroups.map((g) => { + // The app default leads its live group; the group's own + // saved model follows (both stay pickable through fetch + // loading/failure). + const pinned: string[] = [] + if (g.kind === 'live') { + if (defaultModel && defaultModel.provider === g.flavor) pinned.push(defaultModel.model) + if (g.savedModel && !pinned.includes(g.savedModel)) pinned.push(g.savedModel) + } + return ( + + + {providerDisplayNames[g.flavor] || g.flavor} + + {g.kind === 'catalog' ? ( + g.models.map((m) => { + const key = `${g.flavor}/${m}` + return ( + + {m} + + ) + }) + ) : ( + + )} + + ) + })} + + )} - ) : null} + )} {onStartCall && (