From 2274369383c0f5754675523d0a0148a448d56b46 Mon Sep 17 00:00:00 2001 From: PRAKHAR PANDEY Date: Wed, 22 Jul 2026 10:59:35 +0530 Subject: [PATCH] Feat/model selector (#770) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(x): add useModels hook — shared model-catalog store One module-level store behind useSyncExternalStore: N mounted consumers share one snapshot and one in-flight fetch (models:list + models.json + oauth state + llm:getDefaultModel). Re-fetches on models-config-changed, oauth:didConnect and chatgpt:statusChanged; newest-wins seq guard ports the composer's epoch race protection. Extracted verbatim from the chat composer's loadModelConfig for the ModelSelector consolidation (Phase 1). Co-Authored-By: Claude Fable 5 * feat(x): add ModelSelector extracted from the chat composer Controlled picker ({provider, model} value + onChange, persistence stays with the caller) rendering the composer's exact UI: provider-grouped catalog + live groups via useProviderModels, search filter, standalone default row, locked-model display, and the reasoning-effort control (shown only for catalog-flagged reasoning models, '' reported up when the model loses reasoning support). Catalog comes from useModels(). Co-Authored-By: Claude Fable 5 * refactor(x): migrate chat composer to useModels + ModelSelector Deletes the composer's inline copies of the catalog loader (oauth state, llm:getDefaultModel, models:list, models.json parsing, event listeners) and the picker/effort dropdown JSX in favor of the shared hook and component. Behavior preserved: picks persist globally via models:updateConfig({defaultSelection}) + models-config-changed dispatch, locked-model display, per-turn unpersisted reasoning effort, and the tab-activation re-fetch. SelectedModel/ReasoningEffortLevel exports stay for existing consumers. Co-Authored-By: Claude Fable 5 * feat(x): ModelSelector settings modes — sentinel, field trigger, provider scoping, custom ids defaultOption pins a top entry that selects null ('Rowboat default' / 'Same as assistant'); variant='field' renders a SelectTrigger-style full-width box; providerFilter scopes to one provider's group, falling back to the raw models:list catalog (newly exposed on the useModels snapshot as catalogByProvider) for providers mid-setup; allowCustom offers a Use-"" row when the search matches nothing. onChange widens to ModelRef | null — the composer guards the (unreachable without defaultOption) null and is otherwise untouched. Still zero persistence inside the component. Co-Authored-By: Claude Fable 5 * refactor(x): signed-in Settings Models section on ModelSelector The four hybrid dropdowns (Assistant / Knowledge graph / Background agents / Permission checks) become field-variant ModelSelectors with a 'Rowboat default' sentinel, sourcing the shared store's groups — BYOK providers without a static catalog (e.g. OpenRouter) now live-fetch their full list inside the open dropdown instead of showing only the saved model (user-reported bug). State is ModelRef | null; the provider::model hybridKey round-trip, options collector, and local providerDisplayNames map are gone. Persistence unchanged: nothing writes until Save's single models:updateConfig + models-config-changed. Co-Authored-By: Claude Fable 5 * refactor(x): BYOK per-provider category fields on ModelSelector Knowledge graph / Meeting notes / Track block / Auto-permission become provider-scoped field-variant ModelSelectors (providerFilter=active card, allowCustom, 'Same as assistant' sentinel), replacing the static catalog Select + free-text Input pair. Values still persist as bare model-id strings inside providers[flavor] through the existing save path — the ModelRef ↔ string adapter lives at the call site. Deletes the section's own models:list loader (modelsCatalog/modelsLoading/ showModelInput/LlmModelOption); scoped pickers fall back to the store's catalogByProvider for providers mid-setup, and configured providers now live-fetch full lists (OpenRouter bug fix on the BYOK side too). Co-Authored-By: Claude Fable 5 * test(x): assert defaultModel refreshes on models-config-changed Covers the settings-Save → event → store refetch leg that updates a fresh composer tab's trigger label. Co-Authored-By: Claude Fable 5 * feat(x): ModelSelector inheritance mode + un-scoped custom ids inheritDefault?: {label} is defaultOption with placeholder styling — one sentinel code path, the trigger renders the label muted when nothing overrides. allowCustom no longer requires providerFilter: an un-scoped custom entry splits 'provider/model' on the first slash (OpenRouter-style ids must be typed provider-qualified) and pairs slash-less text with the global default's provider, matching how the runtime resolves a provider-less override. Adds modelOverrideToRef / refToModelOverride adapters for the two-optional-strings persistence shape shared by BackgroundTask and LiveNote. Co-Authored-By: Claude Fable 5 * refactor(x): bg-task model/provider override on ModelSelector The Advanced section's two free-text Inputs become one inheritance-mode picker ('(global default)' sentinel, allowCustom for arbitrary ids). Same draft state and bg-task:patch save path; a task with model set but no provider round-trips untouched via the shared override adapters. Co-Authored-By: Claude Fable 5 * refactor(x): live-note model/provider override on ModelSelector Same inheritance-mode picker as bg-tasks (the section was a copy-paste of it), same shared adapters, unchanged live-note:set save path. Co-Authored-By: Claude Fable 5 * refactor(x): EnableAgentsDialog model picker on ModelSelector Replaces the flat models:list-flattening setSelected(e.target.value)} - className="min-w-0 flex-1 truncate rounded-md border border-border bg-background px-2 py-1 text-sm"> - {defaultModel && !options.some((o) => o.provider === defaultModel.provider && o.model === defaultModel.model) && ( - - )} - {options.map((o) => ( - - ))} - - - )} +
- diff --git a/apps/x/apps/renderer/src/components/bg-tasks-view.tsx b/apps/x/apps/renderer/src/components/bg-tasks-view.tsx index 8080d503..0c2c3173 100644 --- a/apps/x/apps/renderer/src/components/bg-tasks-view.tsx +++ b/apps/x/apps/renderer/src/components/bg-tasks-view.tsx @@ -19,6 +19,7 @@ import type { ConversationItem } from '@/lib/chat-conversation' import { fetchAgentRunTranscript } from '@/lib/agent-transcript' import { useAgentRunTranscript } from '@/hooks/use-agent-run-transcript' import { CompactConversation } from '@/components/compact-conversation' +import { ModelSelector, modelOverrideToRef, refToModelOverride } from '@/components/model-selector' import { RichMarkdownViewer } from '@/components/rich-markdown-viewer' import { HtmlFileViewer } from '@/components/html-file-viewer' @@ -924,19 +925,13 @@ function SetupTab({ {showAdvanced && (
- Model - setDraft({ ...draft, model: e.target.value || undefined })} - placeholder="(global default)" - className="h-7 font-mono text-xs" - /> - Provider - setDraft({ ...draft, provider: e.target.value || undefined })} - placeholder="(global default)" - className="h-7 font-mono text-xs" + Model + setDraft({ ...draft, ...refToModelOverride(ref) })} />
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 be1bba98..80bcd318 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 { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { ArrowUp, @@ -17,7 +17,6 @@ import { Globe, ImagePlus, LoaderIcon, - Brain, Lock, Mic, MoreHorizontal, @@ -38,15 +37,13 @@ import { DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' -import { useProviderModels, type ProviderModelsFlavor } from '@/hooks/use-provider-models' +import { ModelSelector, type ModelRef, type ReasoningEffortLevel } from '@/components/model-selector' +import { useModels } from '@/hooks/use-models' import { type AttachmentIconKind, getAttachmentDisplayName, @@ -85,131 +82,18 @@ const RECENT_WORK_DIRS_CONFIG_PATH = 'config/recent-work-dirs.json' const RECENT_WORK_DIRS_CHANGED_EVENT = 'rowboat-chat-recent-work-dirs-changed' -const providerDisplayNames: Record = { - openai: 'OpenAI', - anthropic: 'Anthropic', - google: 'Gemini', - ollama: 'Ollama', - openrouter: 'OpenRouter', - aigateway: 'AI Gateway', - 'openai-compatible': 'OpenAI-Compatible', - rowboat: 'Rowboat', - // Matches what other subscription clients call this provider; the auth - // itself is "Sign in with ChatGPT" (Plus/Pro subscription). - codex: 'OpenAI Codex', -} - -type ProviderName = "openai" | "anthropic" | "google" | "openrouter" | "aigateway" | "ollama" | "openai-compatible" | "rowboat" | "codex" - -interface ConfiguredModel { - provider: ProviderName - model: string -} - -// One picker group per connected provider. Catalog groups carry a resolved -// model list (models:list / saved config); live groups carry credentials and -// fetch their list from the provider inside the dropdown via -// useProviderModels (models:listForProvider). -const LIVE_PICKER_FLAVORS = new Set(['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[] } - | { kind: 'live'; flavor: ProviderModelsFlavor; apiKey: string; baseURL: string; savedModel: string } - -// Rendered inside the dropdown's radio group: each live provider fetches its -// own list, so groups load and fail independently. Pinned models (the saved -// default / app default) render first — the model that actually runs is -// always pickable even while the fetch is pending or failed. Live-fetched -// ids carry no reasoning metadata, so the effort control stays hidden for -// them (reasoningByKey lookup misses default to off). -// -// The group owns its header so it can hide itself when the search filter -// matches none of its rows. Loading/error rows are status, not models — they -// render (with the header) regardless of the filter, and don't count toward -// the parent's "No models match" check (which is what gets reported up). -function LiveProviderGroupItems({ group, label, pinnedModels, filter, onModelRowsChange }: { - group: Extract - 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 ( - <> - {label} - {visible.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 } -export interface SelectedModel { - provider: string - model: string -} - -export type ReasoningEffortLevel = 'low' | 'medium' | 'high' - -// '' = auto (provider default). Ordered as shown in the picker. -const REASONING_EFFORT_OPTIONS: Array<{ value: '' | ReasoningEffortLevel; label: string; hint: string }> = [ - { value: '', label: 'Auto', hint: 'Provider default' }, - { value: 'low', label: 'Fast', hint: 'Minimal thinking' }, - { value: 'medium', label: 'Balanced', hint: 'Moderate thinking' }, - { value: 'high', label: 'Thorough', hint: 'Deep thinking, costs more' }, -] +// The picker itself lives in ModelSelector; these aliases keep the composer's +// public prop surface stable for existing consumers (chat-sidebar, App). +export type SelectedModel = ModelRef +export type { ReasoningEffortLevel } from '@/components/model-selector' export type PermissionMode = 'manual' | 'auto' -function getSelectedModelDisplayName(model: string) { - return model.split('/').pop() || model -} - function getAttachmentIcon(kind: AttachmentIconKind) { switch (kind) { case 'audio': @@ -401,21 +285,15 @@ function ChatInputInner({ const fileInputRef = useRef(null) const canSubmit = (Boolean(message.trim()) || attachments.length > 0) && !isProcessing - 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 - // list order, which can disagree with the real default. - const [defaultModel, setDefaultModel] = useState(null) - const loadModelConfigEpoch = useRef(0) + // Shared model-catalog store (one fetch app-wide); sign-in state also + // gates search availability below. + const { isRowboatConnected, refresh: refreshModels } = useModels() + const [selectedModel, setSelectedModel] = useState(null) const [lockedModel, setLockedModel] = useState(null) - // '' = auto. Per-model reasoning capability ("provider/model" → flag) from - // models:list; the effort control renders only for known-reasoning models. + // '' = auto. Effort is per-turn config: reported up, never persisted. const [reasoningEffort, setReasoningEffort] = useState<'' | ReasoningEffortLevel>('') - const [reasoningByKey, setReasoningByKey] = useState>({}) const [searchEnabled, setSearchEnabled] = useState(false) const [searchAvailable, setSearchAvailable] = useState(false) - const [isRowboatConnected, setIsRowboatConnected] = useState(false) const [codingAgent, setCodingAgent] = useState<'claude' | 'codex'>('claude') const [codeModeEnabled, setCodeModeEnabled] = useState(false) const [codeModeFeatureEnabled, setCodeModeFeatureEnabled] = useState(false) @@ -455,7 +333,7 @@ function ChatInputInner({ // no-dep effect below still re-collapses if any toggle happens to widen the row. useLayoutEffect(() => { setCollapseLevel(0) - }, [workDir, searchAvailable, codeModeFeatureEnabled, lockedModel, activeModelKey]) + }, [workDir, searchAvailable, codeModeFeatureEnabled, lockedModel, selectedModel]) // After each render, if the left group still overflows, collapse one more step. // Runs before paint, so the intermediate (overflowing) state is never visible. @@ -487,155 +365,17 @@ function ChatInputInner({ } }, []) - // Check Rowboat sign-in state + // The store loads on mount and re-fetches on config/sign-in events by + // itself; re-fetch on tab activation too, preserving the old per-mount + // reload that picked up external edits to config/models.json. + const didLoadModelsRef = useRef(false) useEffect(() => { - window.ipc.invoke('oauth:getState', null).then((result) => { - setIsRowboatConnected(result.config?.rowboat?.connected ?? false) - }).catch(() => setIsRowboatConnected(false)) - }, [isActive]) - - // Update sign-in state when OAuth events fire - useEffect(() => { - const cleanup = window.ipc.on('oauth:didConnect', () => { - window.ipc.invoke('oauth:getState', null).then((result) => { - setIsRowboatConnected(result.config?.rowboat?.connected ?? false) - }).catch(() => setIsRowboatConnected(false)) - }) - return cleanup - }, []) - - // Load the list of models the user can choose from. Hybrid mode: signed-in - // users get the gateway list AND every BYOK provider configured in - // models.json (selecting a BYOK model routes that message through the - // user's own key / local server). Signed-out users get BYOK only. - const loadModelConfig = useCallback(async () => { - // Concurrent runs race (mount fires one before the sign-in state resolves, - // which fires another) — only the newest run may write state, else a slow - // stale run can clobber the fresh list with an empty one. - const epoch = ++loadModelConfigEpoch.current - try { - const def = await window.ipc.invoke('llm:getDefaultModel', null) - if (loadModelConfigEpoch.current !== epoch) return - setDefaultModel({ provider: def.provider as ProviderName, model: def.model }) - } catch { - if (loadModelConfigEpoch.current === epoch) setDefaultModel(null) + if (!didLoadModelsRef.current) { + didLoadModelsRef.current = true + return } - try { - const groups: ModelPickerGroup[] = [] - - // Full catalog per provider (gateway + models.dev cloud providers). - const catalog: Record = {} - const reasoningFlags: Record = {} - try { - const listResult = await window.ipc.invoke('models:list', null) - for (const p of listResult.providers || []) { - catalog[p.id] = (p.models || []).map((m: { id: string }) => m.id) - for (const m of p.models || []) { - if (typeof m.reasoning === 'boolean') { - reasoningFlags[`${p.id}/${m.id}`] = m.reasoning - } - } - } - } catch { /* offline / no catalog — groups fall back to saved config below */ } - if (loadModelConfigEpoch.current === epoch) setReasoningByKey(reasoningFlags) - - if (isRowboatConnected && (catalog['rowboat'] || []).length > 0) { - groups.push({ kind: 'catalog', flavor: 'rowboat', models: catalog['rowboat'] }) - } - - // ChatGPT subscription (codex): models:list only carries this catalog - // while signed in with ChatGPT, so presence is the gate. - if ((catalog['codex'] || []).length > 0) { - groups.push({ kind: 'catalog', flavor: 'codex', models: catalog['codex'] }) - } - - try { - const result = await window.ipc.invoke('workspace:readFile', { path: 'config/models.json' }) - const parsed = JSON.parse(result.data) - - // List the default provider's group first. - const defaultFlavor = typeof parsed?.provider?.flavor === 'string' ? parsed.provider.flavor : '' - const flavors = Object.keys(parsed?.providers || {}) - .sort((a, b) => (a === defaultFlavor ? -1 : b === defaultFlavor ? 1 : 0)) - - for (const flavor of flavors) { - const e = (parsed.providers[flavor] || {}) as Record - const apiKey = typeof e.apiKey === 'string' ? e.apiKey.trim() : '' - const baseURL = typeof e.baseURL === 'string' ? e.baseURL.trim() : '' - if (!apiKey && !baseURL) continue // provider not configured - const savedModel = typeof e.model === 'string' ? e.model : '' - - // Live flavors fetch their list from the provider inside the - // dropdown, with the credentials saved in config. Catalog flavors - // degrade to the same live fetch when models:list carried no - // catalog for them (signed in, or empty models.dev cache). - const catalogModels = catalog[flavor] || [] - if (LIVE_PICKER_FLAVORS.has(flavor) || (catalogModels.length === 0 && LIVE_FALLBACK_FLAVORS.has(flavor))) { - groups.push({ kind: 'live', flavor: flavor as ProviderModelsFlavor, apiKey, baseURL, savedModel }) - continue - } - - // Catalog group: the saved default model leads, then the catalog. - // Saved models[] survives as the fallback for unknown flavors the - // live fetch doesn't support. - const models: string[] = [] - const push = (model: string) => { - if (model && !models.includes(model)) models.push(model) - } - push(savedModel) - if (catalogModels.length > 0) { - for (const m of catalogModels) push(m) - } else { - const saved = Array.isArray(e.models) ? e.models as string[] : [] - for (const m of saved) push(m) - } - groups.push({ kind: 'catalog', flavor, models }) - } - - // The user's explicit default selection leads the picker: its group - // first and, within a catalog group, the model itself first. (Live - // groups pin the default at the top themselves.) - const sel = parsed?.defaultSelection - if (sel && typeof sel.provider === 'string' && typeof sel.model === 'string') { - const index = groups.findIndex((g) => g.flavor === sel.provider) - if (index >= 0) { - const [group] = groups.splice(index, 1) - groups.unshift(group) - if (group.kind === 'catalog') { - const mi = group.models.indexOf(sel.model) - if (mi > 0) { - group.models.splice(mi, 1) - group.models.unshift(sel.model) - } - } - } - } - } catch { /* no BYOK config yet */ } - - if (loadModelConfigEpoch.current !== epoch) return - setModelGroups(groups) - } catch (err) { - // No config yet — but surface unexpected failures for diagnosis. - console.error('[chat-input] failed to load model list', err) - } - }, [isRowboatConnected]) - - useEffect(() => { - loadModelConfig() - }, [isActive, loadModelConfig]) - - // ChatGPT subscription models appear/disappear with the ChatGPT session. - useEffect(() => { - const cleanup = window.ipc.on('chatgpt:statusChanged', () => { loadModelConfig() }) - return cleanup - }, [loadModelConfig]) - - // Reload when model config changes (e.g. from settings dialog) - useEffect(() => { - const handler = () => { loadModelConfig() } - window.addEventListener('models-config-changed', handler) - return () => window.removeEventListener('models-config-changed', handler) - }, [loadModelConfig]) + refreshModels() + }, [isActive, refreshModels]) // Load the global code-mode feature flag (from settings) and stay in sync. useEffect(() => { @@ -809,83 +549,31 @@ function ChatInputInner({ checkSearch() }, [isActive, isRowboatConnected]) - // Search filter for the model dropdown. Reset each time the menu opens; - // matching is a case-insensitive substring test on the model id. Live - // groups filter themselves and report whether they still have rows, so the - // parent can render the global "No models match" row. - const [modelFilter, setModelFilter] = useState('') - const modelFilterInputRef = useRef(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 - // 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]) - - const standaloneVisible = standaloneDefault !== null && - (!modelFilterValue || standaloneDefault.model.toLowerCase().includes(modelFilterValue)) - // Nothing matches anywhere → "No models match". Live groups that haven't - // reported yet (first render after opening) count as having rows so the - // empty row never flashes. - const anyModelRowVisible = standaloneVisible || modelGroups.some((g) => - g.kind === 'catalog' - ? g.models.some((m) => m.toLowerCase().includes(modelFilterValue)) - : liveGroupHasRows[g.flavor] !== false) - // Selecting a model affects the *next* run created from this tab (frozen // once a run exists) AND persists as the app default so background agents // and new tabs follow the last pick. The models-config-changed dispatch - // re-runs loadModelConfig here too — that re-read lands on the same - // selection (activeModelKey is untouched, the live lists come from the - // hook's cache), so it's visually a no-op. - const handleModelChange = useCallback((key: string) => { + // re-fetches the shared store too — that re-read lands on the same + // selection (selectedModel is untouched, the live lists come from the + // useProviderModels cache), so it's visually a no-op. + const handleModelChange = useCallback((model: SelectedModel | null) => { if (lockedModel) return - const slash = key.indexOf('/') - if (slash <= 0 || slash === key.length - 1) return - const provider = key.slice(0, slash) - const model = key.slice(slash + 1) - setActiveModelKey(key) - onSelectedModelChange?.({ provider, model }) - void window.ipc.invoke('models:updateConfig', { defaultSelection: { provider, model } }) + // null = the sentinel row, which the composer never renders (no + // defaultOption) — guard for the widened onChange contract only. + if (!model) return + setSelectedModel(model) + onSelectedModelChange?.(model) + void window.ipc.invoke('models:updateConfig', { defaultSelection: { provider: model.provider, model: model.model } }) .then(() => { window.dispatchEvent(new Event('models-config-changed')) }) .catch(() => { toast.error('Failed to save default model') }) }, [lockedModel, onSelectedModelChange]) - // Reasoning effort applies to the model the next message will actually use: - // the run's frozen model once one exists, else the picker selection, else - // the app default. Only known-reasoning models show the control. - const effectiveModelKey = lockedModel - ? `${lockedModel.provider}/${lockedModel.model}` - : activeModelKey - || (defaultModel ? `${defaultModel.provider}/${defaultModel.model}` : '') - const reasoningAvailable = reasoningByKey[effectiveModelKey] === true - - const handleReasoningEffortChange = useCallback((value: string) => { - const effort = value === 'low' || value === 'medium' || value === 'high' ? value : '' + // Effort is per-turn and unpersisted; ModelSelector reports '' when the + // effective model loses reasoning support so a stale effort never sticks. + const handleReasoningEffortChange = useCallback((effort: '' | ReasoningEffortLevel) => { setReasoningEffort(effort) onReasoningEffortChange?.(effort === '' ? null : effort) }, [onReasoningEffortChange]) - // Switching to a model without reasoning support drops a stale selection — - // otherwise the next message would carry an effort the model rejects. - useEffect(() => { - if (!reasoningAvailable && reasoningEffort !== '') { - setReasoningEffort('') - onReasoningEffortChange?.(null) - } - }, [reasoningAvailable, reasoningEffort, onReasoningEffortChange]) - // Restore the tab draft when this input mounts. useEffect(() => { if (initialDraft) { @@ -1477,165 +1165,13 @@ function ChatInputInner({ )}
- {reasoningAvailable && ( - - - - - - - - Reasoning effort — applies to your next message - - - - {REASONING_EFFORT_OPTIONS.map((option) => ( - - {option.label} - {option.hint} - - ))} - - - - )} - {lockedModel ? ( - - - - {getSelectedModelDisplayName(lockedModel.model)} - - - - {providerDisplayNames[lockedModel.provider] || lockedModel.provider} — fixed for this chat - - - ) : ( - { - // 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) - } - }} - > - - - - - {modelGroups.length === 0 && !standaloneDefault ? ( -
- 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" - /> -
-
- - {standaloneDefault && standaloneVisible && ( - - {standaloneDefault.model} - - {providerDisplayNames[standaloneDefault.provider] || standaloneDefault.provider} - - - )} - {modelGroups.map((g) => { - const label = providerDisplayNames[g.flavor] || g.flavor - if (g.kind === 'live') { - // The app default leads its live group; the group's - // own saved model follows (both stay pickable through - // fetch loading/failure). - const pinned: string[] = [] - if (defaultModel && defaultModel.provider === g.flavor) pinned.push(defaultModel.model) - if (g.savedModel && !pinned.includes(g.savedModel)) pinned.push(g.savedModel) - return ( - - ) - } - const visibleModels = modelFilterValue - ? g.models.filter((m) => m.toLowerCase().includes(modelFilterValue)) - : g.models - if (visibleModels.length === 0) return null - return ( - - - {label} - - {visibleModels.map((m) => { - const key = `${g.flavor}/${m}` - return ( - - {m} - - ) - })} - - ) - })} - {modelFilterValue && !anyModelRowVisible && ( -
No models match
- )} -
-
- - )} -
-
- )} + {onStartCall && (
diff --git a/apps/x/apps/renderer/src/components/code/code-agent-options.ts b/apps/x/apps/renderer/src/components/code/code-agent-options.ts index 9bbe6db1..a360d565 100644 --- a/apps/x/apps/renderer/src/components/code/code-agent-options.ts +++ b/apps/x/apps/renderer/src/components/code/code-agent-options.ts @@ -23,6 +23,20 @@ export function withDefault(options: CodeAgentOption[]): CodeAgentOption[] { return options.some((o) => o.value === 'default') ? options : [{ value: 'default', label: 'Default' }, ...options] } +// Adapt an engine option list for ModelSelector: the 'default' entry becomes +// the sentinel (keeping the engine's own label, e.g. Claude's "Default +// (recommended)"), the rest become staticOptions rows. +export function toSelectorOptions(options: CodeAgentOption[]): { + defaultLabel: string + options: Array<{ id: string; label?: string }> +} { + const all = withDefault(options) + return { + defaultLabel: all.find((o) => o.value === 'default')?.label ?? 'Default', + options: all.filter((o) => o.value !== 'default').map((o) => ({ id: o.value, label: o.label })), + } +} + export function optionLabel(options: CodeAgentOption[], value: string | undefined): string { return options.find((o) => o.value === (value ?? 'default'))?.label ?? value ?? 'Default' } diff --git a/apps/x/apps/renderer/src/components/code/code-view.tsx b/apps/x/apps/renderer/src/components/code/code-view.tsx index 90654307..f070bb82 100644 --- a/apps/x/apps/renderer/src/components/code/code-view.tsx +++ b/apps/x/apps/renderer/src/components/code/code-view.tsx @@ -1,7 +1,8 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { Bot, ChevronDown, ChevronUp, Code2, GitBranch, Terminal as TerminalIcon } from 'lucide-react' import type { CodeSession, CodeSessionStatus, CodeAgentModelOptions } from '@x/shared/src/code-sessions.js' -import { fetchCodeAgentOptions, withDefault, optionLabel } from './code-agent-options' +import { fetchCodeAgentOptions, toSelectorOptions, withDefault, optionLabel } from './code-agent-options' +import { ModelSelector } from '@/components/model-selector' import type { ApprovalPolicy } from '@x/shared/src/code-mode.js' import { toast } from 'sonner' import { Button } from '@/components/ui/button' @@ -228,27 +229,15 @@ export function CodeView({
- - - - - - {withDefault(modelOpts.models).map((m) => ( - void handleUpdateSession({ agentModel: m.value })}> - {m.label} - {(selectedSession.agentModel ?? 'default') === m.value && } - - ))} - - + void handleUpdateSession({ agentModel: ref?.model ?? 'default' })} + /> {modelOpts.efforts.length > 0 && ( diff --git a/apps/x/apps/renderer/src/components/code/new-session-dialog.tsx b/apps/x/apps/renderer/src/components/code/new-session-dialog.tsx index 84d8a1f5..a1039994 100644 --- a/apps/x/apps/renderer/src/components/code/new-session-dialog.tsx +++ b/apps/x/apps/renderer/src/components/code/new-session-dialog.tsx @@ -1,7 +1,9 @@ import { useEffect, useState } from 'react' import { Bot, GitBranch, Loader2, Terminal } from 'lucide-react' import type { CodeSession, CodeSessionMode, CodeAgentModelOptions } from '@x/shared/src/code-sessions.js' -import { fetchCodeAgentOptions, withDefault } from './code-agent-options' +import { fetchCodeAgentOptions, toSelectorOptions, withDefault } from './code-agent-options' +import { ModelSelector, type ModelRef } from '@/components/model-selector' +import { useModels } from '@/hooks/use-models' import type { ApprovalPolicy, CodingAgent } from '@x/shared/src/code-mode.js' import { cn } from '@/lib/utils' import { toast } from 'sonner' @@ -25,7 +27,6 @@ import { import type { ProjectRow } from './use-code-sessions' type AgentStatus = { installed: boolean; signedIn: boolean } -type ModelOption = { provider: string; model: string } const POLICY_LABEL: Record = { ask: 'Ask every time', @@ -33,38 +34,6 @@ const POLICY_LABEL: Record = { yolo: 'Auto-approve everything (YOLO)', } -// Models the user can pick for Rowboat-mode turns — mirrors the chat -// composer's loading: gateway list when signed in, models.json otherwise. -async function loadModelOptions(): Promise { - try { - const oauth = await window.ipc.invoke('oauth:getState', null) - const connected = oauth.config?.rowboat?.connected ?? false - if (connected) { - const listResult = await window.ipc.invoke('models:list', null) - const rowboatProvider = (listResult.providers as Array<{ id: string; models?: Array<{ id: string }> }> | undefined) - ?.find((p) => p.id === 'rowboat') - return (rowboatProvider?.models ?? []).map((m) => ({ provider: 'rowboat', model: m.id })) - } - const result = await window.ipc.invoke('workspace:readFile', { path: 'config/models.json' }) - const parsed = JSON.parse(result.data) - const models: ModelOption[] = [] - if (parsed?.providers) { - for (const [flavor, entry] of Object.entries(parsed.providers)) { - const e = entry as Record - const modelList: string[] = Array.isArray(e.models) ? e.models as string[] : [] - const singleModel = typeof e.model === 'string' ? e.model : '' - const allModels = modelList.length > 0 ? modelList : singleModel ? [singleModel] : [] - for (const model of allModels) { - if (model) models.push({ provider: flavor, model }) - } - } - } - return models - } catch { - return [] - } -} - export function NewSessionDialog({ projectRow, open, @@ -84,9 +53,11 @@ export function NewSessionDialog({ const [isolation, setIsolation] = useState<'in-repo' | 'worktree'>('in-repo') const [title, setTitle] = useState('') const [creating, setCreating] = useState(false) - const [modelOptions, setModelOptions] = useState([]) - // 'default' = let the backend use the configured default model. - const [modelKey, setModelKey] = useState('default') + // null = let the backend use the configured default model. + const [sessionModel, setSessionModel] = useState(null) + // Gates the Rowboat-mode picker the way the old options list did: no + // configured providers → no picker. + const { groups: modelGroups } = useModels() // The coding agent's own model + reasoning effort. 'default' leaves the // engine default. Choices are discovered live per agent (see effect below). const [agentModel, setAgentModel] = useState('default') @@ -102,10 +73,9 @@ export function NewSessionDialog({ setCreating(false) setIsolation('in-repo') setMode('direct') - setModelKey('default') + setSessionModel(null) setAgentModel('default') setAgentEffort('default') - void loadModelOptions().then(setModelOptions) void window.ipc.invoke('codeMode:checkAgentStatus', null).then((status) => { setAgentStatus(status) // Default to whichever agent is actually ready. @@ -138,9 +108,6 @@ export function NewSessionDialog({ if (!projectRow) return setCreating(true) try { - const picked = modelKey !== 'default' - ? modelOptions.find((m) => `${m.provider}/${m.model}` === modelKey) - : undefined const res = await window.ipc.invoke('codeSession:create', { projectId: projectRow.project.id, title: title.trim() || undefined, @@ -148,7 +115,7 @@ export function NewSessionDialog({ mode, policy, isolation, - ...(picked ? { model: picked.model, provider: picked.provider } : {}), + ...(sessionModel ? { model: sessionModel.model, provider: sessionModel.provider } : {}), ...(agentModel !== 'default' ? { agentModel } : {}), ...(modelOpts.efforts.length > 0 && agentEffort !== 'default' ? { agentEffort } : {}), }) @@ -303,20 +270,19 @@ export function NewSessionDialog({ {/* The coding agent's own model + reasoning effort, discovered live from the engine and applied to the ACP session each turn (so they stay editable from the session header later). Effort is a separate - axis only for Claude; Codex folds it into the model id. */} + axis only for Claude; Codex folds it into the model id — it stays + a plain Select deliberately: it's not model selection, so it's + out of ModelSelector's scope. */}
- + setAgentModel(ref?.model ?? 'default')} + />
{modelOpts.efforts.length > 0 && (
@@ -337,21 +303,15 @@ export function NewSessionDialog({ {/* The model only powers Rowboat's own turns; the coding agent uses its own configured model, so hide this entirely for direct sessions. */} - {mode === 'rowboat' && modelOptions.length > 0 && ( + {mode === 'rowboat' && modelGroups.length > 0 && (
- +

Used when Rowboat drives. Fixed once the session is created, like any chat.

diff --git a/apps/x/apps/renderer/src/components/live-note-sidebar.tsx b/apps/x/apps/renderer/src/components/live-note-sidebar.tsx index f3034827..b07e9b85 100644 --- a/apps/x/apps/renderer/src/components/live-note-sidebar.tsx +++ b/apps/x/apps/renderer/src/components/live-note-sidebar.tsx @@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button' import { Switch } from '@/components/ui/switch' import { Textarea } from '@/components/ui/textarea' import { Input } from '@/components/ui/input' +import { ModelSelector, modelOverrideToRef, refToModelOverride } from '@/components/model-selector' import { Play, Square, Loader2, Sparkles, AlertCircle, Plus, X, Check, Pencil, Radio, Repeat, Clock, Zap, @@ -605,19 +606,13 @@ function DetailsTab({ {showAdvanced && (
- Model - setDraft({ ...draft, model: e.target.value || undefined })} - placeholder="(global default)" - className="h-7 font-mono text-xs" - /> - Provider - setDraft({ ...draft, provider: e.target.value || undefined })} - placeholder="(global default)" - className="h-7 font-mono text-xs" + Model + setDraft({ ...draft, ...refToModelOverride(ref) })} />
diff --git a/apps/x/apps/renderer/src/components/model-selector.test.tsx b/apps/x/apps/renderer/src/components/model-selector.test.tsx new file mode 100644 index 00000000..ad39b5b8 --- /dev/null +++ b/apps/x/apps/renderer/src/components/model-selector.test.tsx @@ -0,0 +1,215 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { __resetModelsForTests } from '@/hooks/use-models' +import { ModelSelector } from './model-selector' + +// Radix popper content needs these in jsdom. +class ResizeObserverStub { + observe() {} + unobserve() {} + disconnect() {} +} +;(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = ResizeObserverStub +Element.prototype.scrollIntoView = () => {} + +// Same preload stub pattern as use-models.test.tsx: invoke routes by channel. +let handlers: Record Promise> = {} + +;(window as unknown as { ipc: unknown }).ipc = { + on: () => () => undefined, + invoke: (channel: string, args: unknown) => { + const handler = handlers[channel] + return handler ? handler(args) : Promise.reject(new Error(`no handler: ${channel}`)) + }, +} + +function serveTwoProviders(): void { + handlers['oauth:getState'] = async () => ({ config: { rowboat: { connected: false } } }) + handlers['llm:getDefaultModel'] = async () => ({ provider: 'openai', model: 'gpt-5.4' }) + handlers['models:list'] = async () => ({ + providers: [ + { id: 'openai', name: 'OpenAI', models: [{ id: 'gpt-5.4' }] }, + { id: 'anthropic', name: 'Anthropic', models: [{ id: 'claude-opus-4-8' }] }, + ], + }) + handlers['workspace:readFile'] = async () => ({ + data: JSON.stringify({ + provider: { flavor: 'openai' }, + model: 'gpt-5.4', + providers: { + openai: { apiKey: 'sk-a', model: 'gpt-5.4' }, + anthropic: { apiKey: 'sk-b', model: 'claude-opus-4-8' }, + }, + }), + }) +} + +async function openMenu(): Promise { + const trigger = screen.getByRole('button') + // Radix opens the menu from the trigger's keydown handler in jsdom + // (pointerdown would ALSO toggle — one gesture only). + fireEvent.keyDown(trigger, { key: 'Enter' }) + await waitFor(() => expect(document.querySelector('[role="menu"]')).not.toBeNull()) +} + +beforeEach(() => { + __resetModelsForTests() + handlers = {} +}) + +afterEach(cleanup) + +describe('ModelSelector', () => { + it('renders the defaultOption label when value is null and round-trips null through onChange', async () => { + serveTwoProviders() + const onChange = vi.fn() + render( + , + ) + await waitFor(() => expect(screen.getByRole('button')).toHaveTextContent('gpt-5.4')) + + await openMenu() + const sentinel = await screen.findByText('Rowboat default') + fireEvent.click(sentinel) + expect(onChange).toHaveBeenCalledWith(null) + }) + + it('shows the sentinel as the trigger label when value is null', async () => { + serveTwoProviders() + render( + {}} + defaultOption={{ label: 'Same as assistant' }} + />, + ) + expect(screen.getByRole('button')).toHaveTextContent('Same as assistant') + }) + + it('providerFilter restricts the list to that provider', async () => { + serveTwoProviders() + render( + {}} + providerFilter="anthropic" + defaultOption={{ label: 'Same as assistant' }} + />, + ) + await openMenu() + await screen.findByText('claude-opus-4-8') + expect(screen.queryByText('gpt-5.4')).toBeNull() + }) + + it('inheritDefault renders its label muted in the trigger when value is null', () => { + serveTwoProviders() + render( + {}} + inheritDefault={{ label: '(global default)' }} + />, + ) + const label = screen.getByText('(global default)') + expect(label.className).toContain('text-muted-foreground') + }) + + it('un-scoped allowCustom splits "provider/model" on the first slash', async () => { + serveTwoProviders() + const onChange = vi.fn() + render( + , + ) + await openMenu() + fireEvent.change(screen.getByPlaceholderText('Search models…'), { + target: { value: 'openrouter/meituan/longcat-2.0' }, + }) + fireEvent.click(await screen.findByText('Use "openrouter/meituan/longcat-2.0"')) + expect(onChange).toHaveBeenCalledWith({ provider: 'openrouter', model: 'meituan/longcat-2.0' }) + }) + + it('un-scoped allowCustom pairs slash-less text with the default provider', async () => { + serveTwoProviders() + const onChange = vi.fn() + render( + , + ) + await openMenu() + // Wait for the store snapshot (the default provider comes from it). + await screen.findByText('claude-opus-4-8') + fireEvent.change(screen.getByPlaceholderText('Search models…'), { target: { value: 'my-local-model' } }) + fireEvent.click(await screen.findByText('Use "my-local-model"')) + expect(onChange).toHaveBeenCalledWith({ provider: 'openai', model: 'my-local-model' }) + }) + + it('staticOptions renders only the supplied rows and round-trips ids and null', async () => { + serveTwoProviders() + const onChange = vi.fn() + render( + , + ) + await openMenu() + // Only the caller's rows — nothing from the shared catalog store. + expect(screen.queryByText('gpt-5.4')).toBeNull() + expect(screen.queryByText('claude-opus-4-8', { selector: '[role="menuitemradio"] span' })).not.toBeNull() + // Colliding "Opus" labels are disambiguated by their raw id. + expect(screen.getAllByText('Opus')).toHaveLength(2) + + fireEvent.click(screen.getByText('Sonnet')) + expect(onChange).toHaveBeenCalledWith({ provider: '', model: 'sonnet' }) + + await openMenu() + fireEvent.click(screen.getByText('Default (recommended)', { selector: '[role="menuitemradio"] span' })) + expect(onChange).toHaveBeenCalledWith(null) + }) + + it('allowCustom offers the typed id when nothing matches', async () => { + serveTwoProviders() + const onChange = vi.fn() + render( + , + ) + await openMenu() + fireEvent.change(screen.getByPlaceholderText('Search models…'), { target: { value: 'my-custom-model' } }) + const custom = await screen.findByText('Use "my-custom-model"') + fireEvent.click(custom) + expect(onChange).toHaveBeenCalledWith({ provider: 'anthropic', model: 'my-custom-model' }) + }) +}) diff --git a/apps/x/apps/renderer/src/components/model-selector.tsx b/apps/x/apps/renderer/src/components/model-selector.tsx new file mode 100644 index 00000000..37750dfc --- /dev/null +++ b/apps/x/apps/renderer/src/components/model-selector.tsx @@ -0,0 +1,549 @@ +import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Brain, ChevronDown, LoaderIcon } from 'lucide-react' + +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { useModels, type ModelPickerGroup, type ModelRef } from '@/hooks/use-models' +import { useProviderModels } from '@/hooks/use-provider-models' +import { cn } from '@/lib/utils' + +export type { ModelRef } from '@/hooks/use-models' + +export type ReasoningEffortLevel = 'low' | 'medium' | 'high' + +const TOOLTIP_DELAY_MS = 1000 + +const providerDisplayNames: Record = { + openai: 'OpenAI', + anthropic: 'Anthropic', + google: 'Gemini', + ollama: 'Ollama', + openrouter: 'OpenRouter', + aigateway: 'AI Gateway', + 'openai-compatible': 'OpenAI-Compatible', + rowboat: 'Rowboat', + // Matches what other subscription clients call this provider; the auth + // itself is "Sign in with ChatGPT" (Plus/Pro subscription). + codex: 'OpenAI Codex', +} + +// '' = auto (provider default). Ordered as shown in the picker. +const REASONING_EFFORT_OPTIONS: Array<{ value: '' | ReasoningEffortLevel; label: string; hint: string }> = [ + { value: '', label: 'Auto', hint: 'Provider default' }, + { value: 'low', label: 'Fast', hint: 'Minimal thinking' }, + { value: 'medium', label: 'Balanced', hint: 'Moderate thinking' }, + { value: 'high', label: 'Thorough', hint: 'Deep thinking, costs more' }, +] + +function getModelDisplayName(model: string) { + return model.split('/').pop() || model +} + +// Rendered inside the dropdown's radio group: each live provider fetches its +// own list, so groups load and fail independently. Pinned models (the saved +// default / app default) render first — the model that actually runs is +// always pickable even while the fetch is pending or failed. Live-fetched +// ids carry no reasoning metadata, so the effort control stays hidden for +// them (reasoningByKey lookup misses default to off). +// +// The group owns its header so it can hide itself when the search filter +// matches none of its rows. Loading/error rows are status, not models — they +// render (with the header) regardless of the filter, and don't count toward +// the parent's "No models match" check (which is what gets reported up). +function LiveProviderGroupItems({ group, label, pinnedModels, filter, onModelRowsChange }: { + group: Extract + 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 ( + <> + {label} + {visible.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 + + )} + + ) +} + +// The standardized model picker (model-selection consolidation), mounted +// everywhere models are chosen. One controlled value/onChange contract with +// per-surface modes layered on as optional props: the composer's full +// catalog picker (provider groups, live fetches, search, reasoning effort), +// settings' default sentinel / field trigger / provider scoping / typed-id +// escape hatch, per-task "(global default)" inheritance, and caller-supplied +// restricted lists (coding-agent options). +export interface ModelSelectorProps { + /** Current selection; null follows the app default / the sentinel. */ + value: ModelRef | null + /** null only ever fires when defaultOption is set (sentinel picked). */ + onChange: (value: ModelRef | null) => void + /** + * Pinned top entry ("Rowboat default", "Same as assistant") that selects + * null. When set, a null value renders this label instead of the app + * default model. + */ + defaultOption?: { label: string } + /** + * Inheritance flavor of defaultOption for per-task overrides + * ("(global default)"): same sentinel row and null semantics, but null + * means "inherit the global default at runtime" and the trigger renders + * the label muted, so an un-overridden field reads like a placeholder. + * Mutually exclusive with defaultOption (defaultOption wins). + */ + inheritDefault?: { label: string } + /** + * 'pill' is the composer's compact rounded trigger; 'field' is a + * full-width bordered Select-style trigger for forms. + */ + variant?: 'pill' | 'field' + /** + * Restrict the picker to one provider's group (catalog or live). Providers + * not configured in models.json fall back to their static models:list + * catalog so a provider mid-setup still lists models. + */ + providerFilter?: string + /** + * When the search text matches no rows, offer a `Use ""` row that + * selects the typed id — arbitrary ids for ollama / openai-compatible. + * With providerFilter the typed id attaches to that provider. Without it, + * "provider/model" splits on the FIRST slash (so an OpenRouter id must be + * typed provider-qualified: "openrouter/meituan/longcat-2.0"); text with + * no slash attaches to the global default's provider. + */ + allowCustom?: boolean + /** + * Caller-supplied restricted list (e.g. a coding agent's own model + * options): the picker renders ONLY these rows plus the defaultOption + * sentinel — no catalog groups, no live fetches. Entries are opaque + * engine ids, not provider/model pairs, so the selected ref is + * {provider: '', model: id} — the id travels in .model and provider is + * meaningless. Search filters on label and id; rows whose label differs + * from their id show the id as secondary text (labels can collide, e.g. + * Claude lists both the 'opus' alias and the concrete id as "Opus"). + */ + staticOptions?: Array<{ id: string; label?: string }> + /** Optional title attribute for the trigger button (header tooltips). */ + triggerTitle?: string + /** Frozen selection: renders a static label + tooltip instead of the dropdown. */ + lockedModel?: ModelRef | null + /** + * Reasoning effort ('' = auto). The control renders only for models the + * catalog flags as reasoning-capable. When the effective model loses + * reasoning support, '' is reported up so a stale effort never outlives + * its model. Omit onEffortChange to hide the effort control entirely. + */ + effort?: '' | ReasoningEffortLevel + onEffortChange?: (effort: '' | ReasoningEffortLevel) => void +} + +// Radio value for the defaultOption sentinel row. Never a valid model key +// (real keys always contain "provider/"). +const DEFAULT_OPTION_KEY = '__default__' + +// Un-scoped custom entries can't know their provider, so the rule is: +// scoped → the scoped provider; "provider/model" → split on the FIRST +// slash; no slash → the global default's provider (matching how the +// runtime pairs a provider-less model override). +function parseCustomModel(text: string, providerFilter: string | undefined, defaultModel: ModelRef | null): ModelRef { + if (providerFilter) return { provider: providerFilter, model: text } + const slash = text.indexOf('/') + if (slash > 0 && slash < text.length - 1) { + return { provider: text.slice(0, slash), model: text.slice(slash + 1) } + } + return { provider: defaultModel?.provider ?? '', model: text } +} + +// Adapters for surfaces that persist a per-item override as two optional +// strings (BackgroundTask.model/provider, LiveNote.model/provider) where +// unset = inherit the global default. A model without a provider is legal +// (the runtime pairs it with the default provider), so '' round-trips to +// undefined and a null ref clears both fields. +export function modelOverrideToRef(model: string | undefined, provider: string | undefined): ModelRef | null { + return model ? { provider: provider ?? '', model } : null +} + +export function refToModelOverride(ref: ModelRef | null): { model: string | undefined; provider: string | undefined } { + return { model: ref?.model || undefined, provider: ref?.provider || undefined } +} + +export function ModelSelector({ + value, + onChange, + defaultOption, + inheritDefault, + variant = 'pill', + providerFilter, + allowCustom = false, + staticOptions, + triggerTitle, + lockedModel = null, + effort = '', + onEffortChange, +}: ModelSelectorProps) { + const { groups: allGroups, reasoningByKey, defaultModel, catalogByProvider } = useModels() + + // inheritDefault is defaultOption with placeholder styling — one sentinel + // code path, not two. + const sentinel = defaultOption ?? inheritDefault + const sentinelMuted = !defaultOption && Boolean(inheritDefault) + + const groups = useMemo(() => { + if (!providerFilter) return allGroups + const scoped = allGroups.filter((g) => g.flavor === providerFilter) + if (scoped.length > 0) return scoped + const catalogModels = catalogByProvider[providerFilter] || [] + return catalogModels.length > 0 ? [{ kind: 'catalog', flavor: providerFilter, models: catalogModels }] : [] + }, [allGroups, providerFilter, catalogByProvider]) + + // Search filter for the model dropdown. Reset each time the menu opens; + // matching is a case-insensitive substring test on the model id. Live + // groups filter themselves and report whether they still have rows, so the + // parent can render the global "No models match" row. + const [modelFilter, setModelFilter] = useState('') + const modelFilterInputRef = useRef(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 + // pin the default themselves, so a flavor match is enough there. A + // provider-scoped picker only shows it when it belongs to that provider. + const standaloneDefault = useMemo(() => { + if (!defaultModel) return null + if (providerFilter && defaultModel.provider !== providerFilter) return null + const covered = groups.some((g) => + g.flavor === defaultModel.provider && + (g.kind === 'live' || g.models.includes(defaultModel.model))) + return covered ? null : defaultModel + }, [groups, defaultModel, providerFilter]) + + const standaloneVisible = standaloneDefault !== null && + (!modelFilterValue || standaloneDefault.model.toLowerCase().includes(modelFilterValue)) + // Static mode replaces all store-driven rows with the caller's list. + const staticVisible = useMemo(() => { + if (!staticOptions) return null + if (!modelFilterValue) return staticOptions + return staticOptions.filter((o) => + (o.label ?? o.id).toLowerCase().includes(modelFilterValue) || o.id.toLowerCase().includes(modelFilterValue)) + }, [staticOptions, modelFilterValue]) + const staticLabelFor = (id: string) => staticOptions?.find((o) => o.id === id)?.label ?? id + // Nothing matches anywhere → "No models match". Live groups that haven't + // reported yet (first render after opening) count as having rows so the + // empty row never flashes. + const anyModelRowVisible = staticVisible + ? staticVisible.length > 0 + : standaloneVisible || groups.some((g) => + g.kind === 'catalog' + ? g.models.some((m) => m.toLowerCase().includes(modelFilterValue)) + : liveGroupHasRows[g.flavor] !== false) + + const handleModelChange = useCallback((key: string) => { + if (lockedModel) return + if (key === DEFAULT_OPTION_KEY) { + onChange(null) + return + } + // Static keys are opaque engine ids — no provider/model split (ids may + // themselves contain slashes). + if (staticOptions) { + onChange({ provider: '', model: key }) + return + } + const slash = key.indexOf('/') + if (slash <= 0 || slash === key.length - 1) return + onChange({ provider: key.slice(0, slash), model: key.slice(slash + 1) }) + }, [lockedModel, onChange, staticOptions]) + + // Reasoning effort applies to the model the next message will actually use: + // the frozen model when locked, else the picker selection, else the app + // default. Only known-reasoning models show the control. + const effectiveModelKey = lockedModel + ? `${lockedModel.provider}/${lockedModel.model}` + : (value ? `${value.provider}/${value.model}` : '') + || (defaultModel ? `${defaultModel.provider}/${defaultModel.model}` : '') + const reasoningAvailable = reasoningByKey[effectiveModelKey] === true + + const handleEffortChange = useCallback((raw: string) => { + onEffortChange?.(raw === 'low' || raw === 'medium' || raw === 'high' ? raw : '') + }, [onEffortChange]) + + // Switching to a model without reasoning support drops a stale selection — + // otherwise the next message would carry an effort the model rejects. + useEffect(() => { + if (!reasoningAvailable && effort !== '') { + onEffortChange?.('') + } + }, [reasoningAvailable, effort, onEffortChange]) + + return ( + <> + {reasoningAvailable && onEffortChange && ( + + + + + + + + Reasoning effort — applies to your next message + + + + {REASONING_EFFORT_OPTIONS.map((option) => ( + + {option.label} + {option.hint} + + ))} + + + + )} + {lockedModel ? ( + + + + {getModelDisplayName(lockedModel.model)} + + + + {providerDisplayNames[lockedModel.provider] || lockedModel.provider} — fixed for this chat + + + ) : ( + { + // 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) + } + }} + > + + {variant === 'field' ? ( + // Styled after ui/select's SelectTrigger so it sits naturally + // in forms next to real Select fields. + + ) : ( + + )} + + + {!staticOptions && 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 + if (g.kind === 'live') { + // The app default leads its live group; the group's + // own saved model follows (both stay pickable through + // fetch loading/failure). + const pinned: string[] = [] + if (defaultModel && defaultModel.provider === g.flavor) pinned.push(defaultModel.model) + if (g.savedModel && !pinned.includes(g.savedModel)) pinned.push(g.savedModel) + return ( + + ) + } + const visibleModels = modelFilterValue + ? g.models.filter((m) => m.toLowerCase().includes(modelFilterValue)) + : g.models + if (visibleModels.length === 0) return null + return ( + + + {label} + + {visibleModels.map((m) => { + const key = `${g.flavor}/${m}` + return ( + + {m} + + ) + })} + + ) + })} + {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
+ ) + )} +
+
+ + )} +
+
+ )} + + ) +} diff --git a/apps/x/apps/renderer/src/components/onboarding-modal.tsx b/apps/x/apps/renderer/src/components/onboarding-modal.tsx deleted file mode 100644 index bc9ff541..00000000 --- a/apps/x/apps/renderer/src/components/onboarding-modal.tsx +++ /dev/null @@ -1,1461 +0,0 @@ -"use client" - -import * as React from "react" -import { useState, useEffect, useCallback } from "react" -import { Loader2, Mic, Mail, Calendar, CheckCircle2, ArrowLeft, MessageSquare } from "lucide-react" - -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog" -import { Button } from "@/components/ui/button" -import { Switch } from "@/components/ui/switch" -import { Input } from "@/components/ui/input" -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select" -import { cn } from "@/lib/utils" -import { GoogleClientIdModal } from "@/components/google-client-id-modal" -import { InviteCodeClaim } from "@/components/invite-code-claim" -import { setGoogleCredentials } from "@/lib/google-credentials-store" -import { toast } from "sonner" -import { ComposioApiKeyModal } from "@/components/composio-api-key-modal" - -interface ProviderState { - isConnected: boolean - isLoading: boolean - isConnecting: boolean -} - -interface OnboardingModalProps { - open: boolean - onComplete: () => void -} - -type Step = 0 | 1 | 2 | 3 | 4 - -type OnboardingPath = 'rowboat' | 'byok' | null - -type LlmProviderFlavor = "openai" | "anthropic" | "google" | "openrouter" | "aigateway" | "ollama" | "openai-compatible" - -interface LlmModelOption { - id: string - name?: string - release_date?: string -} - -export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { - 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", - }) - // OAuth provider states - const [providers, setProviders] = useState([]) - const [providersLoading, setProvidersLoading] = useState(true) - const [providerStates, setProviderStates] = useState>({}) - const [googleClientIdOpen, setGoogleClientIdOpen] = useState(false) - - // Granola state - const [granolaEnabled, setGranolaEnabled] = useState(false) - const [granolaLoading, setGranolaLoading] = useState(true) - const [showMoreProviders, setShowMoreProviders] = useState(false) - - // Composio API key state - const [composioApiKeyOpen, setComposioApiKeyOpen] = useState(false) - const [, setComposioApiKeyTarget] = useState<'slack' | 'gmail'>('gmail') - - // Slack state (agent-slack CLI) - const [slackEnabled, setSlackEnabled] = useState(false) - const [slackLoading, setSlackLoading] = useState(true) - const [slackWorkspaces, setSlackWorkspaces] = useState>([]) - const [slackAvailableWorkspaces, setSlackAvailableWorkspaces] = useState>([]) - const [slackSelectedUrls, setSlackSelectedUrls] = useState>(new Set()) - const [slackPickerOpen, setSlackPickerOpen] = useState(false) - const [slackDiscovering, setSlackDiscovering] = useState(false) - const [slackDiscoverError, setSlackDiscoverError] = useState(null) - - // 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) - const [gmailConnected, setGmailConnected] = useState(false) - const [gmailLoading, setGmailLoading] = useState(true) - const [gmailConnecting, setGmailConnecting] = useState(false) - - const [useComposioForGoogleCalendar] = useState(false) - const [googleCalendarConnected, setGoogleCalendarConnected] = useState(false) - 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 = - activeConfig.model.trim().length > 0 && - (!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) - .map(([provider]) => provider) - - // Load available providers and composio-for-google flag on mount - useEffect(() => { - if (!open) return - - async function loadProviders() { - try { - setProvidersLoading(true) - const result = await window.ipc.invoke('oauth:list-providers', null) - setProviders(result.providers || []) - } catch (error) { - console.error('Failed to get available providers:', error) - setProviders([]) - } finally { - setProvidersLoading(false) - } - } - // (Composio Gmail/Calendar flag fetches removed — sync was deleted.) - 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) { - // Check if preferred default exists in the catalog - 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 { - setGranolaLoading(true) - const result = await window.ipc.invoke('granola:getConfig', null) - setGranolaEnabled(result.enabled) - } catch (error) { - console.error('Failed to load Granola config:', error) - setGranolaEnabled(false) - } finally { - setGranolaLoading(false) - } - }, []) - - // Update Granola config - const handleGranolaToggle = useCallback(async (enabled: boolean) => { - try { - setGranolaLoading(true) - await window.ipc.invoke('granola:setConfig', { enabled }) - setGranolaEnabled(enabled) - toast.success(enabled ? 'Granola sync enabled' : 'Granola sync disabled') - } catch (error) { - console.error('Failed to update Granola config:', error) - toast.error('Failed to update Granola sync settings') - } finally { - setGranolaLoading(false) - } - }, []) - - // Load Slack config - const refreshSlackConfig = useCallback(async () => { - try { - setSlackLoading(true) - const result = await window.ipc.invoke('slack:getConfig', null) - setSlackEnabled(result.enabled) - setSlackWorkspaces(result.workspaces || []) - } catch (error) { - console.error('Failed to load Slack config:', error) - setSlackEnabled(false) - setSlackWorkspaces([]) - } finally { - setSlackLoading(false) - } - }, []) - - // Enable Slack: discover workspaces - const handleSlackEnable = useCallback(async () => { - setSlackDiscovering(true) - setSlackDiscoverError(null) - try { - const result = await window.ipc.invoke('slack:listWorkspaces', null) - if (result.error || result.workspaces.length === 0) { - setSlackDiscoverError(result.error || 'No Slack workspaces found. Set up with: agent-slack auth import-desktop') - setSlackAvailableWorkspaces([]) - setSlackPickerOpen(true) - } else { - setSlackAvailableWorkspaces(result.workspaces) - setSlackSelectedUrls(new Set(result.workspaces.map((w: { url: string }) => w.url))) - setSlackPickerOpen(true) - } - } catch (error) { - console.error('Failed to discover Slack workspaces:', error) - setSlackDiscoverError('Failed to discover Slack workspaces') - setSlackPickerOpen(true) - } finally { - setSlackDiscovering(false) - } - }, []) - - // Load Gmail connection status - const refreshGmailStatus = useCallback(async () => { - try { - setGmailLoading(true) - const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'gmail' }) - setGmailConnected(result.isConnected) - } catch (error) { - console.error('Failed to load Gmail status:', error) - setGmailConnected(false) - } finally { - setGmailLoading(false) - } - }, []) - - // Load Google Calendar connection status - const refreshGoogleCalendarStatus = useCallback(async () => { - try { - setGoogleCalendarLoading(true) - const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'googlecalendar' }) - setGoogleCalendarConnected(result.isConnected) - } catch (error) { - console.error('Failed to load Google Calendar status:', error) - setGoogleCalendarConnected(false) - } finally { - setGoogleCalendarLoading(false) - } - }, []) - - // Connect to Gmail via Composio - const startGmailConnect = useCallback(async () => { - try { - setGmailConnecting(true) - const result = await window.ipc.invoke('composio:initiate-connection', { toolkitSlug: 'gmail' }) - if (!result.success) { - toast.error(result.error || 'Failed to connect to Gmail') - setGmailConnecting(false) - } - } catch (error) { - console.error('Failed to connect to Gmail:', error) - toast.error('Failed to connect to Gmail') - setGmailConnecting(false) - } - }, []) - - // Handle Gmail connect button click - const handleConnectGmail = useCallback(async () => { - const configResult = await window.ipc.invoke('composio:is-configured', null) - if (!configResult.configured) { - setComposioApiKeyTarget('gmail') - setComposioApiKeyOpen(true) - return - } - await startGmailConnect() - }, [startGmailConnect]) - - // Connect to Google Calendar via Composio - const startGoogleCalendarConnect = useCallback(async () => { - try { - setGoogleCalendarConnecting(true) - const result = await window.ipc.invoke('composio:initiate-connection', { toolkitSlug: 'googlecalendar' }) - if (!result.success) { - toast.error(result.error || 'Failed to connect to Google Calendar') - setGoogleCalendarConnecting(false) - } - } catch (error) { - console.error('Failed to connect to Google Calendar:', error) - toast.error('Failed to connect to Google Calendar') - setGoogleCalendarConnecting(false) - } - }, []) - - // Handle Google Calendar connect button click - const handleConnectGoogleCalendar = useCallback(async () => { - const configResult = await window.ipc.invoke('composio:is-configured', null) - if (!configResult.configured) { - setComposioApiKeyTarget('gmail') - setComposioApiKeyOpen(true) - return - } - await startGoogleCalendarConnect() - }, [startGoogleCalendarConnect]) - - // Handle Composio API key submission - const handleComposioApiKeySubmit = useCallback(async (apiKey: string) => { - try { - await window.ipc.invoke('composio:set-api-key', { apiKey }) - setComposioApiKeyOpen(false) - toast.success('Composio API key saved') - await startGmailConnect() - } catch (error) { - console.error('Failed to save Composio API key:', error) - toast.error('Failed to save API key') - } - }, [startGmailConnect]) - - // Save selected Slack workspaces - const handleSlackSaveWorkspaces = useCallback(async () => { - const selected = slackAvailableWorkspaces.filter(w => slackSelectedUrls.has(w.url)) - try { - setSlackLoading(true) - await window.ipc.invoke('slack:setConfig', { enabled: true, workspaces: selected }) - setSlackEnabled(true) - setSlackWorkspaces(selected) - setSlackPickerOpen(false) - toast.success('Slack enabled') - } catch (error) { - console.error('Failed to save Slack config:', error) - toast.error('Failed to save Slack settings') - } finally { - setSlackLoading(false) - } - }, [slackAvailableWorkspaces, slackSelectedUrls]) - - // Disable Slack - const handleSlackDisable = useCallback(async () => { - try { - setSlackLoading(true) - await window.ipc.invoke('slack:setConfig', { enabled: false, workspaces: [] }) - setSlackEnabled(false) - setSlackWorkspaces([]) - setSlackPickerOpen(false) - toast.success('Slack disabled') - } catch (error) { - console.error('Failed to update Slack config:', error) - toast.error('Failed to update Slack settings') - } finally { - setSlackLoading(false) - } - }, []) - - const handleNext = () => { - if (currentStep < 4) { - setCurrentStep((prev) => (prev + 1) as Step) - } - } - - const handleBack = () => { - if (currentStep === 1) { - // BYOK upsell → back to sign-in page - setOnboardingPath(null) - setCurrentStep(0 as Step) - } else if (currentStep === 2) { - // LLM setup → back to BYOK upsell - setCurrentStep(1 as Step) - } else if (currentStep === 3) { - // Connect accounts → back depends on path - if (onboardingPath === 'rowboat') { - setCurrentStep(0 as Step) - } else { - setCurrentStep(2 as Step) - } - } - } - - const handleComplete = () => { - onComplete() - } - - const handleTestAndSaveLlmConfig = useCallback(async () => { - if (!canTest) return - setTestState({ status: "testing" }) - try { - const apiKey = activeConfig.apiKey.trim() || undefined - const baseURL = activeConfig.baseURL.trim() || undefined - const model = activeConfig.model.trim() - const knowledgeGraphModel = activeConfig.knowledgeGraphModel.trim() || undefined - const meetingNotesModel = activeConfig.meetingNotesModel.trim() || undefined - const liveNoteAgentModel = activeConfig.liveNoteAgentModel.trim() || undefined - const providerConfig = { - provider: { - flavor: llmProvider, - apiKey, - baseURL, - }, - model, - knowledgeGraphModel, - meetingNotesModel, - liveNoteAgentModel, - } - const result = await window.ipc.invoke("models:test", providerConfig) - if (result.success) { - setTestState({ status: "success" }) - // Save and continue - await window.ipc.invoke("models:saveConfig", providerConfig) - handleNext() - } else { - setTestState({ status: "error", error: result.error }) - toast.error(result.error || "Connection test failed") - } - } catch (error) { - console.error("Connection test failed:", error) - setTestState({ status: "error", error: "Connection test failed" }) - toast.error("Connection test failed") - } - }, [activeConfig.apiKey, activeConfig.baseURL, activeConfig.model, canTest, llmProvider, handleNext]) - - // Check connection status for all providers - const refreshAllStatuses = useCallback(async () => { - // Refresh Granola - refreshGranolaConfig() - - // Refresh Slack config - refreshSlackConfig() - - // Refresh Gmail Composio status if enabled - if (useComposioForGoogle) { - refreshGmailStatus() - } - - // Refresh Google Calendar Composio status if enabled - if (useComposioForGoogleCalendar) { - refreshGoogleCalendarStatus() - } - - // Refresh OAuth providers - if (providers.length === 0) return - - const newStates: Record = {} - - try { - const result = await window.ipc.invoke('oauth:getState', null) - const config = result.config || {} - for (const provider of providers) { - newStates[provider] = { - isConnected: config[provider]?.connected ?? false, - isLoading: false, - isConnecting: false, - } - } - } catch (error) { - console.error('Failed to check connection status for providers:', error) - for (const provider of providers) { - newStates[provider] = { - isConnected: false, - isLoading: false, - isConnecting: false, - } - } - } - - setProviderStates(newStates) - }, [providers, refreshGranolaConfig, refreshSlackConfig, refreshGmailStatus, useComposioForGoogle, refreshGoogleCalendarStatus, useComposioForGoogleCalendar]) - - // Refresh statuses when modal opens or providers list changes - useEffect(() => { - if (open && providers.length > 0) { - refreshAllStatuses() - } - }, [open, providers, refreshAllStatuses]) - - // Listen for OAuth completion events (state updates only — toasts handled by ConnectorsPopover) - useEffect(() => { - const cleanup = window.ipc.on('oauth:didConnect', (event) => { - const { provider, success } = event - - setProviderStates(prev => ({ - ...prev, - [provider]: { - isConnected: success, - isLoading: false, - isConnecting: false, - } - })) - }) - - return cleanup - }, []) - - // Auto-advance from Rowboat sign-in step when OAuth completes - useEffect(() => { - if (onboardingPath !== 'rowboat' || currentStep !== 0) return - - const cleanup = window.ipc.on('oauth:didConnect', (event) => { - if (event.provider === 'rowboat' && event.success) { - setCurrentStep(3 as Step) - } - }) - - return cleanup - }, [onboardingPath, currentStep]) - - // Listen for Composio connection events (state updates only — toasts handled by ConnectorsPopover) - useEffect(() => { - const cleanup = window.ipc.on('composio:didConnect', (event) => { - const { toolkitSlug, success } = event - - if (toolkitSlug === 'gmail') { - setGmailConnected(success) - setGmailConnecting(false) - } - - if (toolkitSlug === 'googlecalendar') { - setGoogleCalendarConnected(success) - setGoogleCalendarConnecting(false) - } - }) - - return cleanup - }, []) - - - const startConnect = useCallback(async (provider: string, credentials?: { clientId: string; clientSecret: string }) => { - setProviderStates(prev => ({ - ...prev, - [provider]: { ...prev[provider], isConnecting: true } - })) - - try { - const result = await window.ipc.invoke('oauth:connect', { provider, clientId: credentials?.clientId, clientSecret: credentials?.clientSecret }) - - if (!result.success) { - toast.error(result.error || `Failed to connect to ${provider}`) - setProviderStates(prev => ({ - ...prev, - [provider]: { ...prev[provider], isConnecting: false } - })) - } - } catch (error) { - console.error('Failed to connect:', error) - toast.error(`Failed to connect to ${provider}`) - setProviderStates(prev => ({ - ...prev, - [provider]: { ...prev[provider], isConnecting: false } - })) - } - }, []) - - // Connect to a provider - const handleConnect = useCallback(async (provider: string) => { - if (provider === 'google') { - // Signed-in users use the rowboat (managed-credentials) flow: opens - // the webapp in the browser, no BYOK modal. Falls back to BYOK modal - // for not-signed-in users. (Mirrors useConnectors.handleConnect.) - const isSignedIntoRowboat = providerStates.rowboat?.isConnected ?? false - if (isSignedIntoRowboat) { - await startConnect('google') - return - } - setGoogleClientIdOpen(true) - return - } - - await startConnect(provider) - }, [startConnect, providerStates]) - - const handleGoogleClientIdSubmit = useCallback((clientId: string, clientSecret: string) => { - setGoogleCredentials(clientId, clientSecret) - setGoogleClientIdOpen(false) - startConnect('google', { clientId, clientSecret }) - }, [startConnect]) - - // Step indicator - dynamic based on path - const renderStepIndicator = () => { - // Rowboat path: Sign In (0), Connect (3), Done (4) = 3 dots - // BYOK path: Sign In (0), Upsell (1), Model (2), Connect (3), Done (4) = 5 dots - // Before path is chosen: show 3 dots (minimal) - const rowboatSteps = [0, 3, 4] - const byokSteps = [0, 1, 2, 3, 4] - const steps = onboardingPath === 'byok' ? byokSteps : rowboatSteps - const currentIndex = steps.indexOf(currentStep) - - return ( -
- {steps.map((_, i) => ( -
= i ? "bg-primary" : "bg-muted" - )} - /> - ))} -
- ) - } - - // Helper to render an OAuth provider row - const renderOAuthProvider = (provider: string, displayName: string, icon: React.ReactNode, description: string) => { - const state = providerStates[provider] || { - isConnected: false, - isLoading: true, - isConnecting: false, - } - - return ( -
-
-
- {icon} -
-
- {displayName} - {state.isLoading ? ( - Checking... - ) : ( - {description} - )} -
-
-
- {state.isLoading ? ( - - ) : state.isConnected ? ( -
- - Connected -
- ) : ( - - )} -
-
- ) - } - - // Render Granola row - const renderGranolaRow = () => ( -
-
-
- -
-
- Granola - - Local meeting notes - -
-
-
- {granolaLoading && ( - - )} - -
-
- ) - - // Render Gmail Composio row - const renderGmailRow = () => ( -
-
-
- -
-
- Gmail - {gmailLoading ? ( - Checking... - ) : ( - - Sync emails - - )} -
-
-
- {gmailLoading ? ( - - ) : gmailConnected ? ( -
- - Connected -
- ) : ( - - )} -
-
- ) - - // Render Google Calendar Composio row - const renderGoogleCalendarRow = () => ( -
-
-
- -
-
- Google Calendar - {googleCalendarLoading ? ( - Checking... - ) : ( - - Sync calendar events - - )} -
-
-
- {googleCalendarLoading ? ( - - ) : googleCalendarConnected ? ( -
- - Connected -
- ) : ( - - )} -
-
- ) - - // Render Slack row - const renderSlackRow = () => ( -
-
-
-
- -
-
- Slack - {slackEnabled && slackWorkspaces.length > 0 ? ( - - {slackWorkspaces.map(w => w.name).join(', ')} - - ) : ( - - Send messages and view channels - - )} -
-
-
- {(slackLoading || slackDiscovering) && ( - - )} - {slackEnabled ? ( - handleSlackDisable()} - disabled={slackLoading} - /> - ) : ( - - )} -
-
- {slackPickerOpen && ( -
- {slackDiscoverError ? ( -

{slackDiscoverError}

- ) : ( - <> - {slackAvailableWorkspaces.map(w => ( - - ))} - - - )} -
- )} -
- ) - - // Step 0: Sign in to Rowboat (with BYOK option) - const renderSignInStep = () => { - const rowboatState = providerStates['rowboat'] || { isConnected: false, isLoading: false, isConnecting: false } - - return ( -
-
- Your AI coworker, with memory -
- - Sign in to Rowboat - - Connect your Rowboat account for instant access to all models through our gateway — no API keys needed. - - - - {rowboatState.isConnected ? ( -
-
- - Connected to Rowboat -
- -
- ) : ( -
- - {rowboatState.isConnecting && ( -

- Complete sign in in your browser, then return here. -

- )} -
- )} - -
- -
-
- ) - } - - // Step 1: BYOK upsell — explain benefits of Rowboat before continuing with BYOK - const renderByokUpsellStep = () => ( -
- - Before you continue - - With a Rowboat account, you get: - - - -
-
- -
-
Instant access to all models
-
GPT, Claude, Gemini, and more — no separate API keys needed
-
-
-
- -
-
Simplified billing
-
One account for everything — no juggling multiple provider subscriptions
-
-
-
- -
-
Automatic updates
-
New models are available as soon as they launch, with no configuration changes
-
-
-
- -

- By continuing, you'll set up your own API keys instead of using Rowboat's managed gateway. -

- -
- - -
-
- ) - - // Step 2 (BYOK path): LLM Setup - const renderLlmSetupStep = () => { - const primaryProviders: Array<{ id: LlmProviderFlavor; name: string; description: string }> = [ - { id: "openai", name: "OpenAI", description: "Use your OpenAI API key" }, - { id: "anthropic", name: "Anthropic", description: "Use your Anthropic API key" }, - { id: "google", name: "Gemini", description: "Use your Google AI Studio key" }, - { id: "ollama", name: "Ollama (Local)", description: "Run a local model via Ollama" }, - ] - - const moreProviders: Array<{ id: LlmProviderFlavor; name: string; description: string }> = [ - { id: "openrouter", name: "OpenRouter", description: "Access multiple models with one key" }, - { id: "aigateway", name: "AI Gateway (Vercel)", description: "Use Vercel's AI Gateway" }, - { id: "openai-compatible", name: "OpenAI-Compatible", description: "Local or hosted OpenAI-compatible API" }, - ] - - const isMoreProvider = moreProviders.some(p => p.id === llmProvider) - - const modelsForProvider = modelsCatalog[llmProvider] || [] - const showModelInput = isLocalProvider || modelsForProvider.length === 0 - - const renderProviderCard = (provider: { id: LlmProviderFlavor; name: string; description: string }) => ( - - ) - - return ( -
-
- Your AI coworker, with memory -
- - Choose your model - - -
-
- Provider -
- {primaryProviders.map(renderProviderCard)} -
- {(showMoreProviders || isMoreProvider) ? ( -
- {moreProviders.map(renderProviderCard)} -
- ) : ( - - )} -
- -
-
- Assistant model - {modelsLoading ? ( -
- - Loading... -
- ) : showModelInput ? ( - updateProviderConfig(llmProvider, { model: e.target.value })} - placeholder="Enter model" - /> - ) : ( - - )} - {modelsError && ( -
{modelsError}
- )} -
- -
- Knowledge graph model - {modelsLoading ? ( -
- - Loading... -
- ) : showModelInput ? ( - updateProviderConfig(llmProvider, { knowledgeGraphModel: e.target.value })} - placeholder={activeConfig.model || "Enter model"} - /> - ) : ( - - )} -
- -
- Meeting notes model - {modelsLoading ? ( -
- - Loading... -
- ) : showModelInput ? ( - updateProviderConfig(llmProvider, { meetingNotesModel: e.target.value })} - placeholder={activeConfig.model || "Enter model"} - /> - ) : ( - - )} -
- -
- Track block model - {modelsLoading ? ( -
- - Loading... -
- ) : showModelInput ? ( - updateProviderConfig(llmProvider, { liveNoteAgentModel: e.target.value })} - placeholder={activeConfig.model || "Enter model"} - /> - ) : ( - - )} -
-
- - {showApiKey && ( -
- - {llmProvider === "openai-compatible" ? "API Key (optional)" : "API Key"} - - updateProviderConfig(llmProvider, { apiKey: e.target.value })} - placeholder="Paste your API key" - /> -
- )} - - {showBaseURL && ( -
- Base URL - 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" - } - /> -
- )} -
- - {testState.status === "error" && ( -
- {testState.error || "Connection test failed"} -
- )} - -
- - -
-
- ) - } - - // Step 3: Connect Accounts - const renderAccountConnectionStep = () => ( -
- - Connect Your Accounts - - Connect your accounts to start syncing your data locally. You can always add more later. - - - -
- {providersLoading ? ( -
- -
- ) : ( - <> - {/* Email / Email & Calendar Section */} - {(useComposioForGoogle || useComposioForGoogleCalendar || providers.includes('google')) && ( -
-
- - {(useComposioForGoogle || useComposioForGoogleCalendar) ? 'Email & Calendar' : 'Email & Calendar'} - -
- {useComposioForGoogle - ? renderGmailRow() - : renderOAuthProvider('google', 'Google', , 'Sync emails and calendar events') - } - {useComposioForGoogleCalendar && renderGoogleCalendarRow()} -
- )} - - {/* Meeting Notes Section */} -
-
- Meeting Notes -
- {renderGranolaRow()} - {providers.includes('fireflies-ai') && renderOAuthProvider('fireflies-ai', 'Fireflies', , 'AI meeting transcripts')} -
- - {/* Team Communication Section */} -
-
- Team Communication -
- {renderSlackRow()} -
- - )} -
- -
- -
- - -
-
-
- ) - - // Step 4: Completion - const renderCompletionStep = () => { - const hasConnections = connectedProviders.length > 0 || granolaEnabled || slackEnabled || gmailConnected || googleCalendarConnected - - return ( -
-
- -
- - You're All Set! - - {hasConnections ? ( - <>Give me 30 minutes to build your context graph.
I can still help with other things on your computer. - ) : ( - <>You can connect your accounts anytime from the sidebar to start syncing data. - )} -
-
- - {hasConnections && ( -
-
-

Connected accounts:

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

- {renderSelect("Assistant model", selectedDefault, setSelectedDefault, "Rowboat default")} - {renderSelect("Knowledge graph model", selectedKg, setSelectedKg, "Rowboat default")} - {renderSelect("Background agents model", selectedLiveNote, setSelectedLiveNote, "Rowboat default")} - {renderSelect("Permission checks model", selectedAutoPermission, setSelectedAutoPermission, "Rowboat default")} + {renderModelField("Assistant model", selectedDefault, setSelectedDefault)} + {renderModelField("Knowledge graph model", selectedKg, setSelectedKg)} + {renderModelField("Background agents model", selectedLiveNote, setSelectedLiveNote)} + {renderModelField("Permission checks model", selectedAutoPermission, setSelectedAutoPermission)} {/* Save */}