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) },