diff --git a/desktop/src/components/controls/ModelSelector.test.tsx b/desktop/src/components/controls/ModelSelector.test.tsx index 59f9e0ea..78277703 100644 --- a/desktop/src/components/controls/ModelSelector.test.tsx +++ b/desktop/src/components/controls/ModelSelector.test.tsx @@ -1,9 +1,11 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import '@testing-library/jest-dom' import { ModelSelector } from './ModelSelector' import { useChatStore } from '../../stores/chatStore' +import { useHahaOAuthStore } from '../../stores/hahaOAuthStore' +import { useHahaOpenAIOAuthStore } from '../../stores/hahaOpenAIOAuthStore' import { useProviderStore } from '../../stores/providerStore' import { useSessionRuntimeStore } from '../../stores/sessionRuntimeStore' import { useSettingsStore } from '../../stores/settingsStore' @@ -28,6 +30,14 @@ afterEach(() => { useProviderStore.setState(useProviderStore.getInitialState(), true) useSessionRuntimeStore.setState(useSessionRuntimeStore.getInitialState(), true) useChatStore.setState(useChatStore.getInitialState(), true) + useHahaOAuthStore.setState(useHahaOAuthStore.getInitialState(), true) + useHahaOpenAIOAuthStore.setState(useHahaOpenAIOAuthStore.getInitialState(), true) +}) + +// Prevent real API calls from fetchStatus on mount +beforeEach(() => { + useHahaOAuthStore.setState({ fetchStatus: async () => {} }) + useHahaOpenAIOAuthStore.setState({ fetchStatus: async () => {} }) }) describe('ModelSelector', () => { @@ -135,6 +145,10 @@ describe('ModelSelector', () => { }, ] const setSessionRuntime = vi.fn() + useHahaOpenAIOAuthStore.setState({ + status: { loggedIn: true, expiresAt: null, email: null, accountId: null }, + fetchStatus: async () => {}, + }) useSettingsStore.setState({ locale: 'en', availableModels: openAIModels, @@ -169,6 +183,45 @@ describe('ModelSelector', () => { }) }) + it('hides official provider sections when OAuth is not logged in', async () => { + useHahaOAuthStore.setState({ status: { loggedIn: false }, fetchStatus: async () => {} }) + useHahaOpenAIOAuthStore.setState({ status: { loggedIn: false }, fetchStatus: async () => {} }) + useSettingsStore.setState({ + locale: 'en', + availableModels: MODELS, + currentModel: MODELS[0], + activeProviderName: 'Provider A', + }) + useProviderStore.setState({ + providers: [{ + id: 'provider-a', + presetId: 'custom', + name: 'Provider A', + apiKey: '***', + baseUrl: 'https://api.example.com', + apiFormat: 'anthropic', + models: { + main: 'provider-main', + haiku: '', + sonnet: '', + opus: '', + }, + }], + activeId: 'provider-a', + hasLoadedProviders: true, + isLoading: true, + }) + + render() + + await clickByRole(/alpha/i) + + const dropdown = screen.getByTestId('model-selector-dropdown') + expect(dropdown.textContent).not.toContain('Claude Official') + expect(dropdown.textContent).not.toContain('ChatGPT Official') + expect(dropdown.textContent).toContain('Provider A') + }) + it('portals the dropdown outside clipping containers and positions it below the trigger', async () => { useSettingsStore.setState({ locale: 'en', diff --git a/desktop/src/components/controls/ModelSelector.tsx b/desktop/src/components/controls/ModelSelector.tsx index b39ed9a7..45e651d2 100644 --- a/desktop/src/components/controls/ModelSelector.tsx +++ b/desktop/src/components/controls/ModelSelector.tsx @@ -16,6 +16,8 @@ import type { RuntimeSelection } from '../../types/runtime' import type { EffortLevel, ModelInfo } from '../../types/settings' import { useMobileViewport } from '../../hooks/useMobileViewport' import { isTauriRuntime } from '../../lib/desktopRuntime' +import { useHahaOAuthStore } from '../../stores/hahaOAuthStore' +import { useHahaOpenAIOAuthStore } from '../../stores/hahaOpenAIOAuthStore' import { MobileBottomSheet } from '../shared/MobileBottomSheet' type ProviderChoice = { @@ -36,7 +38,8 @@ type Props = { } type DropdownPosition = { - top: number + top: number | undefined + bottom: number | undefined left: number width: number maxHeight: number @@ -101,6 +104,8 @@ function buildProviderChoices( officialName: string, openAIOfficialName: string, labels: Record<'main' | 'haiku' | 'sonnet' | 'opus', string>, + claudeOfficialLoggedIn: boolean, + openAIOfficialLoggedIn: boolean, ): ProviderChoice[] { const claudeOfficialModels = activeId === null && availableModels.length > 0 ? availableModels @@ -109,21 +114,30 @@ function buildProviderChoices( ? availableModels : OPENAI_OFFICIAL_MODELS - return [ - officialChoices(null, claudeOfficialModels, activeId === null, officialName), - officialChoices( + const choices: ProviderChoice[] = [] + + if (claudeOfficialLoggedIn) { + choices.push(officialChoices(null, claudeOfficialModels, activeId === null, officialName)) + } + if (openAIOfficialLoggedIn) { + choices.push(officialChoices( OPENAI_OFFICIAL_PROVIDER_ID, openAIOfficialModels, activeId === OPENAI_OFFICIAL_PROVIDER_ID, openAIOfficialName, - ), - ...providers.map((provider) => ({ + )) + } + + for (const provider of providers) { + choices.push({ providerId: provider.id, providerName: provider.name, isDefault: activeId === provider.id, models: buildProviderModels(provider, labels), - })), - ] + }) + } + + return choices } function resolveDefaultRuntimeSelection( @@ -173,6 +187,10 @@ export function ModelSelector({ isLoading: providersLoading, fetchProviders, } = useProviderStore() + const claudeOAuthStatus = useHahaOAuthStore((s) => s.status) + const fetchClaudeOAuthStatus = useHahaOAuthStore((s) => s.fetchStatus) + const openAIOAuthStatus = useHahaOpenAIOAuthStore((s) => s.status) + const fetchOpenAIOAuthStatus = useHahaOpenAIOAuthStore((s) => s.fetchStatus) const runtimeSelection = useSessionRuntimeStore((state) => runtimeKey ? state.selections[runtimeKey] : undefined, ) @@ -200,6 +218,11 @@ export function ModelSelector({ void fetchProviders() }, [fetchProviders, isRuntimeScoped, providersLoading]) + useEffect(() => { + void fetchClaudeOAuthStatus() + void fetchOpenAIOAuthStatus() + }, [fetchClaudeOAuthStatus, fetchOpenAIOAuthStatus]) + useEffect(() => { if (!open) return const handleClick = (e: MouseEvent) => { @@ -245,9 +268,8 @@ export function ModelSelector({ const maxHeight = Math.min(DROPDOWN_MAX_HEIGHT, availableHeight) setDropdownPosition({ - top: placeBelow - ? rect.bottom + DROPDOWN_GAP - : Math.max(VIEWPORT_MARGIN, rect.top - DROPDOWN_GAP - maxHeight), + top: placeBelow ? rect.bottom + DROPDOWN_GAP : undefined, + bottom: placeBelow ? undefined : (viewportHeight - rect.top + DROPDOWN_GAP), left, width, maxHeight, @@ -290,8 +312,10 @@ export function ModelSelector({ t('settings.providers.officialName'), t('settings.providers.openaiOfficialName'), roleLabels, + claudeOAuthStatus?.loggedIn === true, + openAIOAuthStatus?.loggedIn === true, ), - [activeId, availableModels, providers, roleLabels, t], + [activeId, availableModels, providers, roleLabels, t, claudeOAuthStatus, openAIOAuthStatus], ) const selectedModel = isControlled @@ -511,6 +535,7 @@ export function ModelSelector({ className="fixed z-[80] rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-dropdown)]" style={{ top: dropdownPosition.top, + bottom: dropdownPosition.bottom, left: dropdownPosition.left, width: dropdownPosition.width, }}