import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { OFFICIAL_DEFAULT_MODEL_ID } from '../../constants/modelCatalog' import { OPENAI_OFFICIAL_DEFAULT_MODEL_ID, OPENAI_OFFICIAL_PROVIDER_ID, } from '../../constants/openaiOfficialProvider' import { useTranslation } from '../../i18n' import { useChatStore } from '../../stores/chatStore' import { useProviderStore } from '../../stores/providerStore' import { DRAFT_RUNTIME_SELECTION_KEY, useSessionRuntimeStore } from '../../stores/sessionRuntimeStore' import { useSettingsStore } from '../../stores/settingsStore' import type { SavedProvider } from '../../types/provider' import type { RuntimeSelection } from '../../types/runtime' import type { EffortLevel, ModelInfo } from '../../types/settings' import { useMobileViewport } from '../../hooks/useMobileViewport' import { isTauriRuntime } from '../../lib/desktopRuntime' import { MobileBottomSheet } from '../shared/MobileBottomSheet' type ProviderChoice = { providerId: string | null providerName: string isDefault: boolean models: ModelInfo[] } type Props = { value?: string onChange?: (modelId: string) => void runtimeSelection?: RuntimeSelection onRuntimeSelectionChange?: (selection: RuntimeSelection) => void runtimeKey?: string disabled?: boolean compact?: boolean } type DropdownPosition = { top: number left: number width: number maxHeight: number } const CLI_PROVIDER_NAMES: Record = { nvidia: 'NVIDIA', openrouter: 'OpenRouter', opencode: 'OpenCode Zen', openai: 'OpenAI', local: 'Local', anthropic: 'Anthropic', } const DROPDOWN_WIDTH = 360 const DROPDOWN_GAP = 8 const VIEWPORT_MARGIN = 16 const DROPDOWN_MAX_HEIGHT = 420 const DROPDOWN_MIN_HEIGHT = 180 function buildProviderChoices( providers: SavedProvider[], availableModels: ModelInfo[], activeProviderId: string | null, ): ProviderChoice[] { if (!activeProviderId) return [] // First try: if we have availableModels from fetchProviderModels, use them if (availableModels.length > 0) { const isCli = activeProviderId.startsWith('cli-') if (isCli) { const cliProviderKey = activeProviderId.replace('cli-', '') const cliProviderName = CLI_PROVIDER_NAMES[cliProviderKey] || activeProviderId return [{ providerId: activeProviderId, providerName: cliProviderName, isDefault: true, models: availableModels, }] } if (activeProviderId === OPENAI_OFFICIAL_PROVIDER_ID) { return [{ providerId: OPENAI_OFFICIAL_PROVIDER_ID, providerName: 'OpenAI', isDefault: true, models: availableModels, }] } const provider = providers.find(p => p.id === activeProviderId) if (provider) { return [{ providerId: provider.id, providerName: provider.name, isDefault: true, models: availableModels, }] } } // Fallback: if availableModels is empty (e.g. local provider, or fetch failed), // build choices from the saved providers list const isCli = activeProviderId.startsWith('cli-') if (isCli) { const cliProviderKey = activeProviderId.replace('cli-', '') const cliProviderName = CLI_PROVIDER_NAMES[cliProviderKey] || activeProviderId const provider = providers.find(p => p.id === activeProviderId) const models = provider ? Object.values(provider.models).filter(Boolean).map(id => ({ id, name: id, description: '', context: '', })) : [] return [{ providerId: activeProviderId, providerName: cliProviderName, isDefault: true, models, }] } if (activeProviderId === OPENAI_OFFICIAL_PROVIDER_ID) { return [{ providerId: OPENAI_OFFICIAL_PROVIDER_ID, providerName: 'OpenAI', isDefault: true, models: availableModels, }] } const provider = providers.find(p => p.id === activeProviderId) if (provider) { return [{ providerId: provider.id, providerName: provider.name, isDefault: true, models: availableModels, }] } return [] } function resolveDefaultRuntimeSelection( activeId: string | null, activeProviderName: string | null, providers: SavedProvider[], currentModelId: string | undefined, ): RuntimeSelection { const inferredProviderId = activeId ?? ( activeProviderName ? providers.find((provider) => provider.name === activeProviderName)?.id ?? null : null ) return { providerId: inferredProviderId, modelId: currentModelId ?? ( inferredProviderId === OPENAI_OFFICIAL_PROVIDER_ID ? OPENAI_OFFICIAL_DEFAULT_MODEL_ID : OFFICIAL_DEFAULT_MODEL_ID ), } } export function ModelSelector({ value, onChange, runtimeSelection: controlledRuntimeSelection, onRuntimeSelectionChange, runtimeKey, disabled = false, compact = false, }: Props = {}) { const t = useTranslation() const isMobileBrowser = useMobileViewport() && !isTauriRuntime() const { currentModel: storeModel, availableModels, effortLevel, activeProviderId, activeProviderName, setModel, setEffort, } = useSettingsStore() const { providers, activeId, isLoading: providersLoading, fetchProviders, } = useProviderStore() const runtimeSelection = useSessionRuntimeStore((state) => runtimeKey ? state.selections[runtimeKey] : undefined, ) const [open, setOpen] = useState(false) const [dropdownPosition, setDropdownPosition] = useState(null) const ref = useRef(null) const dropdownRef = useRef(null) const requestedProvidersRef = useRef(false) const EFFORT_OPTIONS: { value: EffortLevel; label: string }[] = [ { value: 'low', label: t('settings.general.effort.low') }, { value: 'medium', label: t('settings.general.effort.medium') }, { value: 'high', label: t('settings.general.effort.high') }, { value: 'max', label: t('settings.general.effort.max') }, ] const isControlled = value !== undefined const isRuntimeScoped = !isControlled && (runtimeKey !== undefined || onRuntimeSelectionChange !== undefined) useEffect(() => { if (!isRuntimeScoped || providersLoading || requestedProvidersRef.current) return requestedProvidersRef.current = true void fetchProviders() }, [fetchProviders, isRuntimeScoped, providersLoading]) useEffect(() => { if (!open) return const handleClick = (e: MouseEvent) => { const target = e.target as Node if ( ref.current && !ref.current.contains(target) && !dropdownRef.current?.contains(target) ) { setOpen(false) } } const handleEsc = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false) } document.addEventListener('mousedown', handleClick) document.addEventListener('keydown', handleEsc) return () => { document.removeEventListener('mousedown', handleClick) document.removeEventListener('keydown', handleEsc) } }, [open]) const updateDropdownPosition = useCallback(() => { const anchor = ref.current if (!anchor) return const rect = anchor.getBoundingClientRect() const viewportWidth = window.innerWidth || document.documentElement.clientWidth const viewportHeight = window.innerHeight || document.documentElement.clientHeight const width = Math.min(DROPDOWN_WIDTH, Math.max(0, viewportWidth - VIEWPORT_MARGIN * 2)) const left = Math.min( Math.max(VIEWPORT_MARGIN, rect.right - width), Math.max(VIEWPORT_MARGIN, viewportWidth - width - VIEWPORT_MARGIN), ) const spaceBelow = viewportHeight - rect.bottom - DROPDOWN_GAP - VIEWPORT_MARGIN const spaceAbove = rect.top - DROPDOWN_GAP - VIEWPORT_MARGIN const placeBelow = spaceBelow >= DROPDOWN_MIN_HEIGHT || spaceBelow >= spaceAbove const availableHeight = Math.max( DROPDOWN_MIN_HEIGHT, placeBelow ? spaceBelow : spaceAbove, ) const maxHeight = Math.min(DROPDOWN_MAX_HEIGHT, availableHeight) setDropdownPosition({ top: placeBelow ? rect.bottom + DROPDOWN_GAP : Math.max(VIEWPORT_MARGIN, rect.top - DROPDOWN_GAP - maxHeight), left, width, maxHeight, }) }, []) useLayoutEffect(() => { if (!open) { setDropdownPosition(null) return } updateDropdownPosition() }, [open, updateDropdownPosition]) useEffect(() => { if (!open) return window.addEventListener('resize', updateDropdownPosition) window.addEventListener('scroll', updateDropdownPosition, true) return () => { window.removeEventListener('resize', updateDropdownPosition) window.removeEventListener('scroll', updateDropdownPosition, true) } }, [open, updateDropdownPosition]) const providerChoices = useMemo( () => buildProviderChoices( providers, availableModels, activeProviderId, ), [activeProviderId, availableModels, providers], ) const selectedModel = isControlled ? availableModels.find((model) => model.id === value) || null : storeModel const activeRuntimeSelection = isRuntimeScoped ? controlledRuntimeSelection ?? runtimeSelection ?? resolveDefaultRuntimeSelection( activeId, activeProviderName, providers, storeModel?.id, ) : null const selectedProviderChoice = activeRuntimeSelection ? providerChoices.find((choice) => choice.providerId === activeRuntimeSelection.providerId) ?? null : null const selectedRuntimeModel = activeRuntimeSelection ? selectedProviderChoice?.models.find((model) => model.id === activeRuntimeSelection.modelId) ?? { id: activeRuntimeSelection.modelId, name: activeRuntimeSelection.modelId, description: '', context: '', } : null const buttonModelLabel = isRuntimeScoped ? selectedRuntimeModel?.name ?? storeModel?.name ?? t('model.selectModel') : selectedModel?.name ?? t('model.selectModel') const buttonProviderLabel = isRuntimeScoped ? selectedProviderChoice?.providerName ?? activeProviderName ?? t('settings.providers.officialName') : null const handleRuntimeSelect = (selection: RuntimeSelection) => { onRuntimeSelectionChange?.(selection) if (runtimeKey) { useSessionRuntimeStore.getState().setSelection(runtimeKey, selection) if (runtimeKey !== DRAFT_RUNTIME_SELECTION_KEY) { useChatStore.getState().setSessionRuntime(runtimeKey, selection) } } setOpen(false) } const dropdownContent = ( <>
{!isMobileBrowser && (
{t('model.configuration')}
)} {isRuntimeScoped ? (
{providerChoices.map((choice) => (
{choice.providerName} {choice.isDefault && ( {t('settings.providers.default')} )}
{choice.models.map((model) => { const isSelected = activeRuntimeSelection?.providerId === choice.providerId && activeRuntimeSelection.modelId === model.id return ( ) })}
))}
) : (
{availableModels.map((model) => { const isSelected = model.id === selectedModel?.id return ( ) })}
)}
{!isControlled && !isRuntimeScoped && (
{t('model.effort')}
{EFFORT_OPTIONS.map((opt) => { const isSelected = opt.value === effortLevel return ( ) })}
)} ) const dropdown = open && dropdownPosition ? isMobileBrowser ? ( setOpen(false)} title={t('model.configuration')} closeLabel={t('tabs.close')} ariaLabel={t('model.configuration')} contentClassName="p-3" panelRef={dropdownRef} testId="model-selector-dropdown" > {dropdownContent} ) : createPortal(
{dropdownContent}
, document.body, ) : null return (
{dropdown}
) }