From aa1321a02bfa6e3baa1092bfd1f32eb5d10529f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B4=BE=E5=A4=A7=E6=98=9F?= Date: Sun, 24 May 2026 00:30:15 +0800 Subject: [PATCH 1/2] feat: hide official provider sections in ModelSelector when OAuth not logged in Only show Claude Official and ChatGPT Official model groups in the runtime-scoped ModelSelector dropdown when the corresponding OAuth session is active. This prevents users from selecting official models they cannot actually use. - Import useHahaOAuthStore and useHahaOpenAIOAuthStore into ModelSelector - Fetch OAuth status on mount - Pass login flags to buildProviderChoices to conditionally include sections - Add test case verifying hidden sections when not logged in Co-Authored-By: claude-opus-4-6 --- .../controls/ModelSelector.test.tsx | 44 +++++++++++++++++++ .../src/components/controls/ModelSelector.tsx | 40 +++++++++++++---- 2 files changed, 76 insertions(+), 8 deletions(-) diff --git a/desktop/src/components/controls/ModelSelector.test.tsx b/desktop/src/components/controls/ModelSelector.test.tsx index 59f9e0ea..62f65dab 100644 --- a/desktop/src/components/controls/ModelSelector.test.tsx +++ b/desktop/src/components/controls/ModelSelector.test.tsx @@ -4,6 +4,8 @@ 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,8 @@ 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) }) describe('ModelSelector', () => { @@ -135,6 +139,7 @@ describe('ModelSelector', () => { }, ] const setSessionRuntime = vi.fn() + useHahaOpenAIOAuthStore.setState({ status: { loggedIn: true, expiresAt: null, email: null, accountId: null } }) useSettingsStore.setState({ locale: 'en', availableModels: openAIModels, @@ -169,6 +174,45 @@ describe('ModelSelector', () => { }) }) + it('hides official provider sections when OAuth is not logged in', async () => { + useHahaOAuthStore.setState({ status: { loggedIn: false } }) + useHahaOpenAIOAuthStore.setState({ status: { loggedIn: false } }) + 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..0b38c88a 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 = { @@ -101,6 +103,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 +113,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 +186,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 +217,11 @@ export function ModelSelector({ void fetchProviders() }, [fetchProviders, isRuntimeScoped, providersLoading]) + useEffect(() => { + void fetchClaudeOAuthStatus() + void fetchOpenAIOAuthStatus() + }, [fetchClaudeOAuthStatus, fetchOpenAIOAuthStatus]) + useEffect(() => { if (!open) return const handleClick = (e: MouseEvent) => { @@ -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 From 12ccd2b611b0a5de8aae6123e8326f4ed3bdffd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B4=BE=E5=A4=A7=E6=98=9F?= Date: Sun, 24 May 2026 00:53:28 +0800 Subject: [PATCH 2/2] fix: use bottom-anchored positioning for above-trigger dropdown in ModelSelector When the dropdown opens above the trigger button, use CSS bottom instead of top to prevent a gap between the dropdown and the button when content is shorter than maxHeight. Co-Authored-By: claude-opus-4-6 --- .../components/controls/ModelSelector.test.tsx | 17 +++++++++++++---- .../src/components/controls/ModelSelector.tsx | 9 +++++---- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/desktop/src/components/controls/ModelSelector.test.tsx b/desktop/src/components/controls/ModelSelector.test.tsx index 62f65dab..78277703 100644 --- a/desktop/src/components/controls/ModelSelector.test.tsx +++ b/desktop/src/components/controls/ModelSelector.test.tsx @@ -1,4 +1,4 @@ -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' @@ -34,6 +34,12 @@ afterEach(() => { useHahaOpenAIOAuthStore.setState(useHahaOpenAIOAuthStore.getInitialState(), true) }) +// Prevent real API calls from fetchStatus on mount +beforeEach(() => { + useHahaOAuthStore.setState({ fetchStatus: async () => {} }) + useHahaOpenAIOAuthStore.setState({ fetchStatus: async () => {} }) +}) + describe('ModelSelector', () => { it('uses controlled model selection without mutating settings directly', async () => { const onChange = vi.fn() @@ -139,7 +145,10 @@ describe('ModelSelector', () => { }, ] const setSessionRuntime = vi.fn() - useHahaOpenAIOAuthStore.setState({ status: { loggedIn: true, expiresAt: null, email: null, accountId: null } }) + useHahaOpenAIOAuthStore.setState({ + status: { loggedIn: true, expiresAt: null, email: null, accountId: null }, + fetchStatus: async () => {}, + }) useSettingsStore.setState({ locale: 'en', availableModels: openAIModels, @@ -175,8 +184,8 @@ describe('ModelSelector', () => { }) it('hides official provider sections when OAuth is not logged in', async () => { - useHahaOAuthStore.setState({ status: { loggedIn: false } }) - useHahaOpenAIOAuthStore.setState({ status: { loggedIn: false } }) + useHahaOAuthStore.setState({ status: { loggedIn: false }, fetchStatus: async () => {} }) + useHahaOpenAIOAuthStore.setState({ status: { loggedIn: false }, fetchStatus: async () => {} }) useSettingsStore.setState({ locale: 'en', availableModels: MODELS, diff --git a/desktop/src/components/controls/ModelSelector.tsx b/desktop/src/components/controls/ModelSelector.tsx index 0b38c88a..45e651d2 100644 --- a/desktop/src/components/controls/ModelSelector.tsx +++ b/desktop/src/components/controls/ModelSelector.tsx @@ -38,7 +38,8 @@ type Props = { } type DropdownPosition = { - top: number + top: number | undefined + bottom: number | undefined left: number width: number maxHeight: number @@ -267,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, @@ -535,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, }}