From 75805e2d676b497655dd790a7a35848d46ead214 Mon Sep 17 00:00:00 2001 From: Prakhar Pandey Date: Sun, 12 Jul 2026 13:36:16 +0530 Subject: [PATCH 1/8] feat: fetch provider models live from API key in settings Settings > Models is now key-first, matching the onboarding flow: entering an API key (or base URL for local providers) fetches the provider's live model list via the existing models:listForProvider IPC, shown in a dropdown. Free-text model entry survives only as the fetch-failure fallback and the openai-compatible custom-model escape hatch. Unkeyed providers no longer show auto-seeded models. Also fixes the models[] wipe: editing the primary model previously discarded every saved model after the first. --- .../src/components/settings-dialog.tsx | 222 +++++++++++------- .../renderer/src/hooks/use-provider-models.ts | 147 ++++++++++++ 2 files changed, 289 insertions(+), 80 deletions(-) create mode 100644 apps/x/apps/renderer/src/hooks/use-provider-models.ts diff --git a/apps/x/apps/renderer/src/components/settings-dialog.tsx b/apps/x/apps/renderer/src/components/settings-dialog.tsx index 4b56eb51..d1a89555 100644 --- a/apps/x/apps/renderer/src/components/settings-dialog.tsx +++ b/apps/x/apps/renderer/src/components/settings-dialog.tsx @@ -29,6 +29,7 @@ import { ConnectedAccountsSettings } from "@/components/settings/connected-accou import { MobileChannelsSettings } from "@/components/settings/mobile-channels-settings" import type { ApprovalPolicy } from "@x/shared/src/code-mode.js" import { startProvisioning, useProvisioning, enabledOptimistic, type AgentStatus, type CodeModeAgentStatus } from "@/lib/code-mode-provisioning" +import { useProviderModels } from "@/hooks/use-provider-models" type ConfigTab = "account" | "connections" | "mobile" | "models" | "mcp" | "security" | "code-mode" | "appearance" | "notifications" | "note-tagging" | "help" @@ -336,6 +337,7 @@ const preferredDefaults: Partial> = { const defaultBaseURLs: Partial> = { ollama: "http://localhost:11434", "openai-compatible": "http://localhost:1234/v1", + aigateway: "https://ai-gateway.vercel.sh/v1", } type ProviderModelConfig = { @@ -356,13 +358,12 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b 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: "", 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 [modelsCatalog, setModelsCatalog] = useState>({}) const [modelsLoading, setModelsLoading] = useState(false) - const [modelsError, setModelsError] = useState(null) const [testState, setTestState] = useState<{ status: "idle" | "testing" | "success" | "error"; error?: string }>({ status: "idle" }) const [configLoading, setConfigLoading] = useState(true) const [showMoreProviders, setShowMoreProviders] = useState(false) @@ -371,8 +372,18 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b // auto-enable) has ever set it, so we only auto-enable once. const [deferBackgroundTasks, setDeferBackgroundTasks] = useState(false) const [deferExplicit, setDeferExplicit] = useState(false) + // openai-compatible only: free-text model that takes precedence over the + // fetched list (many such servers don't implement /models at all). + const [customModel, setCustomModel] = useState("") 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. + const providerModels = useProviderModels({ + flavor: provider, + apiKey: activeConfig.apiKey, + baseURL: activeConfig.baseURL, + }) 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" @@ -399,6 +410,19 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b [] ) + // 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(() => { @@ -489,7 +513,6 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b async function loadModels() { try { setModelsLoading(true) - setModelsError(null) const result = await window.ipc.invoke("models:list", null) const catalog: Record = {} for (const p of result.providers || []) { @@ -497,7 +520,6 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b } setModelsCatalog(catalog) } catch { - setModelsError("Failed to load models list") setModelsCatalog({}) } finally { setModelsLoading(false) @@ -507,24 +529,26 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b loadModels() }, [dialogOpen]) - // Set default models from catalog when catalog loads + // Preselect a model once the live per-key list arrives and nothing is + // chosen yet. Only the active provider is touched — unkeyed providers no + // longer get a model auto-seeded, so they can't look configured. useEffect(() => { - if (Object.keys(modelsCatalog).length === 0) return - setProviderConfigs(prev => { - const next = { ...prev } - const cloudProviders: LlmProviderFlavor[] = ["openai", "anthropic", "google"] - for (const prov of cloudProviders) { - const catalog = modelsCatalog[prov] - if (catalog?.length && !next[prov].models[0]) { - const preferred = preferredDefaults[prov] - const hasPreferred = preferred && catalog.some(m => m.id === preferred) - const defaultModel = hasPreferred ? preferred! : (catalog[0]?.id || "") - next[prov] = { ...next[prov], models: [defaultModel] } - } - } - return next - }) - }, [modelsCatalog]) + if (providerModels.status !== "loaded" || providerModels.models.length === 0) return + if (primaryModel) return + const preferred = preferredDefaults[provider] + const choice = preferred && providerModels.models.includes(preferred) ? preferred : providerModels.models[0] + setPrimaryModel(provider, choice) + }, [providerModels.status, providerModels.models, provider, primaryModel, setPrimaryModel]) + + // A saved openai-compatible model that isn't in the server's list belongs + // in the custom-model field, where it stays visible and editable. + useEffect(() => { + if (provider !== "openai-compatible" || providerModels.status !== "loaded") return + if (customModel) return + if (primaryModel && !providerModels.models.includes(primaryModel)) { + setCustomModel(primaryModel) + } + }, [provider, providerModels.status, providerModels.models, primaryModel, customModel]) const handleTestAndSave = useCallback(async () => { if (!canTest) return @@ -738,46 +762,117 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b )} + {/* 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" + /> +
+ )} + + {/* 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" + } + /> +
+ )} + {/* Model selection - side by side */}
- {/* Assistant models (left column) */} + {/* Assistant model (left column) — live list fetched from the key */}
{rowboatConnected ? "Model" : "Assistant model"} - {modelsLoading ? ( + {providerModels.status === "idle" ? ( +
+ {isLocalProvider + ? "Enter your base URL to load available models" + : "Enter your API key to load available models"} +
+ ) : providerModels.status === "loading" ? (
- Loading... + Loading models... +
+ ) : providerModels.status === "error" ? ( +
+
+ {providerModels.error || "Failed to load models"} +
+ + setPrimaryModel(provider, e.target.value)} + placeholder="Enter model manually" + /> +
+ ) : providerModels.models.length === 0 ? ( +
+
+ The provider reported no models — enter one manually +
+ setPrimaryModel(provider, e.target.value)} + placeholder="Enter model" + />
) : (
- {showModelInput ? ( + + {provider === "openai-compatible" && ( updateConfig(provider, { models: [e.target.value] })} - placeholder="Enter model" + value={customModel} + onChange={(e) => { + setCustomModel(e.target.value) + setPrimaryModel(provider, e.target.value) + }} + placeholder="Custom model (overrides selection)" /> - ) : ( - )}
)} - {modelsError && ( -
{modelsError}
- )}
{!rowboatConnected && (<> @@ -919,39 +1014,6 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b )}
- {/* API Key */} - {showApiKey && ( -
- - {provider === "openai-compatible" ? "API Key (optional)" : "API Key"} - - updateConfig(provider, { apiKey: e.target.value })} - placeholder="Paste your API key" - /> -
- )} - - {/* Base URL */} - {showBaseURL && ( -
- Base URL - updateConfig(provider, { baseURL: e.target.value })} - placeholder={ - provider === "ollama" - ? "http://localhost:11434" - : provider === "openai-compatible" - ? "http://localhost:1234/v1" - : "https://ai-gateway.vercel.sh/v1" - } - /> -
- )} - {/* Test status */} {testState.status === "error" && (
diff --git a/apps/x/apps/renderer/src/hooks/use-provider-models.ts b/apps/x/apps/renderer/src/hooks/use-provider-models.ts new file mode 100644 index 00000000..4b737442 --- /dev/null +++ b/apps/x/apps/renderer/src/hooks/use-provider-models.ts @@ -0,0 +1,147 @@ +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 } +} From 5377d7837ae8dca5268dc599fa56848e894eec09 Mon Sep 17 00:00:00 2001 From: Prakhar Pandey Date: Sun, 12 Jul 2026 14:14:55 +0530 Subject: [PATCH 2/8] feat: make Settings > Models connect-only Settings no longer exposes model selection. Each provider connects with just its API key (or base URL for local providers); the live fetch now drives a connection status line (Connected - N models) and the model the config schema still requires is resolved silently at save (saved > preferred > first fetched). Escape hatches: openai-compatible keeps a visible Model field, and fetch failures offer manual entry so offline saves still work. Provider cards show a Connected badge per configured flavor. Save payload, models:test gate, and the four per-function fields unchanged. Model selection moves to the chat composer next. --- .../src/components/settings-dialog.tsx | 212 ++++++++++-------- 1 file changed, 116 insertions(+), 96 deletions(-) diff --git a/apps/x/apps/renderer/src/components/settings-dialog.tsx b/apps/x/apps/renderer/src/components/settings-dialog.tsx index d1a89555..586238b8 100644 --- a/apps/x/apps/renderer/src/components/settings-dialog.tsx +++ b/apps/x/apps/renderer/src/components/settings-dialog.tsx @@ -353,6 +353,9 @@ type ProviderModelConfig = { 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: "" }, @@ -394,8 +397,25 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b const isMoreProvider = moreProviders.some(p => p.id === provider) const primaryModel = activeConfig.models[0] || "" + // Settings no longer exposes model selection — the model is resolved + // silently when the user connects (the config schema still requires one; + // background agents/channels read it). Precedence: a typed escape-hatch + // value (openai-compatible Model field, offline manual input) > the saved + // model when the fetched list still has it > the flavor's preferred + // default > the first fetched id. + const resolvedModel = (() => { + if (provider === "openai-compatible" && customModel.trim()) return customModel.trim() + const saved = primaryModel.trim() + if (providerModels.status === "loaded" && providerModels.models.length > 0) { + if (saved && providerModels.models.includes(saved)) return saved + const preferred = preferredDefaults[provider] + if (preferred && providerModels.models.includes(preferred)) return preferred + return providerModels.models[0] + } + return saved + })() const canTest = - primaryModel.trim().length > 0 && + resolvedModel.length > 0 && (!requiresApiKey || activeConfig.apiKey.trim().length > 0) && (!requiresBaseURL || activeConfig.baseURL.trim().length > 0) @@ -439,6 +459,10 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b 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)) + )) if (parsed?.provider?.flavor && parsed?.model) { const flavor = parsed.provider.flavor as LlmProviderFlavor setProvider(flavor) @@ -529,23 +553,12 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b loadModels() }, [dialogOpen]) - // Preselect a model once the live per-key list arrives and nothing is - // chosen yet. Only the active provider is touched — unkeyed providers no - // longer get a model auto-seeded, so they can't look configured. + // A saved openai-compatible model that the server's list doesn't confirm + // (not listed, or /models unreachable) belongs in the visible Model field, + // where it stays editable and wins over the silent pick. useEffect(() => { - if (providerModels.status !== "loaded" || providerModels.models.length === 0) return - if (primaryModel) return - const preferred = preferredDefaults[provider] - const choice = preferred && providerModels.models.includes(preferred) ? preferred : providerModels.models[0] - setPrimaryModel(provider, choice) - }, [providerModels.status, providerModels.models, provider, primaryModel, setPrimaryModel]) - - // A saved openai-compatible model that isn't in the server's list belongs - // in the custom-model field, where it stays visible and editable. - useEffect(() => { - if (provider !== "openai-compatible" || providerModels.status !== "loaded") return - if (customModel) return - if (primaryModel && !providerModels.models.includes(primaryModel)) { + if (provider !== "openai-compatible" || customModel || !primaryModel) return + if (providerModels.status === "error" || (providerModels.status === "loaded" && !providerModels.models.includes(primaryModel))) { setCustomModel(primaryModel) } }, [provider, providerModels.status, providerModels.models, primaryModel, customModel]) @@ -554,7 +567,10 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b if (!canTest) return setTestState({ status: "testing" }) try { - const allModels = activeConfig.models.map(m => m.trim()).filter(Boolean) + // The silently resolved model takes the primary slot; the rest of the + // saved list is preserved (same semantics setPrimaryModel had). + const existing = activeConfig.models.map(m => m.trim()) + const allModels = [resolvedModel, ...existing.slice(1).filter(m => m && m !== resolvedModel)] const providerConfig = { provider: { flavor: provider, @@ -574,6 +590,11 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b if (result.success) { await window.ipc.invoke("models:saveConfig", providerConfig) setDefaultProvider(provider) + setProviderConfigs(prev => ({ + ...prev, + [provider]: { ...prev[provider], models: allModels }, + })) + 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: @@ -601,7 +622,7 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b setTestState({ status: "error", error: "Connection test failed" }) toast.error("Connection test failed") } - }, [canTest, provider, activeConfig, rowboatConnected, deferExplicit, deferBackgroundTasks, handleDeferToggle]) + }, [canTest, resolvedModel, provider, activeConfig, rowboatConnected, deferExplicit, deferBackgroundTasks, handleDeferToggle]) const handleSetDefault = useCallback(async (prov: LlmProviderFlavor) => { const config = providerConfigs[prov] @@ -665,6 +686,11 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b ...prev, [prov]: { apiKey: "", baseURL: defaultBaseURLs[prov] || "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" }, })) + setSavedProviders(prev => { + const next = new Set(prev) + next.delete(prov) + return next + }) setTestState({ status: "idle" }) window.dispatchEvent(new Event('models-config-changed')) toast.success("Provider configuration removed") @@ -676,6 +702,7 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b 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 (
{p.description}
{!isDefault && hasModel && isSelected && ( @@ -797,84 +829,72 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b )} - {/* Model selection - side by side */} -
- {/* Assistant model (left column) — live list fetched from the key */} -
- {rowboatConnected ? "Model" : "Assistant model"} - {providerModels.status === "idle" ? ( + {/* 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" ? ( +
- {isLocalProvider - ? "Enter your base URL to load available models" - : "Enter your API key to load available models"} + Connected, but the provider reported no models — enter one manually
- ) : providerModels.status === "loading" ? ( -
- - Loading models... -
- ) : providerModels.status === "error" ? ( -
-
- {providerModels.error || "Failed to load models"} -
- - setPrimaryModel(provider, e.target.value)} - placeholder="Enter model manually" - /> -
- ) : providerModels.models.length === 0 ? ( -
-
- The provider reported no models — enter one manually -
- setPrimaryModel(provider, e.target.value)} - placeholder="Enter model" - /> -
- ) : ( -
- - {provider === "openai-compatible" && ( - { - setCustomModel(e.target.value) - setPrimaryModel(provider, e.target.value) - }} - placeholder="Custom model (overrides selection)" - /> - )} -
- )} -
+ setPrimaryModel(provider, e.target.value)} + placeholder="Enter model" + /> +
+ ) : ( +
+ + Connected · {providerModels.models.length} model{providerModels.models.length === 1 ? "" : "s"} available +
+ )} +
+ {/* openai-compatible escape hatch: its /models often doesn't exist, and + a typed model always wins over the silent pick */} + {provider === "openai-compatible" && ( +
+ Model + { + setCustomModel(e.target.value) + setPrimaryModel(provider, e.target.value) + }} + placeholder="Model ID (leave empty to auto-select)" + /> +
+ )} + + {/* Per-function model overrides */} +
{!rowboatConnected && (<> {/* Knowledge graph model (right column) */}
@@ -1045,9 +1065,9 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b className="w-full" > {testState.status === "testing" ? ( - <>Testing connection... + <>Connecting... ) : ( - "Test & Save" + "Connect" )}
From 367071916a29c91d45a948a1af5d47d7f69ec4a2 Mon Sep 17 00:00:00 2001 From: Prakhar Pandey Date: Sun, 12 Jul 2026 18:50:49 +0530 Subject: [PATCH 3/8] feat: aggregate model picker in composer grouped by connected provider The composer picker lists every model across all connected providers: models.dev catalog groups for openai/anthropic/google (plus the rowboat gateway when signed in) and live per-provider fetches via models:listForProvider for openrouter/aigateway/ollama/openai-compatible, each group loading and failing independently with inline Retry. Picking a model persists as the app default (models:updateConfig defaultSelection) so background agents and new tabs follow the last pick; per-tab override behavior is unchanged. Empty state points to Settings; the dropdown scrolls for large lists and OpenRouter ids containing slashes are handled. --- .../components/chat-input-with-mentions.tsx | 244 +++++++++++++----- 1 file changed, 177 insertions(+), 67 deletions(-) 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 && (
From c9ebbd760040ae9438e977182029f8d109f412c4 Mon Sep 17 00:00:00 2001 From: Prakhar Pandey Date: Sun, 12 Jul 2026 23:10:08 +0530 Subject: [PATCH 4/8] feat: add search filter to model picker and live fallback for catalog providers The picker dropdown gets a sticky search input filtering model ids across all groups (headers hide at zero matches; loading/error rows stay visible as status; a No-models-match row appears when nothing matches anywhere). openai/anthropic/google groups fall back to a live models:listForProvider fetch when the models.dev catalog is unavailable (signed-in mode or empty cache), so they always show the provider's full list. --- .../components/chat-input-with-mentions.tsx | 180 +++++++++++++----- 1 file changed, 135 insertions(+), 45 deletions(-) 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 c4fda45d..6b4d5611 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 @@ -108,6 +108,10 @@ interface ConfiguredModel { // fetch their list from the provider inside the dropdown via // useProviderModels (models:listForProvider). const LIVE_PICKER_FLAVORS = new Set(['openrouter', 'aigateway', 'ollama', 'openai-compatible']) +// Catalog-preferred flavors that degrade to a live fetch when models:list has +// no catalog for them (signed-in mode returns only the rowboat provider, or +// the models.dev cache is empty). +const LIVE_FALLBACK_FLAVORS = new Set(['openai', 'anthropic', 'google']) type ModelPickerGroup = | { kind: 'catalog'; flavor: string; models: string[] } @@ -119,16 +123,35 @@ type ModelPickerGroup = // 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[] }) { +// +// The group owns its header so it can hide itself when the search filter +// matches none of its rows. Loading/error rows are status, not models — they +// render (with the header) regardless of the filter, and don't count toward +// the parent's "No models match" check (which is what gets reported up). +function LiveProviderGroupItems({ group, label, pinnedModels, filter, onModelRowsChange }: { + group: Extract + label: string + pinnedModels: string[] + filter: string + onModelRowsChange: (flavor: string, hasModelRows: boolean) => void +}) { const { status, models, error, refetch } = useProviderModels({ flavor: group.flavor, apiKey: group.apiKey, baseURL: group.baseURL, }) const items = [...pinnedModels, ...models.filter((m) => !pinnedModels.includes(m))] + const visible = filter ? items.filter((m) => m.toLowerCase().includes(filter)) : items + const showStatus = status === 'loading' || status === 'error' + const hasModelRows = visible.length > 0 + useEffect(() => { + onModelRowsChange(group.flavor, hasModelRows) + }, [group.flavor, hasModelRows, onModelRowsChange]) + if (!hasModelRows && !showStatus) return null return ( <> - {items.map((m) => { + {label} + {visible.map((m) => { const key = `${group.flavor}/${m}` return ( @@ -534,21 +557,23 @@ function ChatInputInner({ const savedModel = typeof e.model === 'string' ? e.model : '' // Live flavors fetch their list from the provider inside the - // dropdown, with the credentials saved in config. - if (LIVE_PICKER_FLAVORS.has(flavor)) { + // dropdown, with the credentials saved in config. Catalog flavors + // degrade to the same live fetch when models:list carried no + // catalog for them (signed in, or empty models.dev cache). + const catalogModels = catalog[flavor] || [] + if (LIVE_PICKER_FLAVORS.has(flavor) || (catalogModels.length === 0 && LIVE_FALLBACK_FLAVORS.has(flavor))) { groups.push({ kind: 'live', flavor: flavor as ProviderModelsFlavor, apiKey, baseURL, savedModel }) continue } - // Catalog 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). + // Catalog group: the saved default model leads, then the catalog. + // Saved models[] survives as the fallback for unknown flavors the + // live fetch doesn't support. const models: string[] = [] const push = (model: string) => { if (model && !models.includes(model)) models.push(model) } push(savedModel) - const catalogModels = catalog[flavor] || [] if (catalogModels.length > 0) { for (const m of catalogModels) push(m) } else { @@ -769,6 +794,18 @@ function ChatInputInner({ checkSearch() }, [isActive, isRowboatConnected]) + // Search filter for the model dropdown. Reset each time the menu opens; + // matching is a case-insensitive substring test on the model id. Live + // groups filter themselves and report whether they still have rows, so the + // parent can render the global "No models match" row. + const [modelFilter, setModelFilter] = useState('') + const modelFilterInputRef = useRef(null) + const [liveGroupHasRows, setLiveGroupHasRows] = useState>({}) + const modelFilterValue = modelFilter.trim().toLowerCase() + const handleLiveGroupRows = useCallback((flavor: string, hasRows: boolean) => { + setLiveGroupHasRows((prev) => (prev[flavor] === hasRows ? prev : { ...prev, [flavor]: hasRows })) + }, []) + // The effective default always renders even when no group carries it (the // gateway list failed, or its provider was removed from config) — the // picker must never be missing the model that actually runs. Live groups @@ -781,6 +818,16 @@ function ChatInputInner({ return covered ? null : defaultModel }, [modelGroups, defaultModel]) + const standaloneVisible = standaloneDefault !== null && + (!modelFilterValue || standaloneDefault.model.toLowerCase().includes(modelFilterValue)) + // Nothing matches anywhere → "No models match". Live groups that haven't + // reported yet (first render after opening) count as having rows so the + // empty row never flashes. + const anyModelRowVisible = standaloneVisible || modelGroups.some((g) => + g.kind === 'catalog' + ? g.models.some((m) => m.toLowerCase().includes(modelFilterValue)) + : liveGroupHasRows[g.flavor] !== false) + // Selecting a model affects the *next* run created from this tab (frozen // once a run exists) AND persists as the app default so background agents // and new tabs follow the last pick. The models-config-changed dispatch @@ -1458,7 +1505,18 @@ function ChatInputInner({ ) : ( - + { + // The filter is per-opening, never sticky. Focus the search + // input once the content has mounted and Radix has run its own + // open-focus (DropdownMenu.Content has no onOpenAutoFocus). + if (open) { + setModelFilter('') + setLiveGroupHasRows({}) + setTimeout(() => modelFilterInputRef.current?.focus(), 0) + } + }} + >
)} + {savedProviders.has(provider) && ( + + )}
{/* openai-compatible escape hatch: its /models often doesn't exist, and From 1a8367ada43873c255d800406f03abd009122718 Mon Sep 17 00:00:00 2001 From: Prakhar Pandey Date: Mon, 13 Jul 2026 23:07:01 +0530 Subject: [PATCH 6/8] fix: gate OpenRouter connection on the authenticated /models/user endpoint OpenRouter's public /api/v1/models returned the full catalog even with an invalid key, so Settings showed Connected while the model call failed with Missing Authentication header. With a key present we now fetch the account-scoped /models/user with the Bearer token, so a bad key surfaces as an error instead of a false Connected; no key keeps the public preview. Other providers untouched. Note: unlike the rest of this PR, this touches core (models.ts). --- apps/x/packages/core/src/models/models.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/apps/x/packages/core/src/models/models.ts b/apps/x/packages/core/src/models/models.ts index c14452aa..0396c301 100644 --- a/apps/x/packages/core/src/models/models.ts +++ b/apps/x/packages/core/src/models/models.ts @@ -259,8 +259,20 @@ export async function listModelsForProvider( url = `https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey ?? ""}`; break; case "openrouter": - url = "https://openrouter.ai/api/v1/models"; - if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`; + // /api/v1/models is a public catalog — it returns the full + // list even with an invalid/absent key, so listing it can't + // tell a bad key from a good one (a false "Connected"). When + // a key is given, hit the account-scoped /models/user behind + // Bearer auth instead: a bad key 401s here and the shared + // throw below surfaces it. Same OpenAI-shaped { data:[{id}] } + // response, so the parse path is unchanged. No key → keep the + // public catalog so an unconfigured provider can still preview. + if (apiKey) { + url = "https://openrouter.ai/api/v1/models/user"; + headers["Authorization"] = `Bearer ${apiKey}`; + } else { + url = "https://openrouter.ai/api/v1/models"; + } break; case "ollama": url = `${(baseURL ?? "http://localhost:11434").replace(/\/$/, "")}/api/tags`; From e74ce4e9a5517097bc27cafec0e9a947f250002e Mon Sep 17 00:00:00 2001 From: Prakhar Pandey Date: Tue, 14 Jul 2026 12:38:00 +0530 Subject: [PATCH 7/8] fix: pin picker search input to top and connect providers in a single click Bug 1: the composer picker search input lived inside the scrolling dropdown body as a sticky element, so container padding and Radix's open-focus scroll left it slightly below the top. Moved it out of the scroll area into a fixed header with the model list in an inner scroller, so it's flush at the top and always visible. Bug 2: Connect gated on resolvedModel, which is empty while the debounced key-change fetch is still in flight right after pasting a key, so the first click was a no-op. Connect now gates on credentials only, and the save handler resolves the model on demand (one fetch, same silent precedence) when it hasn't loaded yet. Save payload, models:test gate, and per-function fields unchanged. --- .../components/chat-input-with-mentions.tsx | 13 ++++++-- .../src/components/settings-dialog.tsx | 32 +++++++++++++++++-- 2 files changed, 40 insertions(+), 5 deletions(-) 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 6b4d5611..9e6d76b4 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 @@ -1532,12 +1532,17 @@ function ChatInputInner({ - + {modelGroups.length === 0 && !standaloneDefault ? ( - Connect a provider in Settings +
+ 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. */} +
+
No models match
)} +
)}
diff --git a/apps/x/apps/renderer/src/components/settings-dialog.tsx b/apps/x/apps/renderer/src/components/settings-dialog.tsx index c13d8cbc..7ef1f813 100644 --- a/apps/x/apps/renderer/src/components/settings-dialog.tsx +++ b/apps/x/apps/renderer/src/components/settings-dialog.tsx @@ -414,8 +414,11 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b } return saved })() + // 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 = - resolvedModel.length > 0 && (!requiresApiKey || activeConfig.apiKey.trim().length > 0) && (!requiresBaseURL || activeConfig.baseURL.trim().length > 0) @@ -567,10 +570,35 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b 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 resolvedModel, minus the customModel + // branch (that path only runs when resolvedModel is already empty). + 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 + } // The silently resolved model takes the primary slot; the rest of the // saved list is preserved (same semantics setPrimaryModel had). const existing = activeConfig.models.map(m => m.trim()) - const allModels = [resolvedModel, ...existing.slice(1).filter(m => m && m !== resolvedModel)] + const allModels = [model, ...existing.slice(1).filter(m => m && m !== model)] const providerConfig = { provider: { flavor: provider, From 7ddc82f25da34a2648a27c2b5f892c7f7916d5ad Mon Sep 17 00:00:00 2001 From: Prakhar Pandey Date: Tue, 14 Jul 2026 13:10:52 +0530 Subject: [PATCH 8/8] feat: remove model-name entry from onboarding to match connect-only settings Onboarding still asked for a model name for openrouter/aigateway/ollama/ openai-compatible. It now mirrors the settings dialog: entering a key (or base URL) is enough and the model is resolved silently from the fetched list (same precedence), with openai-compatible keeping a visible Model field as its escape hatch. Save payload shape and multi-provider flow unchanged. --- .../onboarding/steps/llm-setup-step.tsx | 21 +++++++--------- .../onboarding/use-onboarding-state.ts | 25 +++++++++++++------ 2 files changed, 27 insertions(+), 19 deletions(-) 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 4ece02c9..86787753 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 @@ -42,14 +42,11 @@ export function LlmSetupStep({ state }: LlmSetupStepProps) { } = state const isMoreProvider = moreProviders.some(p => p.id === llmProvider) - // Hosted providers (openai/anthropic/google) get a default model, so we only - // ask for a model on providers that truly need one (local/custom/gateway), - // or as a fallback if no model is set yet. - // Hosted providers (openai/anthropic/google) fetch their models from the API - // key on test, so they never need a manual model field. Only local/custom/ - // gateway providers, where the user must specify a model, show the input. - const hostedProviders: LlmProviderFlavor[] = ["openai", "anthropic", "google"] - const showModelInput = !hostedProviders.includes(llmProvider) + // 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 @@ -150,9 +147,9 @@ export function LlmSetupStep({ state }: LlmSetupStepProps) { {/* Provider configuration */}
- {/* Cloud providers get a default model auto-selected; only local/custom - providers (no catalog) need a model here. Users can pick any of the - provider's models later in the chat view. */} + {/* 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 && (