diff --git a/packages/ui/src/components/chat/ModelControls.tsx b/packages/ui/src/components/chat/ModelControls.tsx index c7959c8c..eb431784 100644 --- a/packages/ui/src/components/chat/ModelControls.tsx +++ b/packages/ui/src/components/chat/ModelControls.tsx @@ -191,19 +191,19 @@ const getModalityIcons = (metadata: ModelMetadata | undefined, direction: 'input const uniqueValues = Array.from(new Set(modalityList.map((item) => normalizeModality(item)))); - return uniqueValues - .map((modality) => { - const definition = MODALITY_ICON_MAP[modality]; - if (!definition) { - return null; - } - return { - key: modality, - icon: definition.icon, - label: definition.label, - } satisfies ModalityIcon; - }) - .filter((entry): entry is ModalityIcon => Boolean(entry)); + 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 COMPACT_NUMBER_FORMATTER = new Intl.NumberFormat('en-US', { @@ -220,8 +220,45 @@ const CURRENCY_FORMATTER = new Intl.NumberFormat('en-US', { minimumFractionDigits: 2, }); +const KNOWLEDGE_DATE_FORMATTER = new Intl.DateTimeFormat('en-US', { month: 'short', year: 'numeric' }); + +const DATE_FORMATTER = new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', +}); + 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 '—'; @@ -266,11 +303,13 @@ const formatCompactPrice = (metadata?: ModelMetadata): string | null => { }; const getCapabilityIcons = (metadata?: ModelMetadata) => { - return CAPABILITY_DEFINITIONS.filter((definition) => definition.isActive(metadata)).map((definition) => ({ - key: definition.key, - icon: definition.icon, - label: definition.label, - })); + 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) => { @@ -284,7 +323,7 @@ const formatKnowledge = (knowledge?: string) => { const monthIndex = Number.parseInt(match[2], 10) - 1; const knowledgeDate = new Date(Date.UTC(year, monthIndex, 1)); if (!Number.isNaN(knowledgeDate.getTime())) { - return new Intl.DateTimeFormat('en-US', { month: 'short', year: 'numeric' }).format(knowledgeDate); + return KNOWLEDGE_DATE_FORMATTER.format(knowledgeDate); } } @@ -301,11 +340,7 @@ const formatDate = (value?: string) => { return value; } - return new Intl.DateTimeFormat('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - }).format(parsedDate); + return DATE_FORMATTER.format(parsedDate); }; interface ModelControlsProps { @@ -394,10 +429,16 @@ export const ModelControls: React.FC = ({ const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen); const setSettingsPage = useUIStore((state) => state.setSettingsPage); const hiddenModels = useUIStore((state) => state.hiddenModels); - const collapsedProviderSet = React.useMemo( - () => new Set(collapsedModelProviders.map((providerId) => providerId.trim()).filter(Boolean)), - [collapsedModelProviders] - ); + const collapsedProviderSet = React.useMemo(() => { + const result = new Set(); + for (const providerId of collapsedModelProviders) { + const trimmed = providerId.trim(); + if (trimmed) { + result.add(trimmed); + } + } + return result; + }, [collapsedModelProviders]); // Separate state for agent selector to avoid conflict with model selector const [isAgentSelectorOpen, setIsAgentSelectorOpen] = React.useState(false); @@ -473,13 +514,10 @@ export const ModelControls: React.FC = ({ React.useEffect(() => { if (activeMobilePanel !== 'model') { setMobileModelQuery(''); + setExpandedMobileModelKey(null); } }, [activeMobilePanel]); - React.useEffect(() => { - setExpandedMobileModelKey(null); - }, [mobileModelQuery]); - // Handle model selector close behavior (separate from agent selector) const prevModelSelectorOpenRef = React.useRef(isModelSelectorOpen); React.useEffect(() => { @@ -552,44 +590,28 @@ export const ModelControls: React.FC = ({ const sizeVariant: 'mobile' | 'vscode' | 'default' = isMobile ? 'mobile' : isVSCodeRuntime ? 'vscode' : 'default'; const buttonHeight = sizeVariant === 'mobile' ? 'h-9' : sizeVariant === 'vscode' ? 'h-6' : 'h-8'; - const editToggleIconClass = sizeVariant === 'mobile' ? 'h-5 w-5' : sizeVariant === 'vscode' ? 'h-4 w-4' : 'h-4 w-4'; - const controlIconSize = sizeVariant === 'mobile' ? 'h-5 w-5' : sizeVariant === 'vscode' ? 'h-4 w-4' : 'h-4 w-4'; + 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 renderEditModeIcon = React.useCallback((mode: EditPermissionMode, iconClass = editToggleIconClass) => { - const combinedClassName = cn(iconClass, '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 ; - }, [editToggleIconClass]); const currentProvider = getCurrentProvider(); const models = Array.isArray(currentProvider?.models) ? currentProvider.models : []; const visibleProviders = React.useMemo(() => { - return providers - .map((provider) => { - 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 - ); - }); - return { ...provider, models: visibleModels }; - }) - .filter((provider) => provider.models.length > 0); + 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) => { @@ -632,11 +654,12 @@ export const ModelControls: React.FC = ({ if (!normalizedQuery) return true; return matchesModelSearch(modelName, normalizedQuery) || matchesModelSearch(providerName, normalizedQuery); }; + const providersById = new Map(providers.map((p) => [p.id, p])); let flatIndex = 0; for (const { model, providerID, modelID } of favoriteModelsList) { - const provider = providers.find((entry) => entry.id === providerID); + const provider = providersById.get(providerID); const providerName = provider?.name || providerID; const modelName = getModelDisplayName(model); if (!matchesQuery(modelName, providerName)) { @@ -649,7 +672,7 @@ export const ModelControls: React.FC = ({ } for (const { model, providerID, modelID } of recentModelsList) { - const provider = providers.find((entry) => entry.id === providerID); + const provider = providersById.get(providerID); const providerName = provider?.name || providerID; const modelName = getModelDisplayName(model); if (!matchesQuery(modelName, providerName)) { @@ -1123,6 +1146,7 @@ export const ModelControls: React.FC = ({ if (!contextHydrated) { return; } + const abortController = new AbortController(); const handleAgentSwitch = async () => { try { @@ -1130,7 +1154,17 @@ export const ModelControls: React.FC = ({ prevAgentNameRef.current = currentAgentName; if (currentAgentName && currentSessionId) { - await new Promise(resolve => setTimeout(resolve, 50)); + await new Promise((resolve) => { + const timer = setTimeout(resolve, 50); + abortController.signal.addEventListener('abort', () => { + clearTimeout(timer); + resolve(); + }); + }); + + if (abortController.signal.aborted) { + return; + } const persistedChoice = getAgentModelForSession(currentSessionId, currentAgentName); @@ -1152,6 +1186,10 @@ export const ModelControls: React.FC = ({ }; handleAgentSwitch(); + + return () => { + abortController.abort(); + }; }, [currentAgentName, currentSessionId, getAgentModelForSession, tryApplyModelSelection, contextHydrated]); React.useEffect(() => { @@ -1335,18 +1373,6 @@ export const ModelControls: React.FC = ({ return name.charAt(0).toUpperCase() + name.slice(1); }; - const renderIconBadge = (iconName: IconComponent, label: string, key: string) => ( - - - - ); - const toggleMobileProviderExpansion = React.useCallback((providerId: string) => { setExpandedMobileProviders((prev) => { const next = new Set(prev); @@ -1405,7 +1431,7 @@ export const ModelControls: React.FC = ({
{currentCapabilityIcons.map(({ key, icon, label }) => (
- {renderIconBadge(icon, label, `cap-${key}`)} + {label}
))} @@ -1422,7 +1448,7 @@ export const ModelControls: React.FC = ({
{t('chat.modelControls.input')}
- {inputModalityIcons.map(({ key, icon, label }) => renderIconBadge(icon, `${label} input`, `input-${key}`))} + {inputModalityIcons.map(({ key, icon, label }) => )}
)} @@ -1430,7 +1456,7 @@ export const ModelControls: React.FC = ({
{t('chat.modelControls.output')}
- {outputModalityIcons.map(({ key, icon, label }) => renderIconBadge(icon, `${label} output`, `output-${key}`))} + {outputModalityIcons.map(({ key, icon, label }) => )}
)} @@ -1560,7 +1586,7 @@ export const ModelControls: React.FC = ({
{t('chat.modelControls.edit')}
- {renderEditModeIcon(editPermissionSummary.mode, 'h-3.5 w-3.5')} + {editPermissionSummary.label} @@ -1569,7 +1595,7 @@ export const ModelControls: React.FC = ({
{t('chat.modelControls.bash')}
- {renderEditModeIcon(bashPermissionSummary.mode, 'h-3.5 w-3.5')} + {bashPermissionSummary.label} @@ -1578,7 +1604,7 @@ export const ModelControls: React.FC = ({
{t('chat.modelControls.webFetch')}
- {renderEditModeIcon(webfetchPermissionSummary.mode, 'h-3.5 w-3.5')} + {webfetchPermissionSummary.label} @@ -1592,7 +1618,7 @@ export const ModelControls: React.FC = ({
{t('chat.modelControls.customPrompt')} - +
)} @@ -1623,26 +1649,28 @@ export const ModelControls: React.FC = ({ || matchesModelSearch(providerName, normalizedQuery); }); - const filteredProviders = visibleProviders - .map((provider) => { - 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); - }); - return { - provider, - providerModels: matchesProvider && normalizedQuery.length > 0 ? providerModels : matchingModels, - matchesProvider, - }; - }) - .filter(({ matchesProvider, providerModels }) => matchesProvider || providerModels.length > 0); + 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 focusMobileComposer = () => { requestAnimationFrame(() => { @@ -1724,14 +1752,14 @@ export const ModelControls: React.FC = ({ )} > {showProviderLogo ? ( - + ) : null}
{getModelDisplayName(model)} - {isSelected ? : null} + {isSelected ? : null}
{contextText || indicatorIcons.length > 0 ? (
@@ -1748,11 +1776,11 @@ export const ModelControls: React.FC = ({ {indicatorIcons.map(({ key, icon: iconName, label }) => ( - + ))}
@@ -1770,7 +1798,7 @@ export const ModelControls: React.FC = ({ aria-label={isExpanded ? t('chat.modelControls.hideThinkingModes') : t('chat.modelControls.showThinkingModes')} > {variantLabel} - {isExpanded ? : } + {isExpanded ? : } ) : null}
@@ -1782,7 +1810,7 @@ export const ModelControls: React.FC = ({ toggleFavoriteModel(providerId, modelId); }} className={cn( - 'model-favorite-button flex h-5 w-5 items-center justify-center hover:text-primary/80 flex-shrink-0', + 'model-favorite-button flex size-5 items-center justify-center hover:text-primary/80 flex-shrink-0', isFavoriteModel(providerId, modelId) ? 'text-primary' : 'text-muted-foreground' )} aria-label={isFavoriteModel(providerId, modelId) @@ -1793,15 +1821,15 @@ export const ModelControls: React.FC = ({ : t('chat.modelControls.addToFavorites')} > {isFavoriteModel(providerId, modelId) ? ( - + ) : ( - + )}
{isExpanded && hasVariants ? ( -
+
{inlineVariantOptions.map((variantOption) => { const isVariantSelected = variantOption === resolvedVariant || (!variantOption && !resolvedVariant); @@ -1850,21 +1878,27 @@ export const ModelControls: React.FC = ({
- + setMobileModelQuery(event.target.value)} + onChange={(event) => { + 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 && ( )}
@@ -1880,7 +1914,7 @@ export const ModelControls: React.FC = ({ {filteredFavorites.length > 0 && (
- + {t('chat.modelControls.favorites')}
@@ -1898,7 +1932,7 @@ export const ModelControls: React.FC = ({ {filteredRecents.length > 0 && (
- + {t('chat.modelControls.recent')}
@@ -1936,7 +1970,7 @@ export const ModelControls: React.FC = ({
{provider.name} @@ -1946,9 +1980,9 @@ export const ModelControls: React.FC = ({ )}
{isExpanded ? ( - + ) : ( - + )} @@ -2012,7 +2046,7 @@ export const ModelControls: React.FC = ({ onClick={handleBack} className="flex items-center gap-1 rounded-lg px-1.5 py-1 typography-meta text-muted-foreground hover:bg-interactive-hover" > - + {t('onboarding.common.actions.back')}

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

@@ -2031,7 +2065,7 @@ export const ModelControls: React.FC = ({ onClick={() => handleSelect(undefined)} > {t('chat.modelControls.default')} - {isDefault && } + {isDefault && } {targetVariants.map((variant) => { @@ -2050,7 +2084,7 @@ export const ModelControls: React.FC = ({ onClick={() => handleSelect(variant)} > {label} - {selected && } + {selected && } ); })} @@ -2089,7 +2123,7 @@ export const ModelControls: React.FC = ({ onClick={() => handleAgentChange(agent.name)} >
-
+
= ({ {capitalizeAgentName(agent.name)} {isSelected && ( - + )}
{agent.description && ( @@ -2128,7 +2162,7 @@ export const ModelControls: React.FC = ({
{currentCapabilityIcons.length > 0 ? ( currentCapabilityIcons.map(({ key, icon, label }) => - renderIconBadge(icon, label, `cap-${key}`) + ) ) : ( {t('chat.modelControls.modeValue.none')} @@ -2143,9 +2177,9 @@ export const ModelControls: React.FC = ({
{inputModalityIcons.length > 0 ? inputModalityIcons.map(({ key, icon, label }) => - renderIconBadge(icon, `${label} input`, `input-${key}`) + ) - : } + : -}
@@ -2153,9 +2187,9 @@ export const ModelControls: React.FC = ({
{outputModalityIcons.length > 0 ? outputModalityIcons.map(({ key, icon, label }) => - renderIconBadge(icon, `${label} output`, `output-${key}`) + ) - : } + : -}
@@ -2266,12 +2300,12 @@ export const ModelControls: React.FC = ({ {indicatorIcons.map(({ id, icon: iconName, label }) => ( - + ))}
@@ -2308,11 +2342,19 @@ export const ModelControls: React.FC = ({
{ modelItemRefs.current[flatIndex] = el; }} + role="option" + aria-selected={isSelected} className={cn( "typography-meta group flex items-center gap-2 px-2 py-1.5 rounded-md cursor-pointer", isHighlighted ? "bg-interactive-selection" : "hover:bg-interactive-hover/50" )} onClick={() => handleProviderAndModelChange(providerID, modelID)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleProviderAndModelChange(providerID, modelID); + } + }} onMouseEnter={handlePointerActivity} onMouseMove={handlePointerActivity} > @@ -2326,16 +2368,16 @@ export const ModelControls: React.FC = ({ event.preventDefault(); event.stopPropagation(); }} - className="model-favorite-drag-handle flex h-4 w-4 flex-shrink-0 items-center justify-center text-muted-foreground hover:text-foreground" + className="model-favorite-drag-handle flex size-4 flex-shrink-0 items-center justify-center text-muted-foreground hover:text-foreground" aria-label={t('chat.modelControls.reorderFavoriteAria')} title={t('chat.modelControls.reorderFavoriteTitle')} > - + ) : null}
{showProviderLogo && ( - + )} {getModelDisplayName(model)} @@ -2370,7 +2412,7 @@ export const ModelControls: React.FC = ({
) : null} {isSelected && ( - + )}
@@ -2429,16 +2471,17 @@ export const ModelControls: React.FC = ({ return filterByQuery(modelName, providerName, desktopModelQuery); }); - const filteredProviders = visibleProviders - .map((provider) => { - const providerModels = Array.isArray(provider.models) ? provider.models : []; - const filteredModels = providerModels.filter((model: ProviderModel) => { - const modelName = getModelDisplayName(model); - return filterByQuery(modelName, provider.name || provider.id || '', desktopModelQuery); - }); - return { ...provider, models: filteredModels }; - }) - .filter((provider) => provider.models.length > 0); + const filteredProviders: typeof visibleProviders = []; + for (const provider of visibleProviders) { + const providerModels = Array.isArray(provider.models) ? provider.models : []; + const filteredModels = providerModels.filter((model: ProviderModel) => { + const modelName = getModelDisplayName(model); + return filterByQuery(modelName, provider.name || provider.id || '', desktopModelQuery); + }); + if (filteredModels.length > 0) { + filteredProviders.push({ ...provider, models: filteredModels }); + } + } const providerSections = filteredProviders.map((provider) => { const providerId = typeof provider.id === 'string' ? provider.id : ''; @@ -2457,9 +2500,12 @@ export const ModelControls: React.FC = ({ filteredRecents.length > 0 || filteredProviders.length > 0; - const filteredProviderIds = filteredProviders - .map((provider) => (typeof provider.id === 'string' ? provider.id : '')) - .filter(Boolean); + const filteredProviderIds: string[] = []; + for (const provider of filteredProviders) { + if (typeof provider.id === 'string' && provider.id) { + filteredProviderIds.push(provider.id); + } + } const favoriteModelLookup = new Map( filteredFavorites.map(({ providerID, modelID }) => [buildModelRefKey(providerID, modelID), { providerID, modelID }]) @@ -2687,7 +2733,7 @@ export const ModelControls: React.FC = ({ {/* Search Input */}
- + = ({ onChange={(e) => setDesktopModelQuery(e.target.value)} onKeyDown={handleModelKeyDown} className="pl-8 h-8 typography-meta" - autoFocus />
@@ -2718,8 +2763,8 @@ export const ModelControls: React.FC = ({ }} className="typography-meta group flex items-center gap-1 rounded-md px-2 py-1.5 cursor-pointer hover:bg-interactive-hover/50" > - - + + {t('chat.modelControls.addNewProvider')}
@@ -2738,7 +2783,7 @@ export const ModelControls: React.FC = ({ - + {t('chat.modelControls.favorites')} {favoriteSortingEnabled ? ( @@ -2788,7 +2833,7 @@ export const ModelControls: React.FC = ({ - + {t('chat.modelControls.recent')} {filteredRecents.map(({ model, providerID, modelID }) => { @@ -2849,14 +2894,14 @@ export const ModelControls: React.FC = ({
{provider.name} - + {isExpanded ? ( - + ) : ( - + )}
@@ -3023,7 +3068,7 @@ export const ModelControls: React.FC = ({
{t('chat.modelControls.edit')}
- {renderEditModeIcon(editPermissionSummary.mode, 'h-3.5 w-3.5')} + {editPermissionSummary.label} @@ -3032,7 +3077,7 @@ export const ModelControls: React.FC = ({
{t('chat.modelControls.bash')}
- {renderEditModeIcon(bashPermissionSummary.mode, 'h-3.5 w-3.5')} + {bashPermissionSummary.label} @@ -3041,7 +3086,7 @@ export const ModelControls: React.FC = ({
{t('chat.modelControls.webFetch')}
- {renderEditModeIcon(webfetchPermissionSummary.mode, 'h-3.5 w-3.5')} + {webfetchPermissionSummary.label} @@ -3052,7 +3097,7 @@ export const ModelControls: React.FC = ({ {hasCustomPrompt && (
{t('chat.modelControls.customPrompt')} - +
)}
@@ -3125,7 +3170,7 @@ export const ModelControls: React.FC = ({ handleVariantSelect(undefined)}>
{t('chat.modelControls.default')} - {isDefault && } + {isDefault && }
{availableVariants.length > 0 && } @@ -3140,7 +3185,7 @@ export const ModelControls: React.FC = ({ >
{label} - {selected && } + {selected && }
); @@ -3213,7 +3258,7 @@ export const ModelControls: React.FC = ({
- + = ({ e.stopPropagation(); }} className="pl-8 h-8 typography-meta" - autoFocus />
@@ -3236,7 +3280,7 @@ export const ModelControls: React.FC = ({ onSelect={() => handleAgentChange(defaultAgentName)} >
- + {t('chat.modelControls.resetToDefault')}