import React from 'react'; import { focusChatInput } from './composer/editor/dom'; import type { EditPermissionMode } from '@/stores/types/sessionTypes'; import type { ModelMetadata } from '@/types'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { Input } from '@/components/ui/input'; import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; import { ProviderLogo } from '@/components/ui/ProviderLogo'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { Icon } from "@/components/icon/Icon"; import type { IconName } from "@/components/icon/icons"; import { ModelPickerList, type ModelPickerEntry, type ModelPickerProvider } from '@/components/model-picker/ModelPickerList'; import { useIsVSCodeRuntime } from '@/hooks/useRuntimeAPIs'; import { isDesktopShell } from '@/lib/desktop'; import { getAgentColor } from '@/lib/agentColors'; import { useDeviceInfo } from '@/lib/device'; import { mergeModelMetadataWithLiveModel } from '@/lib/modelMetadata'; import { getModelDisplayName as getSharedModelDisplayName } from '@/lib/modelDisplay'; import { getEditModeColors } from '@/lib/permissions/editModeColors'; import { cn, fuzzyMatch } from '@/lib/utils'; import { useContextStore } from '@/stores/contextStore'; import { useConfigStore } from '@/stores/useConfigStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSelectionStore } from '@/sync/selection-store'; import { useSessionMessages, useSessionRenderable } from '@/sync/sync-context'; import { useSync } from '@/sync/use-sync'; import { useUIStore } from '@/stores/useUIStore'; import { useModelLists } from '@/hooks/useModelLists'; import { useIsTextTruncated } from '@/hooks/useIsTextTruncated'; import { formatEffortLabel, getCycledPrimaryAgentName, isPrimaryMode, type MobileControlsPanel } from './mobileControlsUtils'; import { getCurrentIntlLocale, useI18n } from '@/lib/i18n'; import { useOpenCodeReadiness } from '@/hooks/useOpenCodeReadiness'; import { eventMatchesShortcut, getEffectiveShortcutCombo, normalizeCombo } from '@/lib/shortcuts'; import { markStartupTrace } from '@/lib/startupTrace'; import { findLatestUserModelChoice, shouldPreserveManualModelOverride, } from '@/lib/messages/userModelChoice'; import { getSyncParts } from '@/sync/sync-refs'; type IconComponent = IconName; type ProviderModel = Record & { id?: string; name?: string }; type PermissionAction = 'allow' | 'ask' | 'deny'; type PermissionRule = { permission: string; pattern: string; action: PermissionAction }; type MobileVariantTarget = { providerId: string; modelId: string }; const buildModelRefKey = (providerID: string, modelID: string) => `${providerID}:${modelID}`; const MAX_INLINE_MOBILE_VARIANT_OPTIONS = 6; const asPermissionRuleset = (value: unknown): PermissionRule[] | null => { if (!Array.isArray(value)) { return null; } const rules: PermissionRule[] = []; for (const entry of value) { if (!entry || typeof entry !== 'object') { continue; } const candidate = entry as Partial; if (typeof candidate.permission !== 'string' || typeof candidate.pattern !== 'string' || typeof candidate.action !== 'string') { continue; } if (candidate.action !== 'allow' && candidate.action !== 'ask' && candidate.action !== 'deny') { continue; } rules.push({ permission: candidate.permission, pattern: candidate.pattern, action: candidate.action }); } return rules; }; const resolveWildcardPermissionAction = (ruleset: unknown, permission: string): PermissionAction | undefined => { const rules = asPermissionRuleset(ruleset); if (!rules || rules.length === 0) { return undefined; } for (let i = rules.length - 1; i >= 0; i -= 1) { const rule = rules[i]; if (rule.permission === permission && rule.pattern === '*') { return rule.action; } } for (let i = rules.length - 1; i >= 0; i -= 1) { const rule = rules[i]; if (rule.permission === '*' && rule.pattern === '*') { return rule.action; } } return undefined; }; interface CapabilityDefinition { key: 'tool_call' | 'reasoning'; icon: IconComponent; label: string; isActive: (metadata?: ModelMetadata) => boolean; } const CAPABILITY_DEFINITIONS: CapabilityDefinition[] = [ { key: 'tool_call', icon: "tools", label: 'Tool calling', isActive: (metadata) => metadata?.tool_call === true, }, { key: 'reasoning', icon: "brain-ai-3", label: 'Reasoning', isActive: (metadata) => metadata?.reasoning === true, }, ]; interface ModalityIconDefinition { icon: IconComponent; label: string; } type ModalityIcon = { key: string; icon: IconComponent; label: string; }; type ModelApplyResult = 'applied' | 'provider-missing' | 'model-missing'; const MODALITY_ICON_MAP: Record = { text: { icon: "text", label: 'Text' }, image: { icon: "file-image", label: 'Image' }, video: { icon: "file-video", label: 'Video' }, audio: { icon: "file-music", label: 'Audio' }, pdf: { icon: "file-pdf", label: 'PDF' }, }; const normalizeModality = (value: string) => value.trim().toLowerCase(); const getModalityIcons = (metadata: ModelMetadata | undefined, direction: 'input' | 'output'): ModalityIcon[] => { const modalityList = direction === 'input' ? metadata?.modalities?.input : metadata?.modalities?.output; if (!Array.isArray(modalityList) || modalityList.length === 0) { return []; } const uniqueValues = Array.from(new Set(modalityList.map((item) => normalizeModality(item)))); const result: ModalityIcon[] = []; for (const modality of uniqueValues) { const definition = MODALITY_ICON_MAP[modality]; if (!definition) { continue; } result.push({ key: modality, icon: definition.icon, label: definition.label, }); } return result; }; const formatCompactNumber = (value: number) => new Intl.NumberFormat(getCurrentIntlLocale(), { notation: 'compact', compactDisplay: 'short', maximumFractionDigits: 1, minimumFractionDigits: 0, }).format(value); const formatUsdCurrency = (value: number) => new Intl.NumberFormat(getCurrentIntlLocale(), { style: 'currency', currency: 'USD', maximumFractionDigits: 4, minimumFractionDigits: 2, }).format(value); const formatKnowledgeDate = (value: Date) => new Intl.DateTimeFormat(getCurrentIntlLocale(), { month: 'short', year: 'numeric' }).format(value); const formatReleaseDate = (value: Date) => new Intl.DateTimeFormat(getCurrentIntlLocale(), { month: 'short', day: 'numeric', year: 'numeric', }).format(value); const ADD_PROVIDER_ID = '__add_provider__'; const IconBadge: React.FC<{ iconName: IconComponent; label: string }> = ({ iconName, label }) => ( ); const EditModeIcon: React.FC<{ mode: EditPermissionMode; className?: string }> = ({ mode, className }) => { const combinedClassName = cn(className, 'flex-shrink-0'); const modeColors = getEditModeColors(mode); const iconColor = modeColors ? modeColors.text : 'var(--foreground)'; const iconStyle = { color: iconColor }; if (mode === 'full') { return ; } if (mode === 'allow') { return ; } if (mode === 'deny') { return ; } return ; }; const formatTokens = (value?: number | null) => { if (typeof value !== 'number' || Number.isNaN(value)) { return '—'; } if (value === 0) { return '0'; } const formatted = formatCompactNumber(value); return formatted.endsWith('.0') ? formatted.slice(0, -2) : formatted; }; const formatCost = (value?: number | null) => { if (typeof value !== 'number' || !Number.isFinite(value)) { return '—'; } return formatUsdCurrency(value); }; const getCapabilityIcons = (metadata?: ModelMetadata) => { const result: { key: string; icon: IconComponent; label: string }[] = []; for (const definition of CAPABILITY_DEFINITIONS) { if (definition.isActive(metadata)) { result.push({ key: definition.key, icon: definition.icon, label: definition.label }); } } return result; }; const formatKnowledge = (knowledge?: string) => { if (!knowledge) { return '—'; } const match = knowledge.match(/^(\d{4})-(\d{2})$/); if (match) { const year = Number.parseInt(match[1], 10); const monthIndex = Number.parseInt(match[2], 10) - 1; const knowledgeDate = new Date(Date.UTC(year, monthIndex, 1)); if (!Number.isNaN(knowledgeDate.getTime())) { return formatKnowledgeDate(knowledgeDate); } } return knowledge; }; const formatDate = (value?: string) => { if (!value) { return '—'; } const parsedDate = new Date(value); if (Number.isNaN(parsedDate.getTime())) { return value; } return formatReleaseDate(parsedDate); }; interface ModelControlsProps { className?: string; mobilePanel?: MobileControlsPanel; onMobilePanelChange?: (panel: MobileControlsPanel) => void; } export const ModelControls: React.FC = ({ className, mobilePanel, onMobilePanelChange, }) => { const { t } = useI18n(); const { isReady, isUnavailable } = useOpenCodeReadiness(); const readinessLabel = isUnavailable ? t('common.unavailable') : t('common.loading'); const providers = useConfigStore((state) => state.providers); const currentProviderId = useConfigStore((state) => state.currentProviderId); const currentModelId = useConfigStore((state) => state.currentModelId); const currentVariant = useConfigStore((state) => state.currentVariant); const currentAgentName = useConfigStore((state) => state.currentAgentName); const settingsDefaultVariant = useConfigStore((state) => state.settingsDefaultVariant); const settingsDefaultAgent = useConfigStore((state) => state.settingsDefaultAgent); const setProvider = useConfigStore((state) => state.setProvider); const setSelectedProvider = useConfigStore((state) => state.setSelectedProvider); const setModel = useConfigStore((state) => state.setModel); const setCurrentVariant = useConfigStore((state) => state.setCurrentVariant); const getCurrentModelVariants = useConfigStore((state) => state.getCurrentModelVariants); const setAgent = useConfigStore((state) => state.setAgent); const getCurrentProvider = useConfigStore((state) => state.getCurrentProvider); const getModelMetadata = useConfigStore((state) => state.getModelMetadata); const getCurrentAgent = useConfigStore((state) => state.getCurrentAgent); const getVisibleAgents = useConfigStore((state) => state.getVisibleAgents); // Use visible agents (excludes hidden internal agents) const agents = getVisibleAgents(); const primaryAgents = React.useMemo(() => agents.filter((agent) => agent.mode === 'primary'), [agents]); const tracedReadyRef = React.useRef(false); React.useEffect(() => { if (tracedReadyRef.current || !isReady) return; tracedReadyRef.current = true; markStartupTrace('ModelControls:ready', { providers: providers.length, agents: agents.length, currentProviderId, currentModelId, currentAgentName, }); }, [agents.length, currentAgentName, currentModelId, currentProviderId, isReady, providers.length]); const currentSessionId = useSessionUIStore((s) => s.currentSessionId); const getDirectoryForSession = useSessionUIStore((s) => s.getDirectoryForSession); const sync = useSync(); const getSessionModelSelection = useSelectionStore((state) => state.getSessionModelSelection); const saveSessionModelSelection = useSelectionStore((state) => state.saveSessionModelSelection); const saveSessionAgentSelection = useSelectionStore((state) => state.saveSessionAgentSelection); const saveAgentModelForSession = useSelectionStore((state) => state.saveAgentModelForSession); const getAgentModelForSession = useSelectionStore((state) => state.getAgentModelForSession); const saveAgentModelVariantForSession = useSelectionStore((state) => state.saveAgentModelVariantForSession); const getAgentModelVariantForSession = useSelectionStore((state) => state.getAgentModelVariantForSession); const contextHydrated = useContextStore((state) => state.hasHydrated); const sessionSavedAgentName = useSelectionStore((state) => currentSessionId ? state.sessionAgentSelections.get(currentSessionId) ?? null : null ); const stickySessionAgentRef = React.useRef(null); React.useEffect(() => { if (!currentSessionId) { stickySessionAgentRef.current = null; return; } if (sessionSavedAgentName) { stickySessionAgentRef.current = sessionSavedAgentName; } }, [currentSessionId, sessionSavedAgentName]); const stickySessionAgentName = currentSessionId ? stickySessionAgentRef.current : null; // Prefer per-session selection over global config to avoid flicker during server-driven mode switches. const uiAgentName = currentSessionId ? (sessionSavedAgentName || stickySessionAgentName || currentAgentName) : currentAgentName; const toggleFavoriteModel = useUIStore((state) => state.toggleFavoriteModel); const reorderFavoriteModel = useUIStore((state) => state.reorderFavoriteModel); const providerOrder = useUIStore((state) => state.providerOrder); const setProviderOrder = useUIStore((state) => state.setProviderOrder); const isFavoriteModel = useUIStore((state) => state.isFavoriteModel); const addRecentModel = useUIStore((state) => state.addRecentModel); const addRecentAgent = useUIStore((state) => state.addRecentAgent); const addRecentEffort = useUIStore((state) => state.addRecentEffort); const isModelSelectorOpen = useUIStore((state) => state.isModelSelectorOpen); const setModelSelectorOpen = useUIStore((state) => state.setModelSelectorOpen); const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen); const setSettingsPage = useUIStore((state) => state.setSettingsPage); const hiddenModels = useUIStore((state) => state.hiddenModels); const cycleAgentShortcutOverride = useUIStore((state) => state.shortcutOverrides.cycle_agent); const cycleAgentShortcut = React.useMemo(() => ( getEffectiveShortcutCombo('cycle_agent', cycleAgentShortcutOverride ? { cycle_agent: cycleAgentShortcutOverride } : undefined) ), [cycleAgentShortcutOverride]); // Separate state for agent selector to avoid conflict with model selector const [isAgentSelectorOpen, setIsAgentSelectorOpen] = React.useState(false); const { favoriteModelsList, recentModelsList } = useModelLists(); const { isMobile: deviceIsMobile } = useDeviceInfo(); // The composer decides whether it renders the mobile layout from the UI // store (the Capacitor shell forces it true even on tablets/iPad, where // useDeviceInfo classifies the wide screen as non-mobile). The bottom-sheet // panels must follow the SAME source: with the device flag alone, tapping // the model/agent chip on an iPad set the panel state while the sheet // itself rendered null. const uiIsMobile = useUIStore((state) => state.isMobile); const isMobile = deviceIsMobile || uiIsMobile; const isDesktop = React.useMemo(() => isDesktopShell(), []); const isVSCodeRuntime = useIsVSCodeRuntime(); // Only use mobile panels on actual mobile devices, VSCode uses desktop dropdowns const isCompact = isMobile; const [localMobilePanel, setLocalMobilePanel] = React.useState(null); const usingExternalMobilePanel = mobilePanel !== undefined && typeof onMobilePanelChange === 'function'; const activeMobilePanel = usingExternalMobilePanel ? mobilePanel : localMobilePanel; const setActiveMobilePanel = usingExternalMobilePanel ? onMobilePanelChange : setLocalMobilePanel; const [mobileTooltipOpen, setMobileTooltipOpen] = React.useState<'model' | 'agent' | null>(null); const [mobileModelQuery, setMobileModelQuery] = React.useState(''); const [expandedMobileModelKey, setExpandedMobileModelKey] = React.useState(null); const [mobileVariantTarget, setMobileVariantTarget] = React.useState(null); const manualVariantSelectionRef = React.useRef(false); const closeMobilePanel = React.useCallback(() => setActiveMobilePanel(null), [setActiveMobilePanel]); const closeMobileTooltip = React.useCallback(() => setMobileTooltipOpen(null), []); const longPressTimerRef = React.useRef(undefined); const [expandedMobileProviders, setExpandedMobileProviders] = React.useState>(() => { const initial = new Set(); if (currentProviderId) { initial.add(currentProviderId); } return initial; }); // Use global state for model selector (allows Ctrl+M shortcut) const agentMenuOpen = isModelSelectorOpen; const setAgentMenuOpen = setModelSelectorOpen; const openAddProviderSettings = React.useCallback(() => { setSelectedProvider(ADD_PROVIDER_ID); setSettingsPage('providers'); setSettingsDialogOpen(true); setAgentMenuOpen(false); closeMobilePanel(); }, [setSelectedProvider, setSettingsPage, setSettingsDialogOpen, setAgentMenuOpen, closeMobilePanel]); const [desktopModelQuery, setDesktopModelQuery] = React.useState(''); const keyboardOwnsModelSelectionRef = React.useRef(false); const lastModelPointerPositionRef = React.useRef<{ x: number; y: number } | null>(null); const activeModelPickerEntryRef = React.useRef(undefined); const [pendingThinkingVariants, setPendingThinkingVariants] = React.useState>(new Map()); const [adjustedThinkingModels, setAdjustedThinkingModels] = React.useState>(new Set()); const [modelPickerRenderVersion, setModelPickerRenderVersion] = React.useState(0); React.useEffect(() => { if (activeMobilePanel === 'model') { setExpandedMobileProviders(() => { const initial = new Set(); if (currentProviderId) { initial.add(currentProviderId); } return initial; }); } }, [activeMobilePanel, currentProviderId]); React.useEffect(() => { if (activeMobilePanel === null) { setExpandedMobileModelKey(null); } if (activeMobilePanel !== 'variant') { setMobileVariantTarget(null); } }, [activeMobilePanel]); React.useEffect(() => { if (activeMobilePanel !== 'model') { setMobileModelQuery(''); setExpandedMobileModelKey(null); } }, [activeMobilePanel]); // Handle model selector close behavior (separate from agent selector) const prevModelSelectorOpenRef = React.useRef(isModelSelectorOpen); React.useEffect(() => { const wasOpen = prevModelSelectorOpenRef.current; prevModelSelectorOpenRef.current = isModelSelectorOpen; if (!isModelSelectorOpen) { setDesktopModelQuery(''); keyboardOwnsModelSelectionRef.current = false; lastModelPointerPositionRef.current = null; setPendingThinkingVariants(new Map()); setAdjustedThinkingModels(new Set()); // Restore focus to chat input when model selector closes if (wasOpen && !isCompact) { requestAnimationFrame(focusChatInput); } } }, [isModelSelectorOpen, isCompact]); // Handle agent selector close behavior const [agentSearchQuery, setAgentSearchQuery] = React.useState(''); React.useEffect(() => { if (!isAgentSelectorOpen) { setAgentSearchQuery(''); if (!isCompact) { requestAnimationFrame(focusChatInput); } } }, [isAgentSelectorOpen, isCompact]); const selectableDesktopAgents = React.useMemo(() => { return agents.filter((agent) => isPrimaryMode(agent.mode)); }, [agents]); const sortedAndFilteredAgents = React.useMemo(() => { const sorted = [...selectableDesktopAgents].sort((a, b) => a.name.localeCompare(b.name)); if (!agentSearchQuery.trim()) { return sorted; } return sorted.filter((agent) => fuzzyMatch(agent.name, agentSearchQuery) || (agent.description && fuzzyMatch(agent.description, agentSearchQuery)) ); }, [selectableDesktopAgents, agentSearchQuery]); const defaultAgentName = React.useMemo(() => { if (settingsDefaultAgent) { const found = selectableDesktopAgents.find(a => a.name === settingsDefaultAgent); if (found) return found.name; } const buildAgent = selectableDesktopAgents.find(a => a.name === 'build'); if (buildAgent) return buildAgent.name; return selectableDesktopAgents[0]?.name; }, [settingsDefaultAgent, selectableDesktopAgents]); const currentAgent = React.useMemo(() => { if (uiAgentName) { return agents.find((agent) => agent.name === uiAgentName); } return getCurrentAgent?.(); }, [agents, getCurrentAgent, uiAgentName]); const sizeVariant: 'mobile' | 'vscode' | 'default' = isMobile ? 'mobile' : isVSCodeRuntime ? 'vscode' : 'default'; const buttonHeight = sizeVariant === 'mobile' ? 'h-9' : sizeVariant === 'vscode' ? 'h-6' : 'h-8'; const controlIconSize = sizeVariant === 'mobile' ? 'size-5' : sizeVariant === 'vscode' ? 'size-4' : 'size-4'; const controlTextSize = isCompact ? 'typography-micro' : 'typography-meta'; const inlineGapClass = sizeVariant === 'mobile' ? 'gap-x-1' : sizeVariant === 'vscode' ? 'gap-x-2' : 'gap-x-3'; const currentProvider = getCurrentProvider(); const models = Array.isArray(currentProvider?.models) ? currentProvider.models : []; const visibleProviders = React.useMemo(() => { const result: typeof providers = []; for (const provider of providers) { const providerModels = Array.isArray(provider.models) ? provider.models : []; const visibleModels = providerModels.filter((model: ProviderModel) => { const modelId = typeof model?.id === 'string' ? model.id : ''; return !hiddenModels.some( (item) => item.providerID === String(provider.id) && item.modelID === modelId ); }); if (visibleModels.length > 0) { result.push({ ...provider, models: visibleModels }); } } return result; }, [providers, hiddenModels]); const normalizeModelSearchValue = React.useCallback((value: string) => { const lower = value.toLowerCase().trim(); const compact = lower.replace(/[^a-z0-9]/g, ''); const tokens = lower.split(/[^a-z0-9]+/).filter(Boolean); return { lower, compact, tokens }; }, []); const matchesModelSearch = React.useCallback((candidate: string, query: string) => { const normalizedQuery = normalizeModelSearchValue(query); if (!normalizedQuery.lower) { return true; } const normalizedCandidate = normalizeModelSearchValue(candidate); if (normalizedCandidate.lower.includes(normalizedQuery.lower)) { return true; } if (normalizedQuery.compact.length >= 2 && normalizedCandidate.compact.includes(normalizedQuery.compact)) { return true; } if (normalizedQuery.tokens.length === 0) { return false; } return normalizedQuery.tokens.every((queryToken) => normalizedCandidate.tokens.some((candidateToken) => candidateToken.startsWith(queryToken) || candidateToken.includes(queryToken) ) ); }, [normalizeModelSearchValue]); const currentModelForMetadata = currentModelId ? models.find((model: ProviderModel) => model.id === currentModelId) : undefined; const currentMetadata = currentProviderId && currentModelId && currentModelForMetadata ? mergeModelMetadataWithLiveModel(currentProviderId, currentModelForMetadata, getModelMetadata(currentProviderId, currentModelId)) : currentProviderId && currentModelId ? getModelMetadata(currentProviderId, currentModelId) : undefined; const localizeMetaLabel = React.useCallback((label: string) => { if (label === 'Tool calling') return t('chat.modelControls.capability.toolCalling'); if (label === 'Reasoning') return t('chat.modelControls.capability.reasoning'); if (label === 'Text') return t('chat.modelControls.modality.text'); if (label === 'Image') return t('chat.modelControls.modality.image'); if (label === 'Video') return t('chat.modelControls.modality.video'); if (label === 'Audio') return t('chat.modelControls.modality.audio'); if (label === 'PDF') return t('chat.modelControls.modality.pdf'); return label; }, [t]); const currentCapabilityIcons = React.useMemo( () => getCapabilityIcons(currentMetadata).map((icon) => ({ ...icon, label: localizeMetaLabel(icon.label) })), [currentMetadata, localizeMetaLabel], ); const inputModalityIcons = React.useMemo( () => getModalityIcons(currentMetadata, 'input').map((icon) => ({ ...icon, label: localizeMetaLabel(icon.label) })), [currentMetadata, localizeMetaLabel], ); const outputModalityIcons = React.useMemo( () => getModalityIcons(currentMetadata, 'output').map((icon) => ({ ...icon, label: localizeMetaLabel(icon.label) })), [currentMetadata, localizeMetaLabel], ); // Compute from current model each render to avoid stale variants // in draft/session transitions. const availableVariants = getCurrentModelVariants(); const hasVariants = availableVariants.length > 0; const costRows = [ { label: 'Input', value: formatCost(currentMetadata?.cost?.input) }, { label: 'Output', value: formatCost(currentMetadata?.cost?.output) }, { label: 'Cache read', value: formatCost(currentMetadata?.cost?.cache_read) }, { label: 'Cache write', value: formatCost(currentMetadata?.cost?.cache_write) }, ]; const limitRows = [ { label: 'Context', value: formatTokens(currentMetadata?.limit?.context) }, { label: 'Output', value: formatTokens(currentMetadata?.limit?.output) }, ]; const prevAgentNameRef = React.useRef(undefined); const explicitAgentSwitchRef = React.useRef(null); const latestLoadedUserChoiceRestoreRef = React.useRef(null); const currentSessionDirectory = currentSessionId ? getDirectoryForSession(currentSessionId) : undefined; const hasRenderableCurrentSessionSnapshot = useSessionRenderable( currentSessionId ?? '', currentSessionDirectory ?? undefined, ); const currentSessionMessagesFromSync = useSessionMessages(currentSessionId ?? '', currentSessionDirectory ?? undefined); // Skip synthetic subagent-completion nudges — restoring from them resets a // manual model override back to the agent default (issue #2404). const latestLoadedUserChoice = React.useMemo(() => { return findLatestUserModelChoice( currentSessionMessagesFromSync, (messageId) => getSyncParts(messageId, currentSessionDirectory ?? undefined), ); }, [currentSessionDirectory, currentSessionMessagesFromSync]); const tryApplyModelSelection = React.useCallback( (providerId: string, modelId: string, agentName?: string): ModelApplyResult => { if (!providerId || !modelId) { return 'model-missing'; } const provider = providers.find(p => p.id === providerId); if (!provider) { return 'provider-missing'; } const providerModels = Array.isArray(provider.models) ? provider.models : []; const modelExists = providerModels.find((m: ProviderModel) => m.id === modelId); if (!modelExists) { return 'model-missing'; } const providerMatches = currentProviderId === providerId; const modelMatches = currentModelId === modelId; if (providerMatches && modelMatches) { return 'applied'; } setProvider(providerId); setModel(modelId); if (currentSessionId) { saveSessionModelSelection(currentSessionId, providerId, modelId); if (agentName) { saveAgentModelForSession(currentSessionId, agentName, providerId, modelId); } } return 'applied'; }, [providers, currentProviderId, currentModelId, setProvider, setModel, currentSessionId, saveAgentModelForSession, saveSessionModelSelection], ); const getModelVariantOptions = React.useCallback((providerId: string, modelId: string) => { const provider = providers.find((entry) => entry.id === providerId); const model = provider?.models.find((entry) => entry.id === modelId) as { variants?: Record } | undefined; const variants = model?.variants; return variants ? Object.keys(variants) : []; }, [providers]); const resolveModelVariantSelection = React.useCallback((providerId: string, modelId: string) => { const variantOptions = getModelVariantOptions(providerId, modelId); if (variantOptions.length === 0) { return undefined; } const effectiveAgentName = uiAgentName || currentAgentName; if (currentSessionId && effectiveAgentName) { const savedVariant = getAgentModelVariantForSession(currentSessionId, effectiveAgentName, providerId, modelId); if (savedVariant && variantOptions.includes(savedVariant)) { return savedVariant; } } if (currentProviderId === providerId && currentModelId === modelId && currentVariant && variantOptions.includes(currentVariant)) { return currentVariant; } if (!currentSessionId && settingsDefaultVariant && variantOptions.includes(settingsDefaultVariant)) { return settingsDefaultVariant; } return undefined; }, [ currentAgentName, currentModelId, currentProviderId, currentSessionId, currentVariant, getAgentModelVariantForSession, getModelVariantOptions, settingsDefaultVariant, uiAgentName, ]); const resolveLiveAgentName = React.useCallback(() => { const liveConfigAgentName = useConfigStore.getState().currentAgentName; if (currentSessionId) { return useSelectionStore.getState().getSessionAgentSelection(currentSessionId) || stickySessionAgentRef.current || liveConfigAgentName || currentAgentName; } return liveConfigAgentName || currentAgentName; }, [currentAgentName, currentSessionId]); const commitVariantSelectionForModel = React.useCallback((providerId: string, modelId: string, variant: string | undefined, agentNameOverride?: string | null) => { const variantOptions = getModelVariantOptions(providerId, modelId); if (variantOptions.length === 0) { manualVariantSelectionRef.current = false; setCurrentVariant(undefined); return; } manualVariantSelectionRef.current = true; setCurrentVariant(variant); addRecentEffort(providerId, modelId, variant); const effectiveAgentName = agentNameOverride ?? resolveLiveAgentName(); if (currentSessionId && effectiveAgentName) { saveAgentModelVariantForSession(currentSessionId, effectiveAgentName, providerId, modelId, variant); } }, [ addRecentEffort, currentSessionId, getModelVariantOptions, resolveLiveAgentName, saveAgentModelVariantForSession, setCurrentVariant, ]); const applyModelSelectionWithVariant = React.useCallback((providerId: string, modelId: string, variant: string | undefined, agentNameOverride?: string | null) => { const effectiveAgentName = agentNameOverride ?? resolveLiveAgentName() ?? undefined; const result = tryApplyModelSelection(providerId, modelId, effectiveAgentName); if (result !== 'applied') { return result; } addRecentModel(providerId, modelId); commitVariantSelectionForModel(providerId, modelId, variant, effectiveAgentName); return 'applied'; }, [addRecentModel, commitVariantSelectionForModel, resolveLiveAgentName, tryApplyModelSelection]); React.useEffect(() => { if (!currentSessionId) { latestLoadedUserChoiceRestoreRef.current = null; return; } if (!contextHydrated || providers.length === 0 || !hasRenderableCurrentSessionSnapshot || !latestLoadedUserChoice?.providerID || !latestLoadedUserChoice.modelID) { return; } const restoreKey = [ currentSessionId, latestLoadedUserChoice.id, latestLoadedUserChoice.agent ?? '', latestLoadedUserChoice.providerID, latestLoadedUserChoice.modelID, latestLoadedUserChoice.variant ?? '', ].join('|'); if (latestLoadedUserChoiceRestoreRef.current === restoreKey) { return; } // Manual session override wins over historical / synthetic message metadata. const savedSessionModel = getSessionModelSelection(currentSessionId); if (shouldPreserveManualModelOverride({ selectionSource: useConfigStore.getState().selectionSource, savedSessionModel, candidate: latestLoadedUserChoice, })) { if (savedSessionModel) { applyModelSelectionWithVariant( savedSessionModel.providerId, savedSessionModel.modelId, resolveModelVariantSelection(savedSessionModel.providerId, savedSessionModel.modelId), currentAgentName || undefined, ); } latestLoadedUserChoiceRestoreRef.current = restoreKey; return; } if (latestLoadedUserChoice.agent && currentAgentName !== latestLoadedUserChoice.agent) { setAgent(latestLoadedUserChoice.agent); } const historicalVariant = latestLoadedUserChoice.variant && getModelVariantOptions(latestLoadedUserChoice.providerID, latestLoadedUserChoice.modelID).includes(latestLoadedUserChoice.variant) ? latestLoadedUserChoice.variant : undefined; const applyResult = applyModelSelectionWithVariant( latestLoadedUserChoice.providerID, latestLoadedUserChoice.modelID, historicalVariant, latestLoadedUserChoice.agent || currentAgentName || undefined, ); if (applyResult !== 'applied') { return; } if (latestLoadedUserChoice.agent) { saveSessionAgentSelection(currentSessionId, latestLoadedUserChoice.agent); saveAgentModelVariantForSession( currentSessionId, latestLoadedUserChoice.agent, latestLoadedUserChoice.providerID, latestLoadedUserChoice.modelID, historicalVariant, ); } saveSessionModelSelection(currentSessionId, latestLoadedUserChoice.providerID, latestLoadedUserChoice.modelID); latestLoadedUserChoiceRestoreRef.current = restoreKey; }, [ currentSessionId, currentAgentName, contextHydrated, providers, hasRenderableCurrentSessionSnapshot, latestLoadedUserChoice, setAgent, applyModelSelectionWithVariant, getModelVariantOptions, getSessionModelSelection, resolveModelVariantSelection, saveSessionAgentSelection, saveAgentModelVariantForSession, saveSessionModelSelection, ]); React.useEffect(() => { if (!currentSessionId) { latestLoadedUserChoiceRestoreRef.current = null; return; } if (!contextHydrated || providers.length === 0 || agents.length === 0) { return; } const applySavedSelections = (): 'resolved' | 'waiting' | 'continue' => { const savedSessionModel = getSessionModelSelection(currentSessionId); const savedAgentName = currentSessionId ? useSelectionStore.getState().getSessionAgentSelection(currentSessionId) : null; if (savedAgentName) { if (currentAgentName !== savedAgentName) { setAgent(savedAgentName); } const savedModel = getAgentModelForSession(currentSessionId, savedAgentName); if (savedModel) { const result = tryApplyModelSelection(savedModel.providerId, savedModel.modelId, savedAgentName); if (result === 'applied') { return 'resolved'; } if (result === 'provider-missing') { return 'waiting'; } } } if (savedSessionModel) { const result = tryApplyModelSelection(savedSessionModel.providerId, savedSessionModel.modelId, savedAgentName || currentAgentName || undefined); if (result === 'applied') { return 'resolved'; } if (result === 'provider-missing') { return 'waiting'; } } for (const agent of agents) { const selection = getAgentModelForSession(currentSessionId, agent.name); if (!selection) { continue; } if (currentAgentName !== agent.name) { setAgent(agent.name); } const existingSelection = useSelectionStore.getState().getSessionAgentSelection(currentSessionId) || stickySessionAgentRef.current; if (!existingSelection) { saveSessionAgentSelection(currentSessionId, agent.name); } const result = tryApplyModelSelection(selection.providerId, selection.modelId, agent.name); if (result === 'applied') { return 'resolved'; } if (result === 'provider-missing') { return 'waiting'; } } return 'continue'; }; const applyFallbackAgent = () => { if (agents.length === 0) { return; } const existingSelection = currentSessionId ? (useSelectionStore.getState().getSessionAgentSelection(currentSessionId) || stickySessionAgentRef.current) : null; // If we already have a valid agent selected (often from server-injected mode switch), // don't override it with a fallback. const preferred = (currentSessionId ? (useSelectionStore.getState().getSessionAgentSelection(currentSessionId) || stickySessionAgentRef.current) : null) || currentAgentName; if (preferred && agents.some((agent) => agent.name === preferred)) { if (currentAgentName !== preferred) { setAgent(preferred); } return; } const fallbackAgent = agents.find(agent => agent.name === 'build') || primaryAgents[0] || agents[0]; if (!fallbackAgent) { return; } if (!existingSelection) { saveSessionAgentSelection(currentSessionId, fallbackAgent.name); } if (currentAgentName !== fallbackAgent.name) { setAgent(fallbackAgent.name); } if (fallbackAgent.model?.providerID && fallbackAgent.model?.modelID) { tryApplyModelSelection(fallbackAgent.model.providerID, fallbackAgent.model.modelID, fallbackAgent.name); } }; const savedOutcome = applySavedSelections(); if (savedOutcome === 'resolved' || savedOutcome === 'waiting') { return; } if (!hasRenderableCurrentSessionSnapshot) { if (!sync.isLoading(currentSessionId)) { void sync.ensureSessionRenderable(currentSessionId); } return; } if (latestLoadedUserChoice) { return; } applyFallbackAgent(); }, [ currentSessionId, hasRenderableCurrentSessionSnapshot, latestLoadedUserChoice, agents, primaryAgents, currentAgentName, getSessionModelSelection, getAgentModelForSession, setAgent, tryApplyModelSelection, saveSessionAgentSelection, contextHydrated, providers, sync, ]); React.useEffect(() => { if (!contextHydrated) { return; } const abortController = new AbortController(); const handleAgentSwitch = async () => { try { if (currentAgentName !== prevAgentNameRef.current) { prevAgentNameRef.current = currentAgentName; if (currentAgentName && currentSessionId) { const shouldPreferAgentModel = explicitAgentSwitchRef.current === currentAgentName; explicitAgentSwitchRef.current = null; await new Promise((resolve) => { const timer = setTimeout(resolve, 50); abortController.signal.addEventListener('abort', () => { clearTimeout(timer); resolve(); }); }); if (abortController.signal.aborted) { return; } const selectedAgent = shouldPreferAgentModel ? agents.find((agent) => agent.name === currentAgentName) : undefined; if (selectedAgent?.model?.providerID && selectedAgent.model.modelID) { const result = tryApplyModelSelection( selectedAgent.model.providerID, selectedAgent.model.modelID, currentAgentName, ); if (result === 'applied' || result === 'provider-missing') { if (result === 'applied') { saveSessionModelSelection( currentSessionId, selectedAgent.model.providerID, selectedAgent.model.modelID, ); saveAgentModelForSession( currentSessionId, currentAgentName, selectedAgent.model.providerID, selectedAgent.model.modelID, ); } return; } } const persistedChoice = getAgentModelForSession(currentSessionId, currentAgentName); if (persistedChoice) { const result = tryApplyModelSelection( persistedChoice.providerId, persistedChoice.modelId, currentAgentName, ); if (result === 'applied' || result === 'provider-missing') { return; } } } } } catch (error) { console.error('[ModelControls] Agent change error:', error); } }; handleAgentSwitch(); return () => { abortController.abort(); }; }, [ agents, currentAgentName, currentSessionId, getAgentModelForSession, saveAgentModelForSession, saveSessionModelSelection, tryApplyModelSelection, contextHydrated, ]); React.useEffect(() => { if (!contextHydrated || !currentAgentName) { manualVariantSelectionRef.current = false; setCurrentVariant(undefined); return; } if (!currentProviderId || !currentModelId) { manualVariantSelectionRef.current = false; setCurrentVariant(undefined); return; } if (availableVariants.length === 0) { manualVariantSelectionRef.current = false; setCurrentVariant(undefined); return; } if (currentVariant && !availableVariants.includes(currentVariant)) { setCurrentVariant(undefined); return; } // Draft state (no session yet): seed from settings default, but don't override // user selection while drafting. if (!currentSessionId) { if (!currentVariant && !manualVariantSelectionRef.current) { const desired = settingsDefaultVariant && availableVariants.includes(settingsDefaultVariant) ? settingsDefaultVariant : undefined; setCurrentVariant(desired); } return; } const savedVariant = getAgentModelVariantForSession( currentSessionId, currentAgentName, currentProviderId, currentModelId, ); const resolvedSaved = savedVariant && availableVariants.includes(savedVariant) ? savedVariant : settingsDefaultVariant && availableVariants.includes(settingsDefaultVariant) ? settingsDefaultVariant : undefined; setCurrentVariant(resolvedSaved); manualVariantSelectionRef.current = false; }, [ availableVariants, contextHydrated, currentSessionId, currentAgentName, currentProviderId, currentModelId, currentVariant, getAgentModelVariantForSession, setCurrentVariant, settingsDefaultVariant, ]); React.useEffect(() => { manualVariantSelectionRef.current = false; }, [currentProviderId, currentModelId]); const handleVariantSelect = React.useCallback((variant: string | undefined) => { if (currentProviderId && currentModelId) { commitVariantSelectionForModel(currentProviderId, currentModelId, variant); } }, [commitVariantSelectionForModel, currentModelId, currentProviderId]); const handleAgentChange = React.useCallback((agentName: string, options?: { closeModelSelector?: boolean }) => { try { explicitAgentSwitchRef.current = agentName; setAgent(agentName); addRecentAgent(agentName); if (options?.closeModelSelector ?? true) { setAgentMenuOpen(false); } if (currentSessionId) { saveSessionAgentSelection(currentSessionId, agentName); } if (isCompact) { closeMobilePanel(); } } catch (error) { console.error('[ModelControls] Handle agent change error:', error); } }, [ addRecentAgent, closeMobilePanel, currentSessionId, isCompact, saveSessionAgentSelection, setAgent, setAgentMenuOpen, ]); const handleCycleAgentFromModelPicker = React.useCallback((direction: 1 | -1) => { const nextAgentName = getCycledPrimaryAgentName(agents, currentAgentName, direction); if (!nextAgentName) { return; } handleAgentChange(nextAgentName, { closeModelSelector: false }); }, [agents, currentAgentName, handleAgentChange]); const getCycleAgentDirectionFromEvent = React.useCallback((event: KeyboardEvent | React.KeyboardEvent): 1 | -1 | null => { const cycleAgentBackwardShortcut = cycleAgentShortcut && !cycleAgentShortcut.includes('shift') ? normalizeCombo(`shift+${cycleAgentShortcut}`) : ''; if (cycleAgentBackwardShortcut && eventMatchesShortcut(event, cycleAgentBackwardShortcut)) { return -1; } if (eventMatchesShortcut(event, cycleAgentShortcut)) { return 1; } return null; }, [cycleAgentShortcut]); const handleProviderAndModelChange = ( providerId: string, modelId: string, options?: { applyVariant?: boolean; variant?: string | undefined; agentName?: string | null }, ) => { try { const effectiveAgentName = options?.agentName ?? resolveLiveAgentName() ?? undefined; const result = options?.applyVariant ? applyModelSelectionWithVariant(providerId, modelId, options.variant, effectiveAgentName) : tryApplyModelSelection(providerId, modelId, effectiveAgentName); if (result !== 'applied') { if (result === 'provider-missing') { console.error('[ModelControls] Provider not available for selection:', providerId); } else if (result === 'model-missing') { console.error('[ModelControls] Model not available for selection:', { providerId, modelId }); } return; } if (!options?.applyVariant) { // Add to recent models on successful selection. addRecentModel(providerId, modelId); } setAgentMenuOpen(false); if (isCompact) { closeMobilePanel(); } // Restore focus to chat input after model selection. requestAnimationFrame(focusChatInput); } catch (error) { console.error('[ModelControls] Handle model change error:', error); } }; const getModelDisplayName = (model: ProviderModel | undefined, fallbackModelId?: string) => { return getSharedModelDisplayName(model, fallbackModelId, { maxLength: 40 }); }; const getProviderDisplayName = () => { const provider = providers.find(p => p.id === currentProviderId); return provider?.name || currentProviderId; }; const getCurrentModelDisplayName = () => { if (!currentModelId) return t('chat.modelControls.selectModel'); const currentModel = models.find((m: ProviderModel) => m.id === currentModelId); return getModelDisplayName(currentModel, currentModelId) || t('chat.modelControls.selectModel'); }; const currentModelDisplayName = getCurrentModelDisplayName(); const modelLabelRef = React.useRef(null); const isModelLabelTruncated = useIsTextTruncated(modelLabelRef, [currentModelDisplayName, isCompact]); const getAgentDisplayName = () => { if (!uiAgentName) { const buildAgent = primaryAgents.find(agent => agent.name === 'build'); const defaultAgent = buildAgent || primaryAgents[0]; return defaultAgent ? capitalizeAgentName(defaultAgent.name) : 'Select Agent'; } const agent = agents.find(a => a.name === uiAgentName); return agent ? capitalizeAgentName(agent.name) : capitalizeAgentName(uiAgentName); }; const capitalizeAgentName = (name: string) => { return name.charAt(0).toUpperCase() + name.slice(1); }; const toggleMobileProviderExpansion = React.useCallback((providerId: string) => { setExpandedMobileProviders((prev) => { const next = new Set(prev); if (next.has(providerId)) { next.delete(providerId); } else { next.add(providerId); } return next; }); }, []); const handleLongPressStart = React.useCallback((type: 'model' | 'agent') => { if (longPressTimerRef.current) { clearTimeout(longPressTimerRef.current); } longPressTimerRef.current = setTimeout(() => { setMobileTooltipOpen(type); }, 500); }, []); const handleLongPressEnd = React.useCallback(() => { if (longPressTimerRef.current) { clearTimeout(longPressTimerRef.current); } }, []); React.useEffect(() => { return () => { if (longPressTimerRef.current) { clearTimeout(longPressTimerRef.current); } }; }, []); const renderMobileModelTooltip = () => { if (!isCompact || mobileTooltipOpen !== 'model') return null; return (
{}
{t('chat.modelControls.provider')}
{getProviderDisplayName()}
{} {currentCapabilityIcons.length > 0 && (
{t('chat.modelControls.capabilities')}
{currentCapabilityIcons.map(({ key, icon, label }) => (
{label}
))}
)} {} {(inputModalityIcons.length > 0 || outputModalityIcons.length > 0) && (
{t('chat.modelControls.modalities')}
{inputModalityIcons.length > 0 && (
{t('chat.modelControls.input')}
{inputModalityIcons.map(({ key, icon, label }) => )}
)} {outputModalityIcons.length > 0 && (
{t('chat.modelControls.output')}
{outputModalityIcons.map(({ key, icon, label }) => )}
)}
)} {}
{t('chat.modelControls.limits')}
{t('chat.modelControls.context')} {formatTokens(currentMetadata?.limit?.context)}
{t('chat.modelControls.output')} {formatTokens(currentMetadata?.limit?.output)}
{}
{t('chat.modelControls.metadata')}
{t('chat.modelControls.knowledge')} {formatKnowledge(currentMetadata?.knowledge)}
{t('chat.modelControls.release')} {formatDate(currentMetadata?.release_date)}
); }; const renderMobileAgentTooltip = () => { if (!isCompact || mobileTooltipOpen !== 'agent' || !currentAgent) return null; const hasCustomPrompt = Boolean(currentAgent.prompt && currentAgent.prompt.trim().length > 0); const hasModelConfig = currentAgent.model?.providerID && currentAgent.model?.modelID; const hasTemperatureOrTopP = currentAgent.temperature !== undefined || currentAgent.topP !== undefined; const summarizePermission = (permissionName: string): { mode: EditPermissionMode; label: string } => { const rules = asPermissionRuleset(currentAgent.permission) ?? []; const hasCustom = rules.some((rule) => rule.permission === permissionName && rule.pattern !== '*'); const action = resolveWildcardPermissionAction(rules, permissionName) ?? 'ask'; if (hasCustom) { return { mode: 'ask', label: t('chat.modelControls.permissionLabel.custom') }; } if (action === 'allow') return { mode: 'allow', label: t('chat.modelControls.permissionLabel.allow') }; if (action === 'deny') return { mode: 'deny', label: t('chat.modelControls.permissionLabel.deny') }; return { mode: 'ask', label: t('chat.modelControls.permissionLabel.ask') }; }; const editPermissionSummary = summarizePermission('edit'); const bashPermissionSummary = summarizePermission('bash'); const webfetchPermissionSummary = summarizePermission('webfetch'); return (
{} {currentAgent.description && (
{currentAgent.description}
)} {}
{t('chat.modelControls.mode')}
{currentAgent.mode === 'primary' ? t('chat.modelControls.modeValue.primary') : currentAgent.mode === 'subagent' ? t('chat.modelControls.modeValue.subagent') : currentAgent.mode === 'all' ? t('chat.modelControls.modeValue.all') : t('chat.modelControls.modeValue.none')}
{} {(hasModelConfig || hasTemperatureOrTopP) && (
{t('chat.modelControls.model')}
{hasModelConfig && (
{currentAgent.model!.providerID} / {currentAgent.model!.modelID}
)} {hasTemperatureOrTopP && (
{currentAgent.temperature !== undefined && (
{t('chat.modelControls.temperature')} {currentAgent.temperature}
)} {currentAgent.topP !== undefined && (
{t('chat.modelControls.topP')} {currentAgent.topP}
)}
)}
)} {}
{t('chat.modelControls.permissions')}
{t('chat.modelControls.edit')}
{editPermissionSummary.label}
{t('chat.modelControls.bash')}
{bashPermissionSummary.label}
{t('chat.modelControls.webFetch')}
{webfetchPermissionSummary.label}
{} {hasCustomPrompt && (
{t('chat.modelControls.customPrompt')}
)}
); }; const renderMobileModelPanel = () => { if (!isCompact) return null; const normalizedQuery = mobileModelQuery.trim(); const filteredFavorites = favoriteModelsList.filter(({ model, providerID }) => { const provider = providers.find((entry) => entry.id === providerID); const providerName = provider?.name || providerID; const modelName = getModelDisplayName(model); return normalizedQuery.length === 0 || matchesModelSearch(modelName, normalizedQuery) || matchesModelSearch(providerName, normalizedQuery); }); const filteredRecents = recentModelsList.filter(({ model, providerID }) => { const provider = providers.find((entry) => entry.id === providerID); const providerName = provider?.name || providerID; const modelName = getModelDisplayName(model); return normalizedQuery.length === 0 || matchesModelSearch(modelName, normalizedQuery) || matchesModelSearch(providerName, normalizedQuery); }); const filteredProviders: { provider: (typeof visibleProviders)[number]; providerModels: ProviderModel[]; matchesProvider: boolean; }[] = []; for (const provider of visibleProviders) { const providerModels = Array.isArray(provider.models) ? provider.models : []; const matchesProvider = normalizedQuery.length === 0 ? true : matchesModelSearch(provider.name, normalizedQuery) || matchesModelSearch(provider.id, normalizedQuery); const matchingModels = normalizedQuery.length === 0 ? providerModels : providerModels.filter((model: ProviderModel) => { const name = getModelDisplayName(model); const id = typeof model.id === 'string' ? model.id : ''; return matchesModelSearch(name, normalizedQuery) || matchesModelSearch(id, normalizedQuery); }); const resolvedModels = matchesProvider && normalizedQuery.length > 0 ? providerModels : matchingModels; if (matchesProvider || resolvedModels.length > 0) { filteredProviders.push({ provider, providerModels: resolvedModels, matchesProvider }); } } const handleMobileModelApply = (providerId: string, modelId: string, variant: string | undefined) => { const result = applyModelSelectionWithVariant(providerId, modelId, variant); if (result !== 'applied') { if (result === 'provider-missing') { console.error('[ModelControls] Provider not available for selection:', providerId); } else if (result === 'model-missing') { console.error('[ModelControls] Model not available for selection:', { providerId, modelId }); } return; } setExpandedMobileModelKey(null); closeMobilePanel(); requestAnimationFrame(focusChatInput); }; const openMobileVariantOverflow = (providerId: string, modelId: string) => { setMobileVariantTarget({ providerId, modelId }); setActiveMobilePanel('variant'); }; const renderMobileModelRow = ({ model, providerId, modelId, showProviderLogo, }: { model: ProviderModel; providerId: string; modelId: string; showProviderLogo: boolean; }) => { const rowKey = buildModelRefKey(providerId, modelId); const isSelected = providerId === currentProviderId && modelId === currentModelId; const metadata = mergeModelMetadataWithLiveModel(providerId, model, getModelMetadata(providerId, modelId)); const variantOptions = getModelVariantOptions(providerId, modelId); const hasVariants = variantOptions.length > 0; const resolvedVariant = resolveModelVariantSelection(providerId, modelId); const variantLabel = hasVariants ? formatEffortLabel(resolvedVariant) : null; const isExpanded = expandedMobileModelKey === rowKey; const inlineVariantOptions = [undefined, ...variantOptions].slice(0, MAX_INLINE_MOBILE_VARIANT_OPTIONS); const hasVariantOverflow = inlineVariantOptions.length < variantOptions.length + 1; const capabilityIcons = getCapabilityIcons(metadata).map((icon) => ({ ...icon, label: localizeMetaLabel(icon.label), })); const modalityIcons = [ ...getModalityIcons(metadata, 'input').map((icon) => ({ ...icon, label: localizeMetaLabel(icon.label) })), ...getModalityIcons(metadata, 'output').map((icon) => ({ ...icon, label: localizeMetaLabel(icon.label) })), ]; const indicatorIcons = Array.from( new Map([...capabilityIcons, ...modalityIcons].map((icon) => [icon.key, icon])).values() ); const contextText = metadata?.limit?.context ? `${formatTokens(metadata.limit.context)} ctx` : null; return (
{hasVariants ? ( ) : null}
{isExpanded && hasVariants ? (
{inlineVariantOptions.map((variantOption) => { const isVariantSelected = variantOption === resolvedVariant || (!variantOption && !resolvedVariant); return ( ); })} {hasVariantOverflow ? ( ) : null}
) : null}
); }; const hasResults = filteredFavorites.length > 0 || filteredRecents.length > 0 || filteredProviders.length > 0; return (
{ setMobileModelQuery(event.target.value); setExpandedMobileModelKey(null); }} placeholder={t('chat.modelControls.searchProvidersOrModels')} className="pl-7 h-9 rounded-xl border-border/40 bg-[var(--surface-elevated)] typography-meta" /> {mobileModelQuery && ( )}
{!hasResults && (
{t('chat.modelControls.noProvidersOrModelsFound')}
)} {/* Favorites Section for Mobile */} {filteredFavorites.length > 0 && (
{t('chat.modelControls.favorites')}
{filteredFavorites.map(({ model, providerID, modelID }) => renderMobileModelRow({ model, providerId: providerID, modelId: modelID, showProviderLogo: true, }))}
)} {/* Recent Section for Mobile */} {filteredRecents.length > 0 && (
{t('chat.modelControls.recent')}
{filteredRecents.map(({ model, providerID, modelID }) => renderMobileModelRow({ model, providerId: providerID, modelId: modelID, showProviderLogo: true, }))}
)} {filteredProviders.map(({ provider, providerModels }) => { if (providerModels.length === 0) { return null; } const isActiveProvider = provider.id === currentProviderId; const isExpanded = expandedMobileProviders.has(provider.id) || normalizedQuery.length > 0; return (
{isExpanded && providerModels.length > 0 && (
{providerModels.map((model: ProviderModel) => renderMobileModelRow({ model, providerId: provider.id as string, modelId: model.id as string, showProviderLogo: false, }))}
)}
); })}
); }; const renderMobileVariantPanel = () => { if (!isCompact) return null; const targetProviderId = mobileVariantTarget?.providerId ?? currentProviderId; const targetModelId = mobileVariantTarget?.modelId ?? currentModelId; if (!targetProviderId || !targetModelId) return null; const targetVariants = getModelVariantOptions(targetProviderId, targetModelId); if (targetVariants.length === 0) return null; const selectedVariant = resolveModelVariantSelection(targetProviderId, targetModelId); const isDefault = !selectedVariant; const handleBack = () => { setActiveMobilePanel('model'); }; const handleSelect = (variant: string | undefined) => { const result = applyModelSelectionWithVariant(targetProviderId, targetModelId, variant); if (result !== 'applied') { return; } closeMobilePanel(); requestAnimationFrame(focusChatInput); }; return ( (

{t('chat.modelControls.thinking')}

{closeButton}
)) : undefined} >
{targetVariants.map((variant) => { const selected = selectedVariant === variant; const label = formatEffortLabel(variant); return ( ); })}
); }; const renderMobileAgentPanel = () => { if (!isCompact) return null; return (
{selectableDesktopAgents.map((agent) => { const isSelected = agent.name === uiAgentName; const agentColor = getAgentColor(agent.name); return ( ); })}
); }; const renderModelTooltipContent = () => ( {currentMetadata ? (
{currentMetadata.name || getCurrentModelDisplayName()} {getProviderDisplayName()}
{t('chat.modelControls.capabilities')}
{currentCapabilityIcons.length > 0 ? ( currentCapabilityIcons.map(({ key, icon, label }) => ) ) : ( {t('chat.modelControls.modeValue.none')} )}
{t('chat.modelControls.modalities')}
{t('chat.modelControls.input')}
{inputModalityIcons.length > 0 ? inputModalityIcons.map(({ key, icon, label }) => ) : -}
{t('chat.modelControls.output')}
{outputModalityIcons.length > 0 ? outputModalityIcons.map(({ key, icon, label }) => ) : -}
{t('chat.modelControls.costPerMillion')} {costRows.map((row) => (
{row.label} {row.value}
))}
{t('chat.modelControls.limits')} {limitRows.map((row) => (
{row.label} {row.value}
))}
{t('chat.modelControls.metadata')}
{t('chat.modelControls.knowledge')} {formatKnowledge(currentMetadata.knowledge)}
{t('chat.modelControls.release')} {formatDate(currentMetadata.release_date)}
) : (
{t('chat.modelControls.metadataUnavailable')}
)}
); const renderModelSelector = () => { const handleThinkingVariantKey = (e: React.KeyboardEvent, selectedItem: ModelPickerEntry) => { keyboardOwnsModelSelectionRef.current = true; if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return false; const { providerID, modelID } = selectedItem; const canonicalProvider = useConfigStore.getState().providers.find((provider) => provider.id === providerID); const canonicalModel = canonicalProvider?.models.find((model) => model.id === modelID) as { variants?: Record } | undefined; const variantKeys = canonicalModel?.variants ? Object.keys(canonicalModel.variants) : getModelVariantOptions(providerID, modelID); if (variantKeys.length === 0) return false; e.preventDefault(); e.stopPropagation(); const mapKey = buildModelRefKey(providerID, modelID); const hasPendingVariant = pendingThinkingVariants.has(mapKey); const currentPending = pendingThinkingVariants.get(mapKey); const activeModelVariant = hasPendingVariant ? currentPending : (currentProviderId === providerID && currentModelId === modelID ? currentVariant : undefined); const variantsWithDefault: Array = [undefined, ...variantKeys]; const currentVariantIndex = variantsWithDefault.indexOf(activeModelVariant); const safeCurrentIndex = currentVariantIndex >= 0 ? currentVariantIndex : 0; const direction = e.key === 'ArrowRight' ? 1 : -1; const nextVariantIndex = (safeCurrentIndex + direction + variantsWithDefault.length) % variantsWithDefault.length; const nextVariant = variantsWithDefault[nextVariantIndex]; setPendingThinkingVariants((prev) => { const next = new Map(prev); next.set(mapKey, nextVariant); return next; }); setAdjustedThinkingModels((prev) => { const next = new Set(prev); next.add(mapKey); return next; }); setModelPickerRenderVersion((version) => version + 1); return true; }; const handleModelPickerKeyDown = (e: React.KeyboardEvent, selectedItem: ModelPickerEntry | undefined) => { const cycleAgentDirection = getCycleAgentDirectionFromEvent(e); if (cycleAgentDirection) { e.preventDefault(); handleCycleAgentFromModelPicker(cycleAgentDirection); return; } if (selectedItem) handleThinkingVariantKey(e, selectedItem); }; const handleSharedModelSelect = (entry: ModelPickerEntry) => { const mapKey = buildModelRefKey(entry.providerID, entry.modelID); const pendingVariant = pendingThinkingVariants.get(mapKey); const wasAdjusted = adjustedThinkingModels.has(mapKey); const effectiveAgentName = resolveLiveAgentName(); handleProviderAndModelChange(entry.providerID, entry.modelID, wasAdjusted ? { applyVariant: true, variant: pendingVariant, agentName: effectiveAgentName } : { agentName: effectiveAgentName }); }; const handleModelShortcutKeyDownCapture = (e: React.KeyboardEvent) => { const cycleAgentDirection = getCycleAgentDirectionFromEvent(e); if (!cycleAgentDirection) { return; } e.preventDefault(); e.stopPropagation(); keyboardOwnsModelSelectionRef.current = true; handleCycleAgentFromModelPicker(cycleAgentDirection); }; const handleModelMenuOpenChange = (nextOpen: boolean) => { setAgentMenuOpen(nextOpen); }; const modelPickerLabels = { searchPlaceholder: t('chat.modelControls.searchModels'), noResults: t('chat.modelControls.noModelsFound'), favorites: t('chat.modelControls.favorites'), recent: t('chat.modelControls.recent'), keyboardHint: t('chat.modelControls.keyboardHintNavigate'), favorite: t('chat.modelControls.favoriteAria'), unfavorite: t('chat.modelControls.unfavoriteAria'), capabilities: t('chat.modelControls.capabilities'), capabilityToolCalling: t('chat.modelControls.capability.toolCalling'), capabilityReasoning: t('chat.modelControls.capability.reasoning'), input: t('chat.modelControls.input'), output: t('chat.modelControls.output'), costPerMillion: t('chat.modelControls.costPerMillion'), }; const renderThinkingSlot = (entry: ModelPickerEntry, { isHighlighted, isSelected }: { isHighlighted: boolean; isSelected: boolean }) => { const hasThinkingVariants = getModelVariantOptions(entry.providerID, entry.modelID).length > 0; const mapKey = buildModelRefKey(entry.providerID, entry.modelID); const wasAdjusted = adjustedThinkingModels.has(mapKey); if (!hasThinkingVariants || (!isHighlighted && !isSelected)) return null; const hasPendingVariant = pendingThinkingVariants.has(mapKey); const pendingVariant = pendingThinkingVariants.get(mapKey); const effectiveVariant = hasPendingVariant ? pendingVariant : (isSelected ? currentVariant : undefined); const displayLabel = effectiveVariant ? effectiveVariant.charAt(0).toUpperCase() + effectiveVariant.slice(1) : 'Default'; return ( Thinking: {displayLabel} ); }; return ( {!isCompact ? (
{!isReady ? ( <> {readinessLabel} ) : currentProviderId ? ( <> ) : ( )} {isReady && ( {currentModelDisplayName} )}
{ activeModelPickerEntryRef.current = entry; }} onVariantKey={handleThinkingVariantKey} isFavorite={(entry) => isFavoriteModel(entry.providerID, entry.modelID)} onToggleFavorite={(entry) => toggleFavoriteModel(entry.providerID, entry.modelID)} renderRowEnd={renderThinkingSlot} renderVersion={modelPickerRenderVersion} onReorderFavorite={(active, over) => reorderFavoriteModel( active.providerID, active.modelID, over.providerID, over.modelID, )} reorderFavoriteAriaLabel={t('chat.modelControls.reorderFavoriteAria')} reorderFavoriteTitle={t('chat.modelControls.reorderFavoriteTitle')} providerOrder={providerOrder} onReorderProvider={setProviderOrder} reorderProviderTitle={t('chat.modelControls.reorderProviderTitle')} footerContent={(activeEntry) => { const activeHasThinkingVariants = activeEntry ? getModelVariantOptions(activeEntry.providerID, activeEntry.modelID).length > 0 : false; return (
{t('chat.modelControls.keyboardHintNavigate')} {t('chat.modelControls.keyboardHintSwitchAgent', { shortcut: 'Tab' })} {activeHasThinkingVariants ? {t('chat.modelControls.keyboardHintThinking')} : null}
); }} tooltipsEnabled={agentMenuOpen} onEscape={() => setAgentMenuOpen(false)} />
) : ( )} {renderModelTooltipContent()}
); }; const renderAgentTooltipContent = () => { if (!currentAgent) { return (
{t('chat.modelControls.noAgentSelected')}
); } const hasCustomPrompt = Boolean(currentAgent.prompt && currentAgent.prompt.trim().length > 0); const hasModelConfig = currentAgent.model?.providerID && currentAgent.model?.modelID; const hasTemperatureOrTopP = currentAgent.temperature !== undefined || currentAgent.topP !== undefined; const summarizePermission = (permissionName: string): { mode: EditPermissionMode; label: string } => { const rules = asPermissionRuleset(currentAgent.permission) ?? []; const hasCustom = rules.some((rule) => rule.permission === permissionName && rule.pattern !== '*'); const action = resolveWildcardPermissionAction(rules, permissionName) ?? 'ask'; if (hasCustom) { return { mode: 'ask', label: t('chat.modelControls.permissionLabel.custom') }; } if (action === 'allow') return { mode: 'allow', label: t('chat.modelControls.permissionLabel.allow') }; if (action === 'deny') return { mode: 'deny', label: t('chat.modelControls.permissionLabel.deny') }; return { mode: 'ask', label: t('chat.modelControls.permissionLabel.ask') }; }; const editPermissionSummary = summarizePermission('edit'); const bashPermissionSummary = summarizePermission('bash'); const webfetchPermissionSummary = summarizePermission('webfetch'); return (
{capitalizeAgentName(currentAgent.name)} {currentAgent.description && ( {currentAgent.description} )}
{t('chat.modelControls.mode')} {currentAgent.mode === 'primary' ? t('chat.modelControls.modeValue.primary') : currentAgent.mode === 'subagent' ? t('chat.modelControls.modeValue.subagent') : currentAgent.mode === 'all' ? t('chat.modelControls.modeValue.all') : t('chat.modelControls.modeValue.none')}
{(hasModelConfig || hasTemperatureOrTopP) && (
{t('chat.modelControls.model')} {hasModelConfig ? ( {currentAgent.model!.providerID} / {currentAgent.model!.modelID} ) : ( {t('chat.modelControls.modeValue.none')} )} {hasTemperatureOrTopP && (
{currentAgent.temperature !== undefined && (
{t('chat.modelControls.temperature')} {currentAgent.temperature}
)} {currentAgent.topP !== undefined && (
{t('chat.modelControls.topP')} {currentAgent.topP}
)}
)}
)}
{t('chat.modelControls.permissions')}
{t('chat.modelControls.edit')}
{editPermissionSummary.label}
{t('chat.modelControls.bash')}
{bashPermissionSummary.label}
{t('chat.modelControls.webFetch')}
{webfetchPermissionSummary.label}
{hasCustomPrompt && (
{t('chat.modelControls.customPrompt')}
)}
); }; const renderVariantSelector = () => { if (!isReady || !hasVariants) { return null; } const displayVariant = currentVariant ?? t('chat.modelControls.default'); const isDefault = !currentVariant; const colorClass = isDefault ? 'text-muted-foreground' : 'text-[color:var(--status-info)]'; if (isCompact) { return ( ); } return (
{displayVariant}
{t('chat.modelControls.thinking')} handleVariantSelect(undefined)}>
{t('chat.modelControls.default')} {isDefault && }
{availableVariants.length > 0 && } {availableVariants.map((variant) => { const selected = currentVariant === variant; const label = variant.charAt(0).toUpperCase() + variant.slice(1); return ( handleVariantSelect(variant)} >
{label} {selected && }
); })}

