From b62faadd15d86656a6cb2865e8d408551757361d Mon Sep 17 00:00:00 2001 From: Dave Otero <31802309+daveotero@users.noreply.github.com> Date: Mon, 27 Apr 2026 04:28:51 -0600 Subject: [PATCH] Improve and unify the model picker across desktop and mobile (#1037) * feat: improve agent and quick model picker behavior * fix: keep the active model highlighted in the quick picker * feat: streamline mobile model selection Open the full mobile model picker directly and remove the intermediate controls drawer so mobile model changes follow the same core selection flow as desktop. Add inline thinking-mode chips that show each model's remembered or default variant, apply model and variant together on tap, and fall back to a dedicated overflow panel for larger variant sets. Also keep favorites and recents searchable on mobile and fix clearing remembered default variants so the picker stays consistent across sessions. * fix: polish desktop model picker interactions Stabilize desktop model picker behavior by keeping keyboard and hover selection in sync, preventing hover-driven closes, and making the footer hints visually stable. Also make quick-picker thinking mode changes apply consistently when switching plan/build or agent mode inside the picker, clamp left/right variant cycling at the ends, and keep thinking feedback visible even when the selected variant cannot move further. * fix: condense mobile model picker rows Tighten the mobile model picker to use a more compact, consistent row layout across favorites, recents, and provider sections while keeping context length and capability icons easy to scan. Also preserve inline thinking-mode selection, improve metadata spacing, and keep the mobile controls readable without reintroducing the heavier drawer-based flow. * fix: include all primary-like agents in picker cycling Keep desktop Tab cycling and mobile tap cycling aligned with the rest of the selection UI by including agents marked as all or left unset, not just strict primary agents. * fix: preserve remembered agent variants in picker flows * Fix model picker variant restore * Polish favorite model drag handle * Fix Korean model picker locale --------- Co-authored-by: Bohdan Triapitsyn --- packages/ui/src/components/chat/ChatInput.tsx | 64 +- .../ui/src/components/chat/ModelControls.tsx | 1084 ++++++++++++----- .../components/chat/UnifiedControlsDrawer.tsx | 259 ---- .../components/chat/mobileControlsUtils.ts | 18 + packages/ui/src/lib/i18n/messages/en.ts | 8 + packages/ui/src/lib/i18n/messages/es.ts | 8 + packages/ui/src/lib/i18n/messages/ko.ts | 8 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 8 + packages/ui/src/lib/i18n/messages/uk.ts | 8 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 8 + packages/ui/src/stores/useConfigStore.ts | 142 +-- packages/ui/src/stores/useUIStore.ts | 54 + packages/ui/src/sync/selection-store.ts | 19 +- 13 files changed, 984 insertions(+), 704 deletions(-) delete mode 100644 packages/ui/src/components/chat/UnifiedControlsDrawer.tsx diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 8c8b9187..24b724d5 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -36,7 +36,6 @@ import { CommandAutocomplete, type CommandAutocompleteHandle, type CommandInfo } import { SkillAutocomplete, type SkillAutocompleteHandle } from './SkillAutocomplete'; import { cn, formatDirectoryName, isMacOS } from '@/lib/utils'; import { ModelControls } from './ModelControls'; -import { UnifiedControlsDrawer } from './UnifiedControlsDrawer'; import { parseAgentMentions } from '@/lib/messages/agentMentions'; import { StatusRow } from './StatusRow'; import { PendingChangesBar } from './PendingChangesBar'; @@ -50,7 +49,7 @@ import { isTauriShell, isVSCodeRuntime } from '@/lib/desktop'; import { isIMECompositionEvent } from '@/lib/ime'; import { StopIcon } from '@/components/icons/StopIcon'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; -import type { MobileControlsPanel } from './mobileControlsUtils'; +import { getCycledPrimaryAgentName, type MobileControlsPanel } from './mobileControlsUtils'; import { DropdownMenu, DropdownMenuContent, @@ -229,7 +228,6 @@ const getProjectIconColor = (projectColor?: string | null): string | undefined = }; const MemoModelControls = React.memo(ModelControls); -const MemoUnifiedControlsDrawer = React.memo(UnifiedControlsDrawer); const MemoBrowserVoiceButton = React.memo(BrowserVoiceButton); const MemoMobileAgentButton = React.memo(MobileAgentButton); const MemoMobileModelButton = React.memo(MobileModelButton); @@ -727,7 +725,6 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const [showSkillAutocomplete, setShowSkillAutocomplete] = React.useState(false); const [skillQuery, setSkillQuery] = React.useState(''); const [textareaSize, setTextareaSize] = React.useState<{ height: number; maxHeight: number } | null>(null); - const [mobileControlsOpen, setMobileControlsOpen] = React.useState(false); const [mobileControlsPanel, setMobileControlsPanel] = React.useState(null); // Message history navigation state (up/down arrow to recall previous messages) const [historyIndex, setHistoryIndex] = React.useState(-1); // -1 = not browsing, 0+ = index from most recent @@ -790,7 +787,6 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const setAgent = useConfigStore((state) => state.setAgent); const getVisibleAgents = useConfigStore((state) => state.getVisibleAgents); const agents = getVisibleAgents(); - const primaryAgents = React.useMemo(() => agents.filter((agent) => agent.mode === 'primary'), [agents]); const isMobile = useUIStore((state) => state.isMobile); const inputBarOffset = useUIStore((state) => state.inputBarOffset); const persistChatDraft = useUIStore((state) => state.persistChatDraft); @@ -1179,50 +1175,16 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo // Session activity for queue availability and controls const { phase: sessionPhase } = useCurrentSessionActivity(); - const handleOpenMobileControls = React.useCallback(() => { - if (!isMobile) { - return; - } - - if (mobileControlsOpen) { - setMobileControlsOpen(false); - return; - } - - setMobileControlsPanel(null); - - if (document.activeElement instanceof HTMLElement) { - document.activeElement.blur(); - } - - setMobileControlsOpen(true); - }, [isMobile, mobileControlsOpen]); - - const handleCloseMobileControls = React.useCallback(() => { - setMobileControlsOpen(false); - }, []); - const handleOpenMobilePanel = React.useCallback((panel: MobileControlsPanel) => { if (!isMobile) { return; } - setMobileControlsOpen(false); textareaRef.current?.blur(); requestAnimationFrame(() => { setMobileControlsPanel(panel); }); }, [isMobile]); - const handleReturnToUnifiedControls = React.useCallback(() => { - if (!isMobile) { - return; - } - setMobileControlsPanel(null); - requestAnimationFrame(() => { - setMobileControlsOpen(true); - }); - }, [isMobile]); - // Consume pending input text (e.g., from revert action) React.useEffect(() => { if (pendingInputText !== null) { @@ -1970,18 +1932,15 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo }, [abortCurrentOperation, clearAbortPrompt, currentSessionId, startAbortIndicator]); const handleCycleAgent = React.useCallback(() => { - if (primaryAgents.length <= 1) return; + const nextAgentName = getCycledPrimaryAgentName(agents, currentAgentName); + if (!nextAgentName) return; - const currentIndex = primaryAgents.findIndex(agent => agent.name === currentAgentName); - const nextIndex = (currentIndex + 1) % primaryAgents.length; - const nextAgent = primaryAgents[nextIndex]; - - setAgent(nextAgent.name); + setAgent(nextAgentName); if (currentSessionId) { - saveSessionAgentSelection(currentSessionId, nextAgent.name); + saveSessionAgentSelection(currentSessionId, nextAgentName); } - }, [primaryAgents, currentAgentName, currentSessionId, setAgent, saveSessionAgentSelection]); + }, [agents, currentAgentName, currentSessionId, setAgent, saveSessionAgentSelection]); const adjustTextareaHeight = React.useCallback((options?: { allowShrink?: boolean }) => { const textarea = textareaRef.current; @@ -2507,7 +2466,6 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo React.useEffect(() => { if (!isMobile) { - setMobileControlsOpen(false); setMobileControlsPanel(null); } }, [isMobile]); @@ -3745,7 +3703,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo
- + handleOpenMobilePanel('model')} className="min-w-0 flex-shrink" /> = ({ onOpenSettings, scrollTo className="hidden" mobilePanel={mobileControlsPanel} onMobilePanelChange={setMobileControlsPanel} - onMobilePanelSelection={handleReturnToUnifiedControls} - onAgentPanelSelection={() => setMobileControlsPanel(null)} - /> - handleOpenMobilePanel('model')} - onOpenEffort={() => handleOpenMobilePanel('variant')} /> ) : ( diff --git a/packages/ui/src/components/chat/ModelControls.tsx b/packages/ui/src/components/chat/ModelControls.tsx index a11af3e2..d1670fa0 100644 --- a/packages/ui/src/components/chat/ModelControls.tsx +++ b/packages/ui/src/components/chat/ModelControls.tsx @@ -1,5 +1,15 @@ import React from 'react'; import type { ComponentType } from 'react'; +import { + DndContext, + PointerSensor, + closestCenter, + useSensor, + useSensors, + type DragEndEvent, +} from '@dnd-kit/core'; +import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable'; +import { CSS as DndCSS } from '@dnd-kit/utilities'; import { RiAddLine, RiAiAgentLine, @@ -10,6 +20,7 @@ import { RiCheckLine, RiCheckboxCircleLine, RiCloseCircleLine, + RiDraggable, RiFileImageLine, RiFileMusicLine, RiFilePdfLine, @@ -54,7 +65,7 @@ import { useSync } from '@/sync/use-sync'; import { useUIStore } from '@/stores/useUIStore'; import { useModelLists } from '@/hooks/useModelLists'; import { useIsTextTruncated } from '@/hooks/useIsTextTruncated'; -import type { MobileControlsPanel } from './mobileControlsUtils'; +import { formatEffortLabel, getCycledPrimaryAgentName, type MobileControlsPanel } from './mobileControlsUtils'; import { useI18n } from '@/lib/i18n'; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -64,6 +75,45 @@ type ProviderModel = Record & { id?: string; name?: string }; type PermissionAction = 'allow' | 'ask' | 'deny'; type PermissionRule = { permission: string; pattern: string; action: PermissionAction }; +type SortableFavoriteHandleProps = { + attributes: ReturnType['attributes']; + listeners: ReturnType['listeners']; + setActivatorNodeRef: ReturnType['setActivatorNodeRef']; + isDragging: boolean; +}; +type MobileVariantTarget = { providerId: string; modelId: string }; + +const buildModelRefKey = (providerID: string, modelID: string) => `${providerID}:${modelID}`; +const MAX_INLINE_MOBILE_VARIANT_OPTIONS = 6; + +const SortableFavoriteModelRow: React.FC<{ + id: string; + disabled?: boolean; + children: (dragHandleProps: SortableFavoriteHandleProps) => React.ReactNode; +}> = ({ id, disabled = false, children }) => { + const { + attributes, + listeners, + setNodeRef, + setActivatorNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id, disabled }); + + return ( +
+ {children({ attributes, listeners, setActivatorNodeRef, isDragging })} +
+ ); +}; const asPermissionRuleset = (value: unknown): PermissionRule[] | null => { if (!Array.isArray(value)) { @@ -283,16 +333,12 @@ interface ModelControlsProps { className?: string; mobilePanel?: MobileControlsPanel; onMobilePanelChange?: (panel: MobileControlsPanel) => void; - onMobilePanelSelection?: () => void; - onAgentPanelSelection?: () => void; } export const ModelControls: React.FC = ({ className, mobilePanel, onMobilePanelChange, - onMobilePanelSelection, - onAgentPanelSelection, }) => { const { t } = useI18n(); const providers = useConfigStore((state) => state.providers); @@ -354,9 +400,11 @@ export const ModelControls: React.FC = ({ : currentAgentName; const toggleFavoriteModel = useUIStore((state) => state.toggleFavoriteModel); + const reorderFavoriteModel = useUIStore((state) => state.reorderFavoriteModel); const isFavoriteModel = useUIStore((state) => state.isFavoriteModel); const collapsedModelProviders = useUIStore((state) => state.collapsedModelProviders); const toggleModelProviderCollapsed = useUIStore((state) => state.toggleModelProviderCollapsed); + const setModelProvidersCollapsed = useUIStore((state) => state.setModelProvidersCollapsed); const addRecentModel = useUIStore((state) => state.addRecentModel); const addRecentAgent = useUIStore((state) => state.addRecentAgent); const addRecentEffort = useUIStore((state) => state.addRecentEffort); @@ -385,6 +433,8 @@ export const ModelControls: React.FC = ({ 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), []); @@ -409,8 +459,13 @@ export const ModelControls: React.FC = ({ const [desktopModelQuery, setDesktopModelQuery] = React.useState(''); const [modelSelectedIndex, setModelSelectedIndex] = React.useState(0); const modelItemRefs = React.useRef<(HTMLDivElement | null)[]>([]); + const keyboardOwnsModelSelectionRef = React.useRef(false); + const lastModelPointerPositionRef = React.useRef<{ x: number; y: number } | null>(null); const [pendingThinkingVariants, setPendingThinkingVariants] = React.useState>(new Map()); const [adjustedThinkingModels, setAdjustedThinkingModels] = React.useState>(new Set()); + const favoriteRowSensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), + ); React.useEffect(() => { if (activeMobilePanel === 'model') { @@ -424,12 +479,25 @@ export const ModelControls: React.FC = ({ } }, [activeMobilePanel, currentProviderId]); + React.useEffect(() => { + if (activeMobilePanel === null) { + setExpandedMobileModelKey(null); + } + if (activeMobilePanel !== 'variant') { + setMobileVariantTarget(null); + } + }, [activeMobilePanel]); + React.useEffect(() => { if (activeMobilePanel !== 'model') { setMobileModelQuery(''); } }, [activeMobilePanel]); + React.useEffect(() => { + setExpandedMobileModelKey(null); + }, [mobileModelQuery]); + // Handle model selector close behavior (separate from agent selector) const prevModelSelectorOpenRef = React.useRef(isModelSelectorOpen); React.useEffect(() => { @@ -439,6 +507,8 @@ export const ModelControls: React.FC = ({ if (!isModelSelectorOpen) { setDesktopModelQuery(''); setModelSelectedIndex(0); + keyboardOwnsModelSelectionRef.current = false; + lastModelPointerPositionRef.current = null; setPendingThinkingVariants(new Map()); setAdjustedThinkingModels(new Set()); @@ -466,11 +536,6 @@ export const ModelControls: React.FC = ({ } }, [isAgentSelectorOpen, isCompact]); - // Reset selected index when search query changes - React.useEffect(() => { - setModelSelectedIndex(0); - }, [desktopModelQuery]); - const selectableDesktopAgents = React.useMemo(() => { return agents.filter((agent) => agent.mode !== 'subagent'); }, [agents]); @@ -545,6 +610,112 @@ export const ModelControls: React.FC = ({ .filter((provider) => provider.models.length > 0); }, [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 getDesktopModelPickerSelectedIndex = React.useCallback((query: string) => { + const normalizedQuery = query.trim(); + const forceExpandProviders = normalizedQuery.length > 0; + const matchesQuery = (modelName: string, providerName: string) => { + if (!normalizedQuery) return true; + return matchesModelSearch(modelName, normalizedQuery) || matchesModelSearch(providerName, normalizedQuery); + }; + + let flatIndex = 0; + + for (const { model, providerID, modelID } of favoriteModelsList) { + const provider = providers.find((entry) => entry.id === providerID); + const providerName = provider?.name || providerID; + const modelName = getModelDisplayName(model); + if (!matchesQuery(modelName, providerName)) { + continue; + } + if (providerID === currentProviderId && modelID === currentModelId) { + return flatIndex; + } + flatIndex += 1; + } + + for (const { model, providerID, modelID } of recentModelsList) { + const provider = providers.find((entry) => entry.id === providerID); + const providerName = provider?.name || providerID; + const modelName = getModelDisplayName(model); + if (!matchesQuery(modelName, providerName)) { + continue; + } + if (providerID === currentProviderId && modelID === currentModelId) { + return flatIndex; + } + flatIndex += 1; + } + + for (const provider of visibleProviders) { + const providerId = typeof provider.id === 'string' ? provider.id : ''; + const providerName = provider.name || providerId; + const providerModels = Array.isArray(provider.models) ? (provider.models as ProviderModel[]) : []; + const filteredModels = providerModels.filter((model) => matchesQuery(getModelDisplayName(model), providerName)); + const isExpanded = forceExpandProviders || !collapsedProviderSet.has(providerId); + if (!isExpanded) { + continue; + } + for (const model of filteredModels) { + const modelId = typeof model.id === 'string' ? model.id : ''; + if (providerId === currentProviderId && modelId === currentModelId) { + return flatIndex; + } + flatIndex += 1; + } + } + + return 0; + }, [ + collapsedProviderSet, + currentModelId, + currentProviderId, + favoriteModelsList, + matchesModelSearch, + providers, + recentModelsList, + visibleProviders, + ]); + + React.useEffect(() => { + if (!isModelSelectorOpen) { + return; + } + setModelSelectedIndex(getDesktopModelPickerSelectedIndex(desktopModelQuery)); + }, [desktopModelQuery, getDesktopModelPickerSelectedIndex, isModelSelectorOpen]); + const currentMetadata = currentProviderId && currentModelId ? getModelMetadata(currentProviderId, currentModelId) : undefined; const localizeMetaLabel = React.useCallback((label: string) => { @@ -670,6 +841,96 @@ export const ModelControls: React.FC = ({ [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; @@ -901,18 +1162,6 @@ export const ModelControls: React.FC = ({ return; } } - - const agent = agents.find(a => a.name === currentAgentName); - if (agent?.model?.providerID && agent?.model?.modelID) { - const result = tryApplyModelSelection( - agent.model.providerID, - agent.model.modelID, - currentAgentName, - ); - if (result === 'provider-missing') { - return; - } - } } } } catch (error) { @@ -921,7 +1170,7 @@ export const ModelControls: React.FC = ({ }; handleAgentSwitch(); - }, [currentAgentName, currentSessionId, getAgentModelForSession, tryApplyModelSelection, agents, contextHydrated]); + }, [currentAgentName, currentSessionId, getAgentModelForSession, tryApplyModelSelection, contextHydrated]); React.useEffect(() => { if (!contextHydrated || !currentAgentName) { @@ -990,58 +1239,56 @@ export const ModelControls: React.FC = ({ }, [currentProviderId, currentModelId]); const handleVariantSelect = React.useCallback((variant: string | undefined) => { - manualVariantSelectionRef.current = true; - setCurrentVariant(variant); - if (currentProviderId && currentModelId) { - addRecentEffort(currentProviderId, currentModelId, variant); + commitVariantSelectionForModel(currentProviderId, currentModelId, variant); } + }, [commitVariantSelectionForModel, currentModelId, currentProviderId]); - if (currentSessionId && currentAgentName && currentProviderId && currentModelId) { - saveAgentModelVariantForSession( - currentSessionId, - currentAgentName, - currentProviderId, - currentModelId, - variant, - ); - } - }, [ - addRecentEffort, - currentAgentName, - currentModelId, - currentProviderId, - currentSessionId, - saveAgentModelVariantForSession, - setCurrentVariant, - ]); - - const handleAgentChange = (agentName: string) => { + const handleAgentChange = React.useCallback((agentName: string, options?: { closeModelSelector?: boolean }) => { try { setAgent(agentName); addRecentAgent(agentName); - setAgentMenuOpen(false); + if (options?.closeModelSelector ?? true) { + setAgentMenuOpen(false); + } if (currentSessionId) { saveSessionAgentSelection(currentSessionId, agentName); } if (isCompact) { closeMobilePanel(); - const callback = onAgentPanelSelection || onMobilePanelSelection; - if (callback) { - requestAnimationFrame(() => { - callback(); - }); - } } } catch (error) { console.error('[ModelControls] Handle agent change error:', error); } - }; + }, [ + addRecentAgent, + closeMobilePanel, + currentSessionId, + isCompact, + saveSessionAgentSelection, + setAgent, + setAgentMenuOpen, + ]); - const handleProviderAndModelChange = (providerId: string, modelId: string) => { + const handleCycleAgentFromModelPicker = React.useCallback((direction: 1 | -1) => { + const nextAgentName = getCycledPrimaryAgentName(agents, currentAgentName, direction); + if (!nextAgentName) { + return; + } + handleAgentChange(nextAgentName, { closeModelSelector: false }); + }, [agents, currentAgentName, handleAgentChange]); + + const handleProviderAndModelChange = ( + providerId: string, + modelId: string, + options?: { applyVariant?: boolean; variant?: string | undefined; agentName?: string | null }, + ) => { try { - const result = tryApplyModelSelection(providerId, modelId, currentAgentName || undefined); + 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); @@ -1050,24 +1297,19 @@ export const ModelControls: React.FC = ({ } return; } - // Add to recent models on successful selection - addRecentModel(providerId, modelId); + if (!options?.applyVariant) { + // Add to recent models on successful selection. + addRecentModel(providerId, modelId); + } setAgentMenuOpen(false); if (isCompact) { closeMobilePanel(); - if (onMobilePanelSelection) { - requestAnimationFrame(() => { - onMobilePanelSelection(); - }); - } - } - if (!isCompact || !onMobilePanelSelection) { - // Restore focus to chat input after model selection - requestAnimationFrame(() => { - const textarea = document.querySelector('textarea[data-chat-input="true"]'); - textarea?.focus(); - }); } + // Restore focus to chat input after model selection. + requestAnimationFrame(() => { + const textarea = document.querySelector('textarea[data-chat-input="true"]'); + textarea?.focus(); + }); } catch (error) { console.error('[ModelControls] Handle model change error:', error); } @@ -1378,43 +1620,28 @@ export const ModelControls: React.FC = ({ ); }; - 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 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 = visibleProviders .map((provider) => { const providerModels = Array.isArray(provider.models) ? provider.models : []; @@ -1428,10 +1655,211 @@ export const ModelControls: React.FC = ({ const id = typeof model.id === 'string' ? model.id : ''; return matchesModelSearch(name, normalizedQuery) || matchesModelSearch(id, normalizedQuery); }); - return { provider, providerModels: matchingModels, matchesProvider }; + return { + provider, + providerModels: matchesProvider && normalizedQuery.length > 0 ? providerModels : matchingModels, + matchesProvider, + }; }) .filter(({ matchesProvider, providerModels }) => matchesProvider || providerModels.length > 0); + const focusMobileComposer = () => { + requestAnimationFrame(() => { + const textarea = document.querySelector('textarea[data-chat-input="true"]'); + textarea?.focus(); + }); + }; + + 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(); + focusMobileComposer(); + }; + + 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 = 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 ( = ({
- {filteredProviders.length === 0 && ( + {!hasResults && (
- No providers or models match your search. + {t('chat.modelControls.noProvidersOrModelsFound')}
)} {/* Favorites Section for Mobile */} - {!mobileModelQuery && favoriteModelsList.length > 0 && ( + {filteredFavorites.length > 0 && (
{t('chat.modelControls.favorites')}
- {favoriteModelsList.map(({ model, providerID, modelID }) => { - const isSelected = providerID === currentProviderId && modelID === currentModelId; - const metadata = getModelMetadata(providerID, modelID); - - return ( - - ); - })} + {filteredFavorites.map(({ model, providerID, modelID }) => renderMobileModelRow({ + model, + providerId: providerID, + modelId: modelID, + showProviderLogo: true, + }))}
)} {/* Recent Section for Mobile */} - {!mobileModelQuery && recentModelsList.length > 0 && ( + {filteredRecents.length > 0 && (
{t('chat.modelControls.recent')}
- {recentModelsList.map(({ model, providerID, modelID }) => { - const isSelected = providerID === currentProviderId && modelID === currentModelId; - const metadata = getModelMetadata(providerID, modelID); - - return ( - - ); - })} + {filteredRecents.map(({ model, providerID, modelID }) => renderMobileModelRow({ + model, + providerId: providerID, + modelId: modelID, + showProviderLogo: true, + }))}
)} {filteredProviders.map(({ provider, providerModels }) => { - if (providerModels.length === 0 && !normalizedQuery.length) { + if (providerModels.length === 0) { return null; } @@ -1571,7 +1943,12 @@ export const ModelControls: React.FC = ({
- -
- ); - })} + {providerModels.map((model: ProviderModel) => renderMobileModelRow({ + model, + providerId: provider.id as string, + modelId: model.id as string, + showProviderLogo: false, + }))} )} @@ -1691,19 +1990,29 @@ export const ModelControls: React.FC = ({ }; const renderMobileVariantPanel = () => { - if (!isCompact || !hasVariants) return null; + if (!isCompact) return null; - const isDefault = !currentVariant; + 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) => { - handleVariantSelect(variant); - closeMobilePanel(); - if (onMobilePanelSelection) { - requestAnimationFrame(() => { - onMobilePanelSelection(); - }); + const result = applyModelSelectionWithVariant(targetProviderId, targetModelId, variant); + if (result !== 'applied') { return; } + + closeMobilePanel(); requestAnimationFrame(() => { const textarea = document.querySelector('textarea[data-chat-input="true"]'); textarea?.focus(); @@ -1715,6 +2024,20 @@ export const ModelControls: React.FC = ({ open={activeMobilePanel === 'variant'} onClose={closeMobilePanel} title={t('chat.modelControls.thinking')} + renderHeader={mobileVariantTarget ? ((closeButton) => ( +
+ +

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

+ {closeButton} +
+ )) : undefined} >
- {availableVariants.map((variant) => { - const selected = currentVariant === variant; - const label = variant.charAt(0).toUpperCase() + variant.slice(1); + {targetVariants.map((variant) => { + const selected = selectedVariant === variant; + const label = formatEffortLabel(variant); return ( + ) : null}
{showProviderLogo && ( @@ -2077,6 +2439,7 @@ export const ModelControls: React.FC = ({ const modelName = getModelDisplayName(model); return filterByQuery(modelName, providerName, desktopModelQuery); }); + const favoriteSortingEnabled = normalizedDesktopQuery.length === 0 && filteredFavorites.length > 1; const filteredRecents = recentModelsList.filter(({ model, providerID }) => { const provider = providers.find(p => p.id === providerID); @@ -2113,6 +2476,13 @@ 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 favoriteModelLookup = new Map( + filteredFavorites.map(({ providerID, modelID }) => [buildModelRefKey(providerID, modelID), { providerID, modelID }]) + ); const flatModelList: FlatModelItem[] = []; filteredFavorites.forEach(({ model, providerID, modelID }) => { @@ -2127,11 +2497,33 @@ export const ModelControls: React.FC = ({ }); }); - return { filteredFavorites, filteredRecents, filteredProviders, providerSections, flatModelList, hasResults, forceExpandProviders }; + return { + filteredFavorites, + filteredRecents, + filteredProviders, + providerSections, + flatModelList, + hasResults, + forceExpandProviders, + favoriteSortingEnabled, + filteredProviderIds, + favoriteModelLookup, + }; }, [desktopModelQuery, favoriteModelsList, recentModelsList, visibleProviders, providers, collapsedProviderSet, matchesModelSearch]); const renderModelSelector = () => { - const { filteredFavorites, filteredRecents, filteredProviders, providerSections, flatModelList, hasResults, forceExpandProviders } = modelSelectorData; + const { + filteredFavorites, + filteredRecents, + filteredProviders, + providerSections, + flatModelList, + hasResults, + forceExpandProviders, + favoriteSortingEnabled, + filteredProviderIds, + favoriteModelLookup, + } = modelSelectorData; const totalItems = flatModelList.length; @@ -2145,8 +2537,12 @@ export const ModelControls: React.FC = ({ // Handle keyboard navigation const handleModelKeyDown = (e: React.KeyboardEvent) => { e.stopPropagation(); + keyboardOwnsModelSelectionRef.current = true; - if (e.key === 'ArrowDown') { + if (e.key === 'Tab') { + e.preventDefault(); + handleCycleAgentFromModelPicker(e.shiftKey ? -1 : 1); + } else if (e.key === 'ArrowDown') { e.preventDefault(); setModelSelectedIndex((prev) => (prev + 1) % Math.max(1, totalItems)); // Scroll into view @@ -2174,7 +2570,7 @@ export const ModelControls: React.FC = ({ const variantKeys = Object.keys(modelVariants); if (variantKeys.length === 0) return; - const mapKey = `${providerID}:${modelID}`; + const mapKey = buildModelRefKey(providerID, modelID); const currentPending = pendingThinkingVariants.get(mapKey); const activeModelVariant = currentPending ?? (currentProviderId === providerID && currentModelId === modelID ? currentVariant : undefined); @@ -2182,7 +2578,10 @@ export const ModelControls: React.FC = ({ 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 nextVariantIndex = Math.min( + variantsWithDefault.length - 1, + Math.max(0, safeCurrentIndex + direction), + ); const nextVariant = variantsWithDefault[nextVariantIndex]; setPendingThinkingVariants((prev) => { @@ -2200,17 +2599,14 @@ export const ModelControls: React.FC = ({ const selectedItem = flatModelList[modelSelectedIndex]; if (selectedItem) { const { providerID, modelID } = selectedItem; - const mapKey = `${providerID}:${modelID}`; + const mapKey = buildModelRefKey(providerID, modelID); const pendingVariant = pendingThinkingVariants.get(mapKey); const wasAdjusted = adjustedThinkingModels.has(mapKey); + const effectiveAgentName = resolveLiveAgentName(); - handleProviderAndModelChange(providerID, modelID); - - if (wasAdjusted) { - setTimeout(() => { - handleVariantSelect(pendingVariant); - }, 0); - } + handleProviderAndModelChange(providerID, modelID, wasAdjusted + ? { applyVariant: true, variant: pendingVariant, agentName: effectiveAgentName } + : { agentName: effectiveAgentName }); } } else if (e.key === 'Escape') { e.preventDefault(); @@ -2218,13 +2614,45 @@ export const ModelControls: React.FC = ({ } }; + const handleFavoriteDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + if (!over || active.id === over.id) { + return; + } + + const activeFavorite = favoriteModelLookup.get(String(active.id)); + const overFavorite = favoriteModelLookup.get(String(over.id)); + if (!activeFavorite || !overFavorite) { + return; + } + + reorderFavoriteModel( + activeFavorite.providerID, + activeFavorite.modelID, + overFavorite.providerID, + overFavorite.modelID, + ); + }; + + const handleProviderSectionToggle = (expand: boolean) => { + if (filteredProviderIds.length === 0) { + return; + } + setModelProvidersCollapsed(filteredProviderIds, !expand); + setModelSelectedIndex(0); + }; + + const handleModelMenuOpenChange = (nextOpen: boolean) => { + setAgentMenuOpen(nextOpen); + }; + // Build index mapping for rendering let currentFlatIndex = 0; return ( {!isCompact ? ( - +
= ({ {t('chat.modelControls.favorites')} - {filteredFavorites.map(({ model, providerID, modelID }) => { - const idx = currentFlatIndex++; - return renderModelRow(model, providerID, modelID, 'fav', idx, modelSelectedIndex === idx); - })} + {favoriteSortingEnabled ? ( + + buildModelRefKey(providerID, modelID))} + strategy={verticalListSortingStrategy} + > + {filteredFavorites.map(({ model, providerID, modelID }) => { + const idx = currentFlatIndex++; + return ( + + {(dragHandleProps) => renderModelRow( + model, + providerID, + modelID, + 'fav', + idx, + modelSelectedIndex === idx, + dragHandleProps, + )} + + ); + })} + + + ) : ( + filteredFavorites.map(({ model, providerID, modelID }) => { + const idx = currentFlatIndex++; + return renderModelRow(model, providerID, modelID, 'fav', idx, modelSelectedIndex === idx); + }) + )}
)} @@ -2356,10 +2817,16 @@ export const ModelControls: React.FC = ({ role="button" tabIndex={forceExpandProviders ? -1 : 0} aria-disabled={forceExpandProviders} - onClick={() => { + onClick={(event) => { if (forceExpandProviders) { return; } + + if (event.metaKey || event.ctrlKey) { + handleProviderSectionToggle(!isExpanded); + return; + } + toggleModelProviderCollapsed(String(provider.id)); setModelSelectedIndex(0); }} @@ -2411,24 +2878,13 @@ export const ModelControls: React.FC = ({ {/* Keyboard hints footer */}
- {(() => { - const thinkingMarker = '__MODEL_THINKING_HINT__'; - const thinkingHint = ` • ${t('chat.modelControls.keyboardHintThinking')}`; - const hintParts = t('chat.modelControls.keyboardHint', { thinking: thinkingMarker }).split(thinkingMarker); - - return ( - <> - {hintParts[0]} - - {thinkingHint} - - {hintParts[1]} - - ); - })()} +
+ {t('chat.modelControls.keyboardHintNavigate')} + {t('chat.modelControls.keyboardHintSwitchAgent')} + + {t('chat.modelControls.keyboardHintThinking')} + +
diff --git a/packages/ui/src/components/chat/UnifiedControlsDrawer.tsx b/packages/ui/src/components/chat/UnifiedControlsDrawer.tsx deleted file mode 100644 index ba4b8bfa..00000000 --- a/packages/ui/src/components/chat/UnifiedControlsDrawer.tsx +++ /dev/null @@ -1,259 +0,0 @@ -import React from 'react'; -import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; -import { ProviderLogo } from '@/components/ui/ProviderLogo'; -import { cn } from '@/lib/utils'; -import { useConfigStore } from '@/stores/useConfigStore'; -import { useSessionUIStore } from '@/sync/session-ui-store'; -import { useSelectionStore } from '@/sync/selection-store'; -import { useContextStore } from '@/stores/contextStore'; -import { useUIStore } from '@/stores/useUIStore'; -import { useModelLists } from '@/hooks/useModelLists'; -import { - formatEffortLabel, - getQuickEffortOptions, - parseEffortVariant, -} from './mobileControlsUtils'; -import { useI18n } from '@/lib/i18n'; - -const COMPACT_NUMBER_FORMATTER = new Intl.NumberFormat('en-US', { - notation: 'compact', - maximumFractionDigits: 1, -}); - -const formatTokens = (value?: number | null) => { - if (typeof value !== 'number' || Number.isNaN(value)) { - return null; - } - if (value === 0) { - return '0'; - } - const formatted = COMPACT_NUMBER_FORMATTER.format(value); - return formatted.endsWith('.0') ? formatted.slice(0, -2) : formatted; -}; - -interface UnifiedControlsDrawerProps { - open: boolean; - onClose: () => void; - onOpenModel: () => void; - onOpenEffort: () => void; -} - -export const UnifiedControlsDrawer: React.FC = ({ - open, - onClose, - onOpenModel, - onOpenEffort, -}) => { - const { t } = useI18n(); - 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 setProvider = useConfigStore((state) => state.setProvider); - const setModel = useConfigStore((state) => state.setModel); - const setCurrentVariant = useConfigStore((state) => state.setCurrentVariant); - const getCurrentModelVariants = useConfigStore((state) => state.getCurrentModelVariants); - const getModelMetadata = useConfigStore((state) => state.getModelMetadata); - const addRecentModel = useUIStore((state) => state.addRecentModel); - const addRecentEffort = useUIStore((state) => state.addRecentEffort); - const recentEfforts = useUIStore((state) => state.recentEfforts); - const { recentModelsList } = useModelLists(); - const currentSessionId = useSessionUIStore((s) => s.currentSessionId); - const saveAgentModelForSession = useSelectionStore((state) => state.saveAgentModelForSession); - const saveAgentModelVariantForSession = useSelectionStore((state) => state.saveAgentModelVariantForSession); - const sessionAgentName = useContextStore((state) => - currentSessionId ? state.getSessionAgentSelection(currentSessionId) : null - ); - - const uiAgentName = currentSessionId ? (sessionAgentName || null) : null; - - const recentModelsBase = recentModelsList.slice(0, 4); - const hasCurrentInRecents = recentModelsBase.some( - (entry) => entry.providerID === currentProviderId && entry.modelID === currentModelId - ); - // If current model not in recents, prepend it so it's always visible - const recentModels = React.useMemo(() => { - if (hasCurrentInRecents || !currentProviderId || !currentModelId) { - return recentModelsBase; - } - const currentProvider = providers.find((p) => p.id === currentProviderId); - const currentModel = currentProvider?.models?.find((m) => m.id === currentModelId); - if (!currentModel) { - return recentModelsBase; - } - return [ - { providerID: currentProviderId, modelID: currentModelId, provider: currentProvider, model: currentModel }, - ...recentModelsBase.slice(0, 3), - ]; - }, [recentModelsBase, hasCurrentInRecents, currentProviderId, currentModelId, providers]); - - const variants = getCurrentModelVariants(); - const hasEffort = variants.length > 0; - const effortKey = currentProviderId && currentModelId ? `${currentProviderId}/${currentModelId}` : null; - const recentEffortsForModel = effortKey ? (recentEfforts[effortKey] ?? []) : []; - const recentEffortOptions = recentEffortsForModel - .map((variant) => parseEffortVariant(variant)) - .filter((variant) => !variant || variants.includes(variant)); - const fallbackEfforts = getQuickEffortOptions(variants); - const baseEfforts = fallbackEfforts.length > 0 ? fallbackEfforts : recentEffortOptions; - const quickEfforts = React.useMemo(() => { - const base = baseEfforts.slice(0, 4); - const orderedRecents = recentEffortOptions.slice().reverse(); - for (const recent of orderedRecents) { - if (base.some((entry) => entry === recent)) { - continue; - } - base.unshift(recent); - base.splice(4); - } - if (!base.some((entry) => entry === currentVariant)) { - if (base.length > 0) { - base[0] = currentVariant; - } else { - base.push(currentVariant); - } - } - if (!base.some((entry) => entry === undefined)) { - base.push(undefined); - base.splice(4); - } - return base; - }, [baseEfforts, currentVariant, recentEffortOptions]); - const effortHasMore = variants.length + 1 > quickEfforts.length; - - const handleModelSelect = (providerId: string, modelId: string) => { - const provider = providers.find((entry) => entry.id === providerId); - if (!provider) { - return; - } - const providerModels = Array.isArray(provider.models) ? provider.models : []; - const modelExists = providerModels.some((model) => model.id === modelId); - if (!modelExists) { - return; - } - - const isRecentAlready = recentModelsList.some( - (entry) => entry.providerID === providerId && entry.modelID === modelId - ); - - setProvider(providerId); - setModel(modelId); - if (!isRecentAlready) { - addRecentModel(providerId, modelId); - } - - if (currentSessionId && uiAgentName) { - saveAgentModelForSession(currentSessionId, uiAgentName, providerId, modelId); - } - }; - - const handleEffortSelect = (variant: string | undefined) => { - setCurrentVariant(variant); - if (currentProviderId && currentModelId) { - addRecentEffort(currentProviderId, currentModelId, variant); - } - if (currentSessionId && uiAgentName && currentProviderId && currentModelId) { - saveAgentModelVariantForSession(currentSessionId, uiAgentName, currentProviderId, currentModelId, variant); - } - }; - - return ( - -
-
-
- {t('chat.unifiedControls.model.title')} -
-
- {recentModels.length === 0 && !hasCurrentInRecents && ( -
- {t('chat.unifiedControls.model.noRecent')} -
- )} - {recentModels.map(({ providerID, modelID, model }) => { - const isSelected = providerID === currentProviderId && modelID === currentModelId; - const modelName = typeof model?.name === 'string' && model.name.trim().length > 0 - ? model.name - : modelID; - const metadata = getModelMetadata(providerID, modelID); - const ctxTokens = formatTokens(metadata?.limit?.context); - const outTokens = formatTokens(metadata?.limit?.output); - return ( - - ); - })} - -
-
- - {hasEffort && ( -
-
- {t('chat.unifiedControls.effort.title')} -
-
- {quickEfforts.map((variant) => { - const isSelected = variant === currentVariant || (!variant && !currentVariant); - return ( - - ); - })} - {effortHasMore && ( - - )} -
-
- )} -
-
- ); -}; - -export default UnifiedControlsDrawer; diff --git a/packages/ui/src/components/chat/mobileControlsUtils.ts b/packages/ui/src/components/chat/mobileControlsUtils.ts index f262d4eb..c126073f 100644 --- a/packages/ui/src/components/chat/mobileControlsUtils.ts +++ b/packages/ui/src/components/chat/mobileControlsUtils.ts @@ -4,6 +4,24 @@ export type MobileControlsPanel = 'model' | 'agent' | 'variant' | null; export const isPrimaryMode = (mode?: string) => mode === 'primary' || mode === 'all' || mode === undefined || mode === null; +export const getCyclablePrimaryAgents = (agents: Agent[]) => agents.filter((agent) => isPrimaryMode(agent.mode)); + +export const getCycledPrimaryAgentName = ( + agents: Agent[], + currentAgentName: string | undefined, + direction: 1 | -1 = 1, +) => { + const primaryAgents = getCyclablePrimaryAgents(agents); + if (primaryAgents.length <= 1) { + return null; + } + + const currentIndex = primaryAgents.findIndex((agent) => agent.name === currentAgentName); + const safeCurrentIndex = currentIndex >= 0 ? currentIndex : 0; + const nextIndex = (safeCurrentIndex + direction + primaryAgents.length) % primaryAgents.length; + return primaryAgents[nextIndex]?.name ?? null; +}; + export const capitalizeLabel = (value: string) => value.charAt(0).toUpperCase() + value.slice(1); export const getAgentDisplayName = (agents: Agent[], agentName?: string) => { diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index a3711595..0d1941d5 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -1484,7 +1484,15 @@ export const dict = { 'chat.modelControls.collapseProvider': 'Collapse provider', 'chat.modelControls.expandProvider': 'Expand provider', 'chat.modelControls.keyboardHint': '↑↓ navigate{thinking} • Enter select • Esc close', + 'chat.modelControls.keyboardHintNavigate': '↑↓ navigate', + 'chat.modelControls.keyboardHintSwitchAgent': 'Tab switch agent', 'chat.modelControls.keyboardHintThinking': '←→ thinking', + 'chat.modelControls.showThinkingModes': 'Show thinking modes', + 'chat.modelControls.hideThinkingModes': 'Hide thinking modes', + 'chat.modelControls.moreThinkingModes': 'More thinking modes', + 'chat.modelControls.noProvidersOrModelsFound': 'No providers or models match your search.', + 'chat.modelControls.reorderFavoriteAria': 'Reorder favorite', + 'chat.modelControls.reorderFavoriteTitle': 'Drag to reorder favorite', 'chat.modelControls.permissionLabel.custom': 'Custom', 'chat.modelControls.permissionLabel.allow': 'Allow', 'chat.modelControls.permissionLabel.deny': 'Deny', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 4685d6fa..48768dae 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -1485,7 +1485,15 @@ export const dict: Record = { "chat.modelControls.collapseProvider": "Colapsar proveedor", "chat.modelControls.expandProvider": "Expandir proveedor", "chat.modelControls.keyboardHint": "↑↓ navegar{thinking} • Enter seleccionar • Esc cerrar", + "chat.modelControls.keyboardHintNavigate": "↑↓ navegar", + "chat.modelControls.keyboardHintSwitchAgent": "Tab cambiar agente", "chat.modelControls.keyboardHintThinking": "←→ cambiar razonamiento", + "chat.modelControls.showThinkingModes": "Mostrar modos de razonamiento", + "chat.modelControls.hideThinkingModes": "Ocultar modos de razonamiento", + "chat.modelControls.moreThinkingModes": "Más modos de razonamiento", + "chat.modelControls.noProvidersOrModelsFound": "Ningún proveedor o modelo coincide con tu búsqueda.", + "chat.modelControls.reorderFavoriteAria": "Reordenar favorito", + "chat.modelControls.reorderFavoriteTitle": "Arrastrar para reordenar favorito", "chat.modelControls.permissionLabel.custom": "Personalizado", "chat.modelControls.permissionLabel.allow": "Permitir", "chat.modelControls.permissionLabel.deny": "Denegar", diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 959e3be0..fe4015c9 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -1485,7 +1485,15 @@ export const dict: Record = { 'chat.modelControls.collapseProvider': '접기 프로바이더', 'chat.modelControls.expandProvider': '펼치기 프로바이더', 'chat.modelControls.keyboardHint': '↑↓ 이동 {thinking} • Enter 선택 • Esc 닫기', + 'chat.modelControls.keyboardHintNavigate': '↑↓ 이동', + 'chat.modelControls.keyboardHintSwitchAgent': 'Tab 에이전트 전환', 'chat.modelControls.keyboardHintThinking': '←→ thinking', + 'chat.modelControls.showThinkingModes': '추론 모드 표시', + 'chat.modelControls.hideThinkingModes': '추론 모드 숨기기', + 'chat.modelControls.moreThinkingModes': '추론 모드 더 보기', + 'chat.modelControls.noProvidersOrModelsFound': '검색과 일치하는 프로바이더 또는 모델이 없습니다.', + 'chat.modelControls.reorderFavoriteAria': '즐겨찾기 순서 변경', + 'chat.modelControls.reorderFavoriteTitle': '드래그하여 즐겨찾기 순서 변경', 'chat.modelControls.permissionLabel.custom': 'Custom', 'chat.modelControls.permissionLabel.allow': '허용', 'chat.modelControls.permissionLabel.deny': '거부', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 80d62faf..ed156672 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -1485,7 +1485,15 @@ export const dict: Record = { "chat.modelControls.collapseProvider": "Recolher provedor", "chat.modelControls.expandProvider": "Expandir provedor", "chat.modelControls.keyboardHint": "↑↓ navegar{thinking} • Enter selecionar • Esc fechar", + "chat.modelControls.keyboardHintNavigate": "↑↓ navegar", + "chat.modelControls.keyboardHintSwitchAgent": "Tab alternar agente", "chat.modelControls.keyboardHintThinking": "←→ mudar raciocínio", + "chat.modelControls.showThinkingModes": "Mostrar modos de raciocínio", + "chat.modelControls.hideThinkingModes": "Ocultar modos de raciocínio", + "chat.modelControls.moreThinkingModes": "Mais modos de raciocínio", + "chat.modelControls.noProvidersOrModelsFound": "Nenhum provedor ou modelo corresponde à sua pesquisa.", + "chat.modelControls.reorderFavoriteAria": "Reordenar favorito", + "chat.modelControls.reorderFavoriteTitle": "Arraste para reordenar favorito", "chat.modelControls.permissionLabel.custom": "Personalizado", "chat.modelControls.permissionLabel.allow": "Permitir", "chat.modelControls.permissionLabel.deny": "Negar", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index a6767f65..fd8ebfbe 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -1485,7 +1485,15 @@ export const dict: Record = { "chat.modelControls.collapseProvider": "Згорнути провайдера", "chat.modelControls.expandProvider": "Розгорнути провайдера", "chat.modelControls.keyboardHint": "↑↓ навігація{thinking} • Enter вибрати • Esc закрити", + "chat.modelControls.keyboardHintNavigate": "↑↓ навігація", + "chat.modelControls.keyboardHintSwitchAgent": "Tab змінити агента", "chat.modelControls.keyboardHintThinking": "←→ мислення", + "chat.modelControls.showThinkingModes": "Показати режими мислення", + "chat.modelControls.hideThinkingModes": "Приховати режими мислення", + "chat.modelControls.moreThinkingModes": "Більше режимів мислення", + "chat.modelControls.noProvidersOrModelsFound": "Жоден провайдер або модель не відповідає пошуку.", + "chat.modelControls.reorderFavoriteAria": "Змінити порядок вибраного", + "chat.modelControls.reorderFavoriteTitle": "Перетягніть, щоб змінити порядок вибраного", "chat.modelControls.permissionLabel.custom": "Custom", "chat.modelControls.permissionLabel.allow": "Дозволити", "chat.modelControls.permissionLabel.deny": "Заборонити", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 5a714866..041a4a15 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -1485,7 +1485,15 @@ export const dict: Record = { 'chat.modelControls.collapseProvider': '折叠提供商', 'chat.modelControls.expandProvider': '展开提供商', 'chat.modelControls.keyboardHint': '↑↓ 导航{thinking} • Enter 选择 • Esc 关闭', + 'chat.modelControls.keyboardHintNavigate': '↑↓ 导航', + 'chat.modelControls.keyboardHintSwitchAgent': 'Tab 切换智能体', 'chat.modelControls.keyboardHintThinking': '←→ 思考', + 'chat.modelControls.showThinkingModes': '显示思考模式', + 'chat.modelControls.hideThinkingModes': '隐藏思考模式', + 'chat.modelControls.moreThinkingModes': '更多思考模式', + 'chat.modelControls.noProvidersOrModelsFound': '没有提供商或模型匹配你的搜索。', + 'chat.modelControls.reorderFavoriteAria': '重新排序收藏', + 'chat.modelControls.reorderFavoriteTitle': '拖动以重新排序收藏', 'chat.modelControls.permissionLabel.custom': '自定义', 'chat.modelControls.permissionLabel.allow': '允许', 'chat.modelControls.permissionLabel.deny': '拒绝', diff --git a/packages/ui/src/stores/useConfigStore.ts b/packages/ui/src/stores/useConfigStore.ts index 4cbd5cbb..b98f7bd7 100644 --- a/packages/ui/src/stores/useConfigStore.ts +++ b/packages/ui/src/stores/useConfigStore.ts @@ -1551,7 +1551,14 @@ export const useConfigStore = create()( }, setAgent: (agentName: string | undefined) => { - const { agents, providers, settingsDefaultModel, settingsDefaultVariant } = get(); + const { + agents, + providers, + settingsDefaultModel, + settingsDefaultVariant, + currentProviderId, + currentModelId, + } = get(); set((state) => { const directoryKey = state.activeDirectoryKey; @@ -1599,60 +1606,82 @@ export const useConfigStore = create()( if (agentName) { const { currentSessionId } = useSessionUIStore.getState(); + const applyResolvedModelSelection = (providerId: string, modelId: string, variant?: string) => { + set((state) => { + const directoryKey = state.activeDirectoryKey; + const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? { + providers: state.providers, + agents: state.agents, + currentProviderId: state.currentProviderId, + currentModelId: state.currentModelId, + currentVariant: state.currentVariant, + currentAgentName: state.currentAgentName, + selectedProviderId: state.selectedProviderId, + agentModelSelections: state.agentModelSelections, + defaultProviders: state.defaultProviders, + }; + + const nextSnapshot: DirectoryScopedConfig = { + ...baseSnapshot, + currentProviderId: providerId, + currentModelId: modelId, + currentVariant: variant, + selectedProviderId: providerId, + }; + + return { + currentProviderId: providerId, + currentModelId: modelId, + currentVariant: variant, + selectedProviderId: providerId, + directoryScoped: { + ...state.directoryScoped, + [directoryKey]: nextSnapshot, + }, + }; + }); + }; + if (currentSessionId) { const existingAgentModel = useSelectionStore.getState().getAgentModelForSession(currentSessionId, agentName); - if (existingAgentModel) { + if (existingAgentModel && hasProviderModel(providers, existingAgentModel.providerId, existingAgentModel.modelId)) { + const savedVariant = useSelectionStore.getState().getAgentModelVariantForSession( + currentSessionId, + agentName, + existingAgentModel.providerId, + existingAgentModel.modelId, + ); + if ( + currentProviderId !== existingAgentModel.providerId + || currentModelId !== existingAgentModel.modelId + || get().currentVariant !== savedVariant + ) { + applyResolvedModelSelection(existingAgentModel.providerId, existingAgentModel.modelId, savedVariant); + } return; } } + if (hasProviderModel(providers, currentProviderId, currentModelId)) { + return; + } + // If settings has a default model, use it instead of agent's preferred if (settingsDefaultModel) { const parsed = parseModelString(settingsDefaultModel); if (parsed) { const settingsProvider = providers.find((p) => p.id === parsed.providerId); if (settingsProvider?.models.some((m) => m.id === parsed.modelId)) { - set((state) => { - const directoryKey = state.activeDirectoryKey; - const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? { - providers: state.providers, - agents: state.agents, - currentProviderId: state.currentProviderId, - currentModelId: state.currentModelId, - currentVariant: state.currentVariant, - currentAgentName: state.currentAgentName, - selectedProviderId: state.selectedProviderId, - agentModelSelections: state.agentModelSelections, - defaultProviders: state.defaultProviders, - }; - - let nextVariant: string | undefined; - if (settingsDefaultVariant) { - const settingsProvider = providers.find((p) => p.id === parsed.providerId); - const model = settingsProvider?.models.find((m) => m.id === parsed.modelId) as { variants?: Record } | undefined; - const variants = model?.variants; - if (variants && Object.prototype.hasOwnProperty.call(variants, settingsDefaultVariant)) { - nextVariant = settingsDefaultVariant; - } + let nextVariant: string | undefined; + if (settingsDefaultVariant) { + const model = settingsProvider.models.find((m) => m.id === parsed.modelId) as { variants?: Record } | undefined; + const variants = model?.variants; + if (variants && Object.prototype.hasOwnProperty.call(variants, settingsDefaultVariant)) { + nextVariant = settingsDefaultVariant; } + } - const nextSnapshot: DirectoryScopedConfig = { - ...baseSnapshot, - currentProviderId: parsed.providerId, - currentModelId: parsed.modelId, - currentVariant: nextVariant, - }; - - return { - currentProviderId: parsed.providerId, - currentModelId: parsed.modelId, - currentVariant: nextVariant, - directoryScoped: { - ...state.directoryScoped, - [directoryKey]: nextSnapshot, - }, - }; - }); + applyResolvedModelSelection(parsed.providerId, parsed.modelId, nextVariant); return; } } @@ -1667,36 +1696,7 @@ export const useConfigStore = create()( const agentModel = agentProvider?.models.find((model) => model.id === modelID); if (agentModel) { - set((state) => { - const directoryKey = state.activeDirectoryKey; - const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? { - providers: state.providers, - agents: state.agents, - currentProviderId: state.currentProviderId, - currentModelId: state.currentModelId, - currentAgentName: state.currentAgentName, - selectedProviderId: state.selectedProviderId, - agentModelSelections: state.agentModelSelections, - defaultProviders: state.defaultProviders, - }; - - const nextSnapshot: DirectoryScopedConfig = { - ...baseSnapshot, - currentProviderId: providerID, - currentModelId: modelID, - selectedProviderId: providerID, - }; - - return { - currentProviderId: providerID, - currentModelId: modelID, - selectedProviderId: providerID, - directoryScoped: { - ...state.directoryScoped, - [directoryKey]: nextSnapshot, - }, - }; - }); + applyResolvedModelSelection(providerID, modelID, undefined); } } } diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index 9468ae49..d85fb0d4 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -646,11 +646,18 @@ interface UIStore { applyPadding: () => void; updateProportionalSidebarWidths: () => void; toggleFavoriteModel: (providerID: string, modelID: string) => void; + reorderFavoriteModel: ( + activeProviderID: string, + activeModelID: string, + overProviderID: string, + overModelID: string, + ) => void; toggleHiddenModel: (providerID: string, modelID: string) => void; isHiddenModel: (providerID: string, modelID: string) => boolean; hideAllModels: (providerID: string, modelIDs: string[]) => void; showAllModels: (providerID: string) => void; toggleModelProviderCollapsed: (providerID: string) => void; + setModelProvidersCollapsed: (providerIDs: string[], collapsed: boolean) => void; isFavoriteModel: (providerID: string, modelID: string) => boolean; addRecentModel: (providerID: string, modelID: string) => void; addRecentAgent: (agentName: string) => void; @@ -1507,6 +1514,29 @@ export const useUIStore = create()( }); }, + reorderFavoriteModel: (activeProviderID, activeModelID, overProviderID, overModelID) => { + set((state) => { + const oldIndex = state.favoriteModels.findIndex( + (fav) => fav.providerID === activeProviderID && fav.modelID === activeModelID + ); + const newIndex = state.favoriteModels.findIndex( + (fav) => fav.providerID === overProviderID && fav.modelID === overModelID + ); + + if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) { + return state; + } + + const nextFavorites = state.favoriteModels.slice(); + const [moved] = nextFavorites.splice(oldIndex, 1); + if (!moved) { + return state; + } + nextFavorites.splice(newIndex, 0, moved); + return { favoriteModels: nextFavorites }; + }); + }, + toggleHiddenModel: (providerID, modelID) => { set((state) => { const exists = state.hiddenModels.some( @@ -1570,6 +1600,30 @@ export const useUIStore = create()( }); }, + setModelProvidersCollapsed: (providerIDs, collapsed) => { + const normalizedProviderIDs = Array.from(new Set( + providerIDs + .filter((providerID): providerID is string => typeof providerID === 'string') + .map((providerID) => providerID.trim()) + .filter(Boolean) + )); + + if (normalizedProviderIDs.length === 0) { + return; + } + + set((state) => { + const scopedProviderIDs = new Set(normalizedProviderIDs); + const untouchedProviders = state.collapsedModelProviders.filter((providerID) => !scopedProviderIDs.has(providerID)); + + return { + collapsedModelProviders: collapsed + ? [...untouchedProviders, ...normalizedProviderIDs] + : untouchedProviders, + }; + }); + }, + isFavoriteModel: (providerID, modelID) => { const { favoriteModels } = get(); return favoriteModels.some( diff --git a/packages/ui/src/sync/selection-store.ts b/packages/ui/src/sync/selection-store.ts index d53fae56..2e3e6383 100644 --- a/packages/ui/src/sync/selection-store.ts +++ b/packages/ui/src/sync/selection-store.ts @@ -64,18 +64,31 @@ export const useSelectionStore = create()((set, get) => ({ get().sessionAgentModelSelections.get(sessionId)?.get(agentName) ?? null, saveAgentModelVariantForSession: (sessionId, agentName, providerId, modelId, variant) => { - if (!variant) return const key = `${providerId}/${modelId}` let agentMap = agentModelVariantSelections.get(sessionId) - if (!agentMap) { + if (!agentMap && variant) { agentMap = new Map() agentModelVariantSelections.set(sessionId, agentMap) } + if (!agentMap) return let modelMap = agentMap.get(agentName) - if (!modelMap) { + if (!modelMap && variant) { modelMap = new Map() agentMap.set(agentName, modelMap) } + if (!modelMap) return + + if (!variant) { + modelMap.delete(key) + if (modelMap.size === 0) { + agentMap.delete(agentName) + } + if (agentMap.size === 0) { + agentModelVariantSelections.delete(sessionId) + } + return + } + modelMap.set(key, variant) },