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) => {
+8
View File
@@ -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',
+8
View File
@@ -1485,7 +1485,15 @@ export const dict: Record<I18nKey, string> = {
"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",
+8
View File
@@ -1485,7 +1485,15 @@ export const dict: Record<I18nKey, string> = {
'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': '거부',
@@ -1485,7 +1485,15 @@ export const dict: Record<I18nKey, string> = {
"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",
+8
View File
@@ -1485,7 +1485,15 @@ export const dict: Record<I18nKey, string> = {
"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": "Заборонити",
@@ -1485,7 +1485,15 @@ export const dict: Record<I18nKey, string> = {
'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': '拒绝',
+71 -71
View File
@@ -1551,7 +1551,14 @@ export const useConfigStore = create<ConfigStore>()(
},
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<ConfigStore>()(
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<string, unknown> } | 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<string, unknown> } | 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<ConfigStore>()(
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);
}
}
}
+54
View File
@@ -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<UIStore>()(
});
},
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<UIStore>()(
});
},
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(
+16 -3
View File
@@ -64,18 +64,31 @@ export const useSelectionStore = create<SelectionState>()((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)
},