Feat/mobile quick actions (#282)

* feat(mobile): add quick actions button and improve mobile UX

Add mobile-optimized quick action buttons and improve autocomplete UX.

- Add quick command button (/) in mobile chat footer
- Add tabbed autocomplete interface for Commands/Agents/Files
- Fix agent selector conflict with model selector
- Improve TerminalViewport Android IME support
- Add reusable Slider component for settings
- Update StatusChip styling for mobile layout

Files changed:
- ChatInput.tsx: Mobile quick actions, command menu, autocomplete tabs
- AgentMentionAutocomplete.tsx: Tab support for mobile
- CommandAutocomplete.tsx: Tab support and touch handling
- FileMentionAutocomplete.tsx: Tab support for mobile
- ModelControls.tsx: Agent selector fix
- StatusChip.tsx: Mobile layout improvements
- TerminalViewport.tsx: Android IME improvements
- ui/slider.tsx: New reusable slider component

* feat(chat): remove unused BrowserVoiceButton component from ChatInput

* fix: Resolve linter errors
This commit is contained in:
gsxdsm
2026-02-04 11:14:10 +02:00
committed by GitHub
parent 66da4ad8f4
commit ea0deec14d
8 changed files with 656 additions and 196 deletions
+106 -28
View File
@@ -3,6 +3,7 @@ import type { ComponentType } from 'react';
import {
RiAiAgentLine,
RiArrowDownSLine,
RiArrowGoBackLine,
RiArrowRightSLine,
RiBrainAi3Line,
RiCheckLine,
@@ -270,6 +271,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
currentVariant,
currentAgentName,
settingsDefaultVariant,
settingsDefaultAgent,
setProvider,
setModel,
setCurrentVariant,
@@ -336,6 +338,9 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
isModelSelectorOpen,
setModelSelectorOpen,
} = useUIStore();
// Separate state for agent selector to avoid conflict with model selector
const [isAgentSelectorOpen, setIsAgentSelectorOpen] = React.useState(false);
const { favoriteModelsList, recentModelsList } = useModelLists();
const { isMobile } = useDeviceInfo();
@@ -385,12 +390,13 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
}
}, [activeMobilePanel]);
const prevAgentMenuOpenRef = React.useRef(agentMenuOpen);
// Handle model selector close behavior (separate from agent selector)
const prevModelSelectorOpenRef = React.useRef(isModelSelectorOpen);
React.useEffect(() => {
const wasOpen = prevAgentMenuOpenRef.current;
prevAgentMenuOpenRef.current = agentMenuOpen;
const wasOpen = prevModelSelectorOpenRef.current;
prevModelSelectorOpenRef.current = isModelSelectorOpen;
if (!agentMenuOpen) {
if (!isModelSelectorOpen) {
setDesktopModelQuery('');
setModelSelectedIndex(0);
@@ -402,13 +408,48 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
});
}
}
}, [agentMenuOpen, isCompact]);
}, [isModelSelectorOpen, isCompact]);
// Handle agent selector close behavior
const [agentSearchQuery, setAgentSearchQuery] = React.useState('');
React.useEffect(() => {
if (!isAgentSelectorOpen) {
setAgentSearchQuery('');
if (!isCompact) {
requestAnimationFrame(() => {
const textarea = document.querySelector<HTMLTextAreaElement>('textarea[data-chat-input="true"]');
textarea?.focus();
});
}
}
}, [isAgentSelectorOpen, isCompact]);
// Reset selected index when search query changes
React.useEffect(() => {
setModelSelectedIndex(0);
}, [desktopModelQuery]);
const sortedAndFilteredAgents = React.useMemo(() => {
const sorted = [...agents].sort((a, b) => a.name.localeCompare(b.name));
if (!agentSearchQuery.trim()) {
return sorted;
}
return sorted.filter((agent) =>
fuzzyMatch(agentSearchQuery, agent.name) ||
(agent.description && fuzzyMatch(agentSearchQuery, agent.description))
);
}, [agents, agentSearchQuery]);
const defaultAgentName = React.useMemo(() => {
if (settingsDefaultAgent) {
const found = agents.find(a => a.name === settingsDefaultAgent);
if (found) return found.name;
}
const buildAgent = agents.find(a => a.name === 'build');
if (buildAgent) return buildAgent.name;
return agents[0]?.name;
}, [settingsDefaultAgent, agents]);
const currentAgent = React.useMemo(() => {
if (uiAgentName) {
return agents.find((agent) => agent.name === uiAgentName);
@@ -2408,7 +2449,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
return (
<div className="flex items-center gap-2 min-w-0">
<Tooltip delayDuration={1000}>
<DropdownMenu>
<DropdownMenu open={isAgentSelectorOpen} onOpenChange={setIsAgentSelectorOpen}>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<div className={cn(
@@ -2437,29 +2478,66 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
</div>
</DropdownMenuTrigger>
</TooltipTrigger>
<DropdownMenuContent align="end" alignOffset={-40} className="w-[min(280px,calc(100vw-2rem))]">
{agents.filter(agent => isPrimaryMode(agent.mode)).map((agent) => (
<DropdownMenuItem
key={agent.name}
className="typography-meta"
onSelect={() => handleAgentChange(agent.name)}
>
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-1.5">
<div className={cn(
'h-1 w-1 rounded-full agent-dot',
getAgentColor(agent.name).class
)} />
<span className="font-medium">{capitalizeAgentName(agent.name)}</span>
<DropdownMenuContent align="end" alignOffset={-40} className="w-[min(280px,calc(100vw-2rem))] p-0 flex flex-col">
<div className="p-2 border-b border-border/40">
<div className="relative">
<RiSearchLine className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
type="text"
placeholder="Search agents"
value={agentSearchQuery}
onChange={(e) => setAgentSearchQuery(e.target.value)}
className="pl-8 h-8 typography-meta"
autoFocus
/>
</div>
</div>
<ScrollableOverlay outerClassName="max-h-[min(400px,calc(100dvh-12rem))] flex-1">
<div className="p-1">
{!agentSearchQuery.trim() && defaultAgentName && (
<>
<DropdownMenuItem
className="typography-meta"
onSelect={() => handleAgentChange(defaultAgentName)}
>
<div className="flex items-center gap-1.5">
<RiArrowGoBackLine className="h-3.5 w-3.5 text-muted-foreground" />
<span className="font-medium">Reset to default</span>
</div>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
{sortedAndFilteredAgents.length === 0 ? (
<div className="px-2 py-4 text-center typography-meta text-muted-foreground">
No agents found
</div>
{agent.description && (
<span className="typography-meta text-muted-foreground max-w-[200px] ml-2.5 break-words">
{agent.description}
</span>
)}
</div>
</DropdownMenuItem>
))}
) : (
sortedAndFilteredAgents.map((agent) => (
<DropdownMenuItem
key={agent.name}
className="typography-meta"
onSelect={() => handleAgentChange(agent.name)}
>
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-1.5">
<div className={cn(
'h-1 w-1 rounded-full agent-dot',
getAgentColor(agent.name).class
)} />
<span className="font-medium">{capitalizeAgentName(agent.name)}</span>
</div>
{agent.description && (
<span className="typography-meta text-muted-foreground max-w-[200px] ml-2.5 break-words">
{agent.description}
</span>
)}
</div>
</DropdownMenuItem>
))
)}
</div>
</ScrollableOverlay>
<DropdownMenuSeparator />
<div className="flex flex-col gap-1 px-1 py-0.5">
<div className="rounded-xl bg-transparent">