diff --git a/apps/desktop/src/app/settings/model-settings.test.tsx b/apps/desktop/src/app/settings/model-settings.test.tsx index 3f81d238f..4ba6fd512 100644 --- a/apps/desktop/src/app/settings/model-settings.test.tsx +++ b/apps/desktop/src/app/settings/model-settings.test.tsx @@ -1,5 +1,5 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' // Radix Select calls scrollIntoView on its items when the content opens; jsdom @@ -21,7 +21,10 @@ const saveMoaModels = vi.fn() const setEnvVar = vi.fn() const getHermesConfigRecord = vi.fn() const saveHermesConfig = vi.fn() +const startManualLocalEndpoint = vi.fn() +const startManualOnboarding = vi.fn() const startManualProviderOAuth = vi.fn() +let profileSwitchHandler: (() => void) | null = null vi.mock('@/hermes', () => ({ getGlobalModelInfo: () => getGlobalModelInfo(), @@ -38,9 +41,17 @@ vi.mock('@/hermes', () => ({ })) vi.mock('@/store/onboarding', () => ({ + startManualLocalEndpoint: () => startManualLocalEndpoint(), + startManualOnboarding: () => startManualOnboarding(), startManualProviderOAuth: (slug: string) => startManualProviderOAuth(slug) })) +vi.mock('../hooks/use-on-profile-switch', () => ({ + useOnProfileSwitch: (handler: () => void) => { + profileSwitchHandler = handler + } +})) + beforeEach(() => { getGlobalModelInfo.mockResolvedValue({ provider: 'nous', model: 'hermes-4' }) getGlobalModelOptions.mockResolvedValue({ @@ -69,6 +80,7 @@ beforeEach(() => { afterEach(() => { cleanup() vi.clearAllMocks() + profileSwitchHandler = null }) async function renderModelSettings() { @@ -98,6 +110,103 @@ describe('ModelSettings', () => { expect(screen.queryByText(/DeepSeek/)).toBeNull() }) + it.each(['custom', 'local', 'custom:lab'])( + 'opens local endpoint setup when %s has no inventory row', + async provider => { + getGlobalModelInfo.mockResolvedValueOnce({ provider, model: '' }) + getGlobalModelOptions.mockResolvedValueOnce({ providers: [] }) + + await renderModelSettings() + + const providerSelect = (await screen.findAllByRole('combobox'))[0] + + expect(providerSelect.textContent).toContain(provider) + expect(screen.queryByText(/undefined/)).toBeNull() + expect(screen.queryByText(/signs in through your browser/)).toBeNull() + + fireEvent.click(await screen.findByRole('button', { name: 'Set up provider' })) + + expect(startManualLocalEndpoint).toHaveBeenCalledOnce() + expect(startManualOnboarding).not.toHaveBeenCalled() + expect(startManualProviderOAuth).not.toHaveBeenCalled() + } + ) + + it('opens the generic provider picker for an unknown provider with no inventory row', async () => { + getGlobalModelInfo.mockResolvedValueOnce({ provider: 'retired-provider', model: '' }) + getGlobalModelOptions.mockResolvedValueOnce({ providers: [] }) + + await renderModelSettings() + + fireEvent.click(await screen.findByRole('button', { name: 'Set up provider' })) + + expect(startManualOnboarding).toHaveBeenCalledOnce() + expect(startManualLocalEndpoint).not.toHaveBeenCalled() + expect(startManualProviderOAuth).not.toHaveBeenCalled() + }) + + it('deep-links a known OAuth provider row into its setup flow', async () => { + getGlobalModelInfo.mockResolvedValueOnce({ provider: 'anthropic', model: '' }) + getGlobalModelOptions.mockResolvedValueOnce({ + providers: [ + { + name: 'Anthropic', + slug: 'anthropic', + models: [], + authenticated: false, + auth_type: 'oauth' + } + ] + }) + + await renderModelSettings() + + fireEvent.click(await screen.findByRole('button', { name: 'Set up Anthropic' })) + + expect(startManualProviderOAuth).toHaveBeenCalledWith('anthropic') + expect(startManualLocalEndpoint).not.toHaveBeenCalled() + expect(startManualOnboarding).not.toHaveBeenCalled() + }) + + it('replaces the selected provider and model when the active profile changes', async () => { + getGlobalModelInfo + .mockResolvedValueOnce({ provider: 'custom', model: 'local-a' }) + .mockResolvedValueOnce({ provider: 'nous', model: 'hermes-4' }) + getGlobalModelOptions + .mockResolvedValueOnce({ + providers: [ + { + name: 'Custom A', + slug: 'custom', + models: ['local-a'], + authenticated: true + } + ] + }) + .mockResolvedValueOnce({ + providers: [ + { + name: 'Nous', + slug: 'nous', + models: ['hermes-4'], + authenticated: true, + capabilities: { 'hermes-4': { reasoning: true, fast: true } } + } + ] + }) + + await renderModelSettings() + expect((await screen.findAllByRole('combobox'))[0].textContent).toContain('Custom A') + + await act(async () => { + profileSwitchHandler?.() + }) + + await waitFor(() => expect(getGlobalModelInfo).toHaveBeenCalledTimes(2)) + await waitFor(() => expect(screen.getAllByRole('combobox')[0].textContent).toContain('Nous')) + expect(screen.queryByRole('button', { name: 'Set up provider' })).toBeNull() + }) + it('writes the profile default speed (service_tier) when the fast switch is toggled', async () => { await renderModelSettings() await waitFor(() => expect(getHermesConfigRecord).toHaveBeenCalled()) diff --git a/apps/desktop/src/app/settings/model-settings.tsx b/apps/desktop/src/app/settings/model-settings.tsx index aba305b78..0b188605c 100644 --- a/apps/desktop/src/app/settings/model-settings.tsx +++ b/apps/desktop/src/app/settings/model-settings.tsx @@ -27,7 +27,7 @@ import { useI18n } from '@/i18n' import { AlertTriangle, Cpu, Loader2 } from '@/lib/icons' import { cn } from '@/lib/utils' import { notifyError } from '@/store/notifications' -import { startManualLocalEndpoint, startManualProviderOAuth } from '@/store/onboarding' +import { startManualLocalEndpoint, startManualOnboarding, startManualProviderOAuth } from '@/store/onboarding' import { invalidateHermesConfig, setHermesConfigCache, useHermesConfigRecord } from '../hooks/use-config-record' import { useOnProfileSwitch } from '../hooks/use-on-profile-switch' @@ -220,7 +220,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) { // A's models/providers into profile B (or fire onMainModelChanged for A). const profileEpoch = useRef(0) - const refresh = useCallback(async () => { + const refresh = useCallback(async ({ replaceSelection = false }: { replaceSelection?: boolean } = {}) => { const epoch = profileEpoch.current setLoading(true) setError('') @@ -239,8 +239,15 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) { setMainModel({ model: modelInfo.model, provider: modelInfo.provider }) setProviders(modelOptions.providers || []) - setSelectedProvider(prev => prev || modelInfo.provider) - setSelectedModel(prev => prev || modelInfo.model) + + if (replaceSelection) { + setSelectedProvider(modelInfo.provider) + setSelectedModel(modelInfo.model) + } else { + setSelectedProvider(prev => prev || modelInfo.provider) + setSelectedModel(prev => prev || modelInfo.model) + } + setAuxiliary(auxiliaryModels) setMoa(moaModels) @@ -270,11 +277,28 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) { // new profile (bumping the epoch first so any in-flight A request is discarded). useOnProfileSwitch(() => { profileEpoch.current += 1 - void refresh() + // The panel stays mounted across profile switches, so clear the previous + // profile's draft selection before loading the new profile's source of + // truth. Ordinary same-profile refreshes still preserve in-progress edits. + setSelectedProvider('') + setSelectedModel('') + setApiKeyDraft('') + void refresh({ replaceSelection: true }) }) const providerOptions = providers.length ? providers : NO_PROVIDERS + // Radix renders a blank trigger when the controlled value has no matching + // item. Keep a missing saved provider visible in the main selector while + // leaving it out of the real inventory used for readiness/setup metadata. + const mainProviderOptions = useMemo( + () => + selectedProvider && !providers.some(provider => provider.slug === selectedProvider) + ? [{ name: selectedProvider, slug: selectedProvider, models: [] }, ...providers] + : providerOptions, + [providerOptions, providers, selectedProvider] + ) + // MoA reference/aggregator slots must never be the moa virtual provider — // that would create a recursive MoA tree (the backend rejects it on save). // Hide it from the slot selectors so it isn't offered as a dead choice. @@ -504,7 +528,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) { notifyError(err, m.defaultsFailed) } }, - [config, m.defaultsFailed] + [config, m.defaultsFailed, setConfig] ) // Paste an API key for the selected `api_key` provider, persist it, then @@ -561,7 +585,8 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) { // local-endpoint form (URL + optional API key) instead of being dead-ended // on the OAuth picker (the original "booted back to the first screen" loop). const startProviderSetup = useCallback(() => { - const slug = selectedProviderRow?.slug + const rowSlug = selectedProviderRow?.slug.trim() ?? '' + const slug = rowSlug || selectedProvider.trim() if (!slug) { return @@ -571,10 +596,14 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) { if (lower === 'custom' || lower === 'local' || lower.startsWith('custom:')) { startManualLocalEndpoint() + } else if (rowSlug) { + startManualProviderOAuth(rowSlug) } else { - startManualProviderOAuth(slug) + // An absent row has no trustworthy auth metadata. Open the generic + // provider picker instead of deep-linking an unknown or stale slug. + startManualOnboarding() } - }, [selectedProviderRow]) + }, [selectedProvider, selectedProviderRow]) const applyMainModel = useCallback(async () => { if (!selectedProvider || !selectedModel) { @@ -700,7 +729,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) { - {providerOptions.map(provider => ( + {mainProviderOptions.map(provider => ( {provider.name} @@ -762,7 +791,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) { )} - {needsSetup && !setupIsApiKey && ( + {needsSetup && !setupIsApiKey && selectedProviderRow && (

{selectedProviderRow?.auth_type === 'api_key' ? `${selectedProviderRow?.name} needs an API key — set it up to choose a model.`