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 <artmore@protonmail.com>
This commit is contained in:
Dave Otero
2026-04-27 13:28:51 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent 4b171ee207
commit b62faadd15
13 changed files with 984 additions and 704 deletions
+7 -57
View File
@@ -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<ChatInputProps> = ({ 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<MobileControlsPanel>(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<ChatInputProps> = ({ 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<ChatInputProps> = ({ 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<ChatInputProps> = ({ 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<ChatInputProps> = ({ onOpenSettings, scrollTo
React.useEffect(() => {
if (!isMobile) {
setMobileControlsOpen(false);
setMobileControlsPanel(null);
}
}, [isMobile]);
@@ -3745,7 +3703,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
</div>
<div className="flex items-center min-w-0 gap-x-1 justify-end">
<div className="flex items-center gap-x-1 min-w-0 max-w-[60vw] flex-shrink">
<MemoMobileModelButton onOpenModel={handleOpenMobileControls} className="min-w-0 flex-shrink" />
<MemoMobileModelButton onOpenModel={() => handleOpenMobilePanel('model')} className="min-w-0 flex-shrink" />
<MemoMobileAgentButton
onOpenAgentPanel={handleOpenAgentPanel}
onCycleAgent={handleCycleAgent}
@@ -3775,14 +3733,6 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
className="hidden"
mobilePanel={mobileControlsPanel}
onMobilePanelChange={setMobileControlsPanel}
onMobilePanelSelection={handleReturnToUnifiedControls}
onAgentPanelSelection={() => setMobileControlsPanel(null)}
/>
<MemoUnifiedControlsDrawer
open={mobileControlsOpen}
onClose={handleCloseMobileControls}
onOpenModel={() => handleOpenMobilePanel('model')}
onOpenEffort={() => handleOpenMobilePanel('variant')}
/>
</>
) : (
File diff suppressed because it is too large Load Diff
@@ -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<UnifiedControlsDrawerProps> = ({
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 (
<MobileOverlayPanel open={open} onClose={onClose} title={t('chat.unifiedControls.title')}>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-2">
<div className="typography-meta font-semibold uppercase tracking-wide text-muted-foreground">
{t('chat.unifiedControls.model.title')}
</div>
<div className="rounded-xl border border-border/40 overflow-hidden">
{recentModels.length === 0 && !hasCurrentInRecents && (
<div className="px-3 py-2 typography-meta text-muted-foreground">
{t('chat.unifiedControls.model.noRecent')}
</div>
)}
{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 (
<button
key={`recent-${providerID}-${modelID}`}
type="button"
onClick={() => handleModelSelect(providerID, modelID)}
className={cn(
'flex min-h-[44px] w-full items-center gap-2 border-b border-border/30 px-3 py-2 text-left last:border-b-0',
isSelected ? 'bg-primary/10' : ''
)}
>
<ProviderLogo providerId={providerID} className="h-4 w-4 flex-shrink-0" />
<span className="typography-meta font-medium text-foreground truncate min-w-0 flex-1">
{modelName}
</span>
{(ctxTokens || outTokens) && (
<span className="typography-micro text-muted-foreground whitespace-nowrap flex-shrink-0">
{ctxTokens && `${ctxTokens} ctx`}
{ctxTokens && outTokens && ' • '}
{outTokens && `${outTokens} out`}
</span>
)}
</button>
);
})}
<button
type="button"
onClick={onOpenModel}
className="flex min-h-[44px] w-full items-center justify-center border-t border-border/30 px-3 py-2 typography-meta font-medium text-muted-foreground"
aria-label={t('chat.unifiedControls.model.moreAria')}
>
...
</button>
</div>
</div>
{hasEffort && (
<div className="flex flex-col gap-2">
<div className="typography-meta font-semibold uppercase tracking-wide text-muted-foreground">
{t('chat.unifiedControls.effort.title')}
</div>
<div className="flex flex-wrap gap-2">
{quickEfforts.map((variant) => {
const isSelected = variant === currentVariant || (!variant && !currentVariant);
return (
<button
key={variant ?? 'default'}
type="button"
onClick={() => handleEffortSelect(variant)}
className={cn(
'inline-flex items-center rounded-full border px-2.5 py-1 typography-meta font-medium',
isSelected
? 'border-primary/30 bg-primary/10 text-foreground'
: 'border-border/40 text-muted-foreground hover:bg-interactive-hover/50'
)}
aria-pressed={isSelected}
>
{formatEffortLabel(variant)}
</button>
);
})}
{effortHasMore && (
<button
type="button"
onClick={onOpenEffort}
className="inline-flex items-center rounded-full border border-border/40 px-2.5 py-1 typography-meta font-medium text-muted-foreground hover:bg-interactive-hover/50"
aria-label={t('chat.unifiedControls.effort.moreAria')}
>
...
</button>
)}
</div>
</div>
)}
</div>
</MobileOverlayPanel>
);
};
export default UnifiedControlsDrawer;
@@ -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) => {