From b733f26aed4a2bcf7bc03d81fd263736657fd1ee Mon Sep 17 00:00:00 2001 From: Jovines <1246634075@qq.com> Date: Thu, 5 Feb 2026 00:07:24 +0800 Subject: [PATCH] feat(mobile): split controls into separate Agent and Model buttons (#293) * feat(mobile): split controls into separate Agent and Model buttons - Add MobileAgentButton with tap-to-cycle and long-press-to-open behavior - Add MobileModelButton for direct model panel access - Remove agent selection from UnifiedControlsDrawer for cleaner mobile UX - Agent cycling accessible via Tab key shortcut * fix(mobile): optimize controls layout and touch handling --------- Co-authored-by: Jovines --- packages/ui/src/components/chat/ChatInput.tsx | 40 ++++---- .../src/components/chat/MobileAgentButton.tsx | 91 +++++++++++++++++ .../src/components/chat/MobileModelButton.tsx | 35 +++++++ .../ui/src/components/chat/ModelControls.tsx | 8 +- .../components/chat/UnifiedControlsDrawer.tsx | 98 +------------------ 5 files changed, 156 insertions(+), 116 deletions(-) create mode 100644 packages/ui/src/components/chat/MobileAgentButton.tsx create mode 100644 packages/ui/src/components/chat/MobileModelButton.tsx diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 55345be7..7de52353 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -22,10 +22,11 @@ import { SkillAutocomplete, type SkillAutocompleteHandle } from './SkillAutocomp import { cn } from '@/lib/utils'; import { ServerFilePicker } from './ServerFilePicker'; import { ModelControls } from './ModelControls'; -import { StatusChip } from './StatusChip'; import { UnifiedControlsDrawer } from './UnifiedControlsDrawer'; import { parseAgentMentions } from '@/lib/messages/agentMentions'; import { StatusRow } from './StatusRow'; +import { MobileAgentButton } from './MobileAgentButton'; +import { MobileModelButton } from './MobileModelButton'; import { useAssistantStatus } from '@/hooks/useAssistantStatus'; import { useCurrentSessionActivity } from '@/hooks/useSessionActivity'; import { toast } from '@/components/ui'; @@ -530,7 +531,7 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo if (e.key === 'Tab' && !showCommandAutocomplete && !showAgentAutocomplete && !showFileMention) { e.preventDefault(); - cycleAgent(); + handleCycleAgent(); return; } @@ -587,9 +588,8 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo void abortCurrentOperation(); }, [abortCurrentOperation, clearAbortPrompt, startAbortIndicator]); - const cycleAgent = () => { + const handleCycleAgent = React.useCallback(() => { const primaryAgents = agents.filter(agent => isPrimaryMode(agent.mode)); - if (primaryAgents.length <= 1) return; const currentIndex = primaryAgents.findIndex(agent => agent.name === currentAgentName); @@ -599,10 +599,9 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo setAgent(nextAgent.name); if (currentSessionId) { - saveSessionAgentSelection(currentSessionId, nextAgent.name); } - }; + }, [agents, currentAgentName, currentSessionId, setAgent, saveSessionAgentSelection]); const adjustTextareaHeight = React.useCallback(() => { const textarea = textareaRef.current; @@ -1364,7 +1363,7 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo + ); +}; + +export default MobileAgentButton; diff --git a/packages/ui/src/components/chat/MobileModelButton.tsx b/packages/ui/src/components/chat/MobileModelButton.tsx new file mode 100644 index 00000000..971d31c3 --- /dev/null +++ b/packages/ui/src/components/chat/MobileModelButton.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { cn } from '@/lib/utils'; +import { useConfigStore } from '@/stores/useConfigStore'; +import { getModelDisplayName } from './mobileControlsUtils'; + +interface MobileModelButtonProps { + onOpenModel: () => void; + className?: string; +} + +export const MobileModelButton: React.FC = ({ onOpenModel, className }) => { + const { currentModelId, getCurrentProvider } = useConfigStore(); + const currentProvider = getCurrentProvider(); + const modelLabel = getModelDisplayName(currentProvider, currentModelId); + + return ( + + ); +}; + +export default MobileModelButton; diff --git a/packages/ui/src/components/chat/ModelControls.tsx b/packages/ui/src/components/chat/ModelControls.tsx index 008d4f30..24c1f13d 100644 --- a/packages/ui/src/components/chat/ModelControls.tsx +++ b/packages/ui/src/components/chat/ModelControls.tsx @@ -256,6 +256,7 @@ interface ModelControlsProps { mobilePanel?: MobileControlsPanel; onMobilePanelChange?: (panel: MobileControlsPanel) => void; onMobilePanelSelection?: () => void; + onAgentPanelSelection?: () => void; } export const ModelControls: React.FC = ({ @@ -263,6 +264,7 @@ export const ModelControls: React.FC = ({ mobilePanel, onMobilePanelChange, onMobilePanelSelection, + onAgentPanelSelection, }) => { const { providers, @@ -1032,9 +1034,10 @@ export const ModelControls: React.FC = ({ } if (isCompact) { closeMobilePanel(); - if (onMobilePanelSelection) { + const callback = onAgentPanelSelection || onMobilePanelSelection; + if (callback) { requestAnimationFrame(() => { - onMobilePanelSelection(); + callback(); }); } } @@ -1739,6 +1742,7 @@ export const ModelControls: React.FC = ({ className={cn( 'flex w-full flex-col gap-1 rounded-xl border px-2 py-1.5 text-left', 'focus:outline-none focus-visible:ring-1 focus-visible:ring-primary agent-list-item', + 'touch-manipulation cursor-pointer', agentColor.class, isSelected ? 'active' : 'border-border/40' )} diff --git a/packages/ui/src/components/chat/UnifiedControlsDrawer.tsx b/packages/ui/src/components/chat/UnifiedControlsDrawer.tsx index a97b61d6..1de252c1 100644 --- a/packages/ui/src/components/chat/UnifiedControlsDrawer.tsx +++ b/packages/ui/src/components/chat/UnifiedControlsDrawer.tsx @@ -9,14 +9,10 @@ import { useUIStore } from '@/stores/useUIStore'; import { useModelLists } from '@/hooks/useModelLists'; import { formatEffortLabel, - getAgentDisplayName, getQuickEffortOptions, - isPrimaryMode, parseEffortVariant, } from './mobileControlsUtils'; -const MAX_QUICK_AGENTS = 3; - const COMPACT_NUMBER_FORMATTER = new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1, @@ -36,7 +32,6 @@ const formatTokens = (value?: number | null) => { interface UnifiedControlsDrawerProps { open: boolean; onClose: () => void; - onOpenAgent: () => void; onOpenModel: () => void; onOpenEffort: () => void; } @@ -44,7 +39,6 @@ interface UnifiedControlsDrawerProps { export const UnifiedControlsDrawer: React.FC = ({ open, onClose, - onOpenAgent, onOpenModel, onOpenEffort, }) => { @@ -53,20 +47,16 @@ export const UnifiedControlsDrawer: React.FC = ({ currentProviderId, currentModelId, currentVariant, - currentAgentName, - setAgent, setProvider, setModel, setCurrentVariant, getCurrentModelVariants, - getVisibleAgents, getModelMetadata, } = useConfigStore(); - const { addRecentModel, addRecentAgent, addRecentEffort, recentAgents, recentEfforts } = useUIStore(); + const { addRecentModel, addRecentEffort, recentEfforts } = useUIStore(); const { recentModelsList } = useModelLists(); const { currentSessionId, - saveSessionAgentSelection, saveAgentModelForSession, saveAgentModelVariantForSession, } = useSessionStore(); @@ -74,39 +64,7 @@ export const UnifiedControlsDrawer: React.FC = ({ currentSessionId ? state.getSessionAgentSelection(currentSessionId) : null ); - const agents = getVisibleAgents(); - const uiAgentName = currentSessionId ? (sessionAgentName || currentAgentName) : currentAgentName; - const primaryAgents = agents.filter((agent) => isPrimaryMode(agent.mode)); - const recentAgentNames = React.useMemo(() => { - if (recentAgents.length === 0) { - return []; - } - const visibleAgents = new Set(agents.map((agent) => agent.name)); - return recentAgents.filter((name) => visibleAgents.has(name)); - }, [recentAgents, agents]); - - const quickAgentNames = React.useMemo(() => { - const fallback = primaryAgents.length > 0 ? primaryAgents.map((agent) => agent.name) : agents.map((agent) => agent.name); - const base = fallback.slice(0, MAX_QUICK_AGENTS); - const orderedRecents = recentAgentNames.slice().reverse(); - for (const recent of orderedRecents) { - if (!recent || base.includes(recent)) { - continue; - } - base.unshift(recent); - base.splice(MAX_QUICK_AGENTS); - } - if (uiAgentName && !base.includes(uiAgentName)) { - if (base.length > 0) { - base[0] = uiAgentName; - } else { - base.push(uiAgentName); - } - } - return base; - }, [agents, primaryAgents, recentAgentNames, uiAgentName]); - - const hasAgentOverflow = agents.some((agent) => !quickAgentNames.includes(agent.name)); + const uiAgentName = currentSessionId ? (sessionAgentName || null) : null; const recentModelsBase = recentModelsList.slice(0, 4); const hasCurrentInRecents = recentModelsBase.some( @@ -162,14 +120,6 @@ export const UnifiedControlsDrawer: React.FC = ({ }, [baseEfforts, currentVariant, recentEffortOptions]); const effortHasMore = variants.length + 1 > quickEfforts.length; - const handleAgentSelect = (agentName: string) => { - setAgent(agentName); - addRecentAgent(agentName); - if (currentSessionId) { - saveSessionAgentSelection(currentSessionId, agentName); - } - }; - const handleModelSelect = (providerId: string, modelId: string) => { const provider = providers.find((entry) => entry.id === providerId); if (!provider) { @@ -209,50 +159,6 @@ export const UnifiedControlsDrawer: React.FC = ({ return (
-
-
- Agent -
-
- {agents.length === 0 ? ( -
- No agents configured -
- ) : ( - quickAgentNames.map((agentName) => { - const displayName = getAgentDisplayName(agents, agentName); - const isSelected = agentName === uiAgentName; - return ( - - ); - }) - )} - {hasAgentOverflow && ( - - )} -
-
-
Model