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:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
4b171ee207
commit
b62faadd15
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user