Thinking: {displayVariant}

); }; const renderAgentSelector = () => { if (!isCompact) { return (
{!isReady ? ( <> {readinessLabel} ) : ( <> {getAgentDisplayName()} )}
setAgentSearchQuery(e.target.value)} onKeyDown={(e) => { e.stopPropagation(); }} className="pl-8 h-8 typography-meta" />
{!agentSearchQuery.trim() && defaultAgentName && ( <> handleAgentChange(defaultAgentName)} >
{t('chat.modelControls.resetToDefault')}
)} {sortedAndFilteredAgents.length === 0 ? (
No agents found
) : ( sortedAndFilteredAgents.map((agent) => ( handleAgentChange(agent.name)} >
{capitalizeAgentName(agent.name)}
{agent.description && ( {agent.description} )}
)) )}
{renderAgentTooltipContent()}
); } return ( ); }; const inlineClassName = cn( '@container/model-controls flex items-center min-w-0', // Only force full-width + truncation behaviors on true mobile layouts. // VS Code also uses "compact" mode, but should keep its right-aligned inline sizing. isMobile && 'w-full', className, ); return ( <>
{renderVariantSelector()} {renderModelSelector()} {renderAgentSelector()}
{renderMobileModelPanel()} {renderMobileVariantPanel()} {renderMobileAgentPanel()} {renderMobileModelTooltip()} {renderMobileAgentTooltip()} ); };