import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { OFFICIAL_MODELS } from '../../constants/modelCatalog' import { OPENAI_OFFICIAL_MODELS, 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 { isDesktopRuntime } from '../../lib/desktopRuntime' import { resolveDefaultRuntimeSelection } from '../../lib/runtimeSelection' import { useHahaOAuthStore } from '../../stores/hahaOAuthStore' import { useHahaOpenAIOAuthStore } from '../../stores/hahaOpenAIOAuthStore' 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 } export type ModelSelectorHandle = { open: () => void } type DropdownPosition = { top: number | undefined bottom: number | undefined left: number width: number maxHeight: number } const DROPDOWN_WIDTH = 360 const DROPDOWN_GAP = 8 const VIEWPORT_MARGIN = 16 const DROPDOWN_MAX_HEIGHT = 420 const DROPDOWN_MIN_HEIGHT = 180 function officialChoices( providerId: string | null, models: ModelInfo[], isDefault: boolean, officialName: string, ): ProviderChoice { return { providerId, providerName: officialName, isDefault, models, } } function buildProviderModels( provider: SavedProvider, labels: Record<'main' | 'haiku' | 'sonnet' | 'opus', string>, ): ModelInfo[] { const entries: Array<{ id: string; label: string }> = [ { id: provider.models.main.trim(), label: labels.main }, { id: provider.models.haiku.trim(), label: labels.haiku }, { id: provider.models.sonnet.trim(), label: labels.sonnet }, { id: provider.models.opus.trim(), label: labels.opus }, ] const byId = new Map() for (const entry of entries) { if (!entry.id) continue const existing = byId.get(entry.id) if (existing) { if (!existing.labels.includes(entry.label)) { existing.labels.push(entry.label) } continue } byId.set(entry.id, { id: entry.id, labels: [entry.label] }) } return [...byId.values()].map((entry) => ({ id: entry.id, name: entry.id, description: entry.labels.join(' ยท '), context: '', })) } function buildProviderChoices( providers: SavedProvider[], activeId: string | null, availableModels: ModelInfo[], officialName: string, openAIOfficialName: string, labels: Record<'main' | 'haiku' | 'sonnet' | 'opus', string>, claudeOfficialLoggedIn: boolean, openAIOfficialLoggedIn: boolean, ): ProviderChoice[] { const claudeOfficialModels = activeId === null && availableModels.length > 0 ? availableModels : OFFICIAL_MODELS const openAIOfficialModels = activeId === OPENAI_OFFICIAL_PROVIDER_ID && availableModels.length > 0 ? availableModels : OPENAI_OFFICIAL_MODELS 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, )) } for (const provider of providers) { choices.push({ providerId: provider.id, providerName: provider.name, isDefault: activeId === provider.id, models: buildProviderModels(provider, labels), }) } return choices } export const ModelSelector = forwardRef(function ModelSelector({ value, onChange, runtimeSelection: controlledRuntimeSelection, onRuntimeSelectionChange, runtimeKey, disabled = false, compact = false, }: Props = {}, selectorRef) { const t = useTranslation() const isMobileBrowser = useMobileViewport() && !isDesktopRuntime() const { currentModel: storeModel, availableModels, effortLevel, activeProviderName, setModel, } = useSettingsStore() const { providers, activeId, 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, ) const [open, setOpen] = useState(false) const [dropdownPosition, setDropdownPosition] = useState(null) const ref = useRef(null) const dropdownRef = useRef(null) const requestedProvidersRef = useRef(false) const requestedOAuthStatusRef = 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) const canEditRuntimeEffort = runtimeKey !== undefined useEffect(() => { if (!isRuntimeScoped || providersLoading || requestedProvidersRef.current) return requestedProvidersRef.current = true void fetchProviders() }, [fetchProviders, isRuntimeScoped, providersLoading]) useEffect(() => { if (!isRuntimeScoped || !open || requestedOAuthStatusRef.current) return requestedOAuthStatusRef.current = true void fetchClaudeOAuthStatus() void fetchOpenAIOAuthStatus() }, [fetchClaudeOAuthStatus, fetchOpenAIOAuthStatus, isRuntimeScoped, open]) const openSelector = useCallback(() => { if (!disabled) setOpen(true) }, [disabled]) useImperativeHandle(selectorRef, () => ({ open: openSelector, }), [openSelector]) 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 : undefined, bottom: placeBelow ? undefined : (viewportHeight - rect.top + DROPDOWN_GAP), 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 roleLabels = useMemo( () => ({ main: t('settings.providers.mainModel'), haiku: t('settings.providers.haikuModel'), sonnet: t('settings.providers.sonnetModel'), opus: t('settings.providers.opusModel'), }), [t], ) const providerChoices = useMemo( () => buildProviderChoices( providers, activeId, availableModels, t('settings.providers.officialName'), t('settings.providers.openaiOfficialName'), roleLabels, claudeOAuthStatus?.loggedIn === true, openAIOAuthStatus?.loggedIn === true, ), [activeId, availableModels, providers, roleLabels, t, claudeOAuthStatus, openAIOAuthStatus], ) 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 selectedRuntimeEffort = activeRuntimeSelection?.effortLevel ?? effortLevel 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 handleRuntimeEffortSelect = (level: EffortLevel) => { if (!activeRuntimeSelection) return handleRuntimeSelect({ ...activeRuntimeSelection, effortLevel: level, }) } 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 ( ) })}
)}
{canEditRuntimeEffort && (
{t('model.effort')}
{EFFORT_OPTIONS.map((opt) => { const isSelected = opt.value === selectedRuntimeEffort 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}
) })