From 3cfc98b409ecd466c2123e923327f9d10e2c9891 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Wed, 21 Jan 2026 18:58:33 +0800 Subject: [PATCH] feat: add search and keyboard navigation to model selector - Add search input when 5+ models available with filtering - Add keyboard navigation (Arrow keys, Tab, Enter, Escape) - Add scrollable model list with thin scrollbar styling - Fix highlight jumping: use -1 to indicate no selection until user interacts - Fix scroll position reset on dropdown open - Remove dropdown animations for instant open/close - Fix focus management: prevent Radix auto-focus return to trigger - Add onSelectionComplete callback for textarea focus after selection Co-Authored-By: Claude Opus 4.5 --- .../src/components/AgentModelSelector.tsx | 379 +++++++++++++----- src/renderer/src/components/MessageInput.tsx | 12 + src/renderer/src/components/ModeSelector.tsx | 9 +- src/renderer/src/styles/index.css | 22 + 4 files changed, 319 insertions(+), 103 deletions(-) diff --git a/src/renderer/src/components/AgentModelSelector.tsx b/src/renderer/src/components/AgentModelSelector.tsx index 72107f57d0..59aa5288a6 100644 --- a/src/renderer/src/components/AgentModelSelector.tsx +++ b/src/renderer/src/components/AgentModelSelector.tsx @@ -2,8 +2,8 @@ * Combined Agent and Model selector with vertical layout * Shows Agent icon + Model name, with vertical list for model selection */ -import { useState, useEffect } from 'react' -import { ChevronDown, Loader2, Check } from 'lucide-react' +import { useState, useEffect, useMemo, useRef, useCallback } from 'react' +import { ChevronDown, Loader2, Check, Search } from 'lucide-react' import type { AgentCheckResult } from '../../../shared/electron-api' import type { SessionModelState, ModelId } from '../../../shared/types' @@ -21,6 +21,9 @@ const AGENT_ICONS: Record = { // Icons that need dark mode inversion (monochrome black icons) const INVERT_IN_DARK = new Set(['codex']) +// Minimum number of models to show search input +const MIN_MODELS_FOR_SEARCH = 5 + import { DropdownMenu, DropdownMenuTrigger, @@ -40,6 +43,7 @@ interface AgentModelSelectorProps { disabled?: boolean isSwitching?: boolean isInitializing?: boolean + onSelectionComplete?: () => void } export function AgentModelSelector({ @@ -49,12 +53,16 @@ export function AgentModelSelector({ onModelChange, disabled = false, isSwitching = false, - isInitializing = false + isInitializing = false, + onSelectionComplete }: AgentModelSelectorProps): React.JSX.Element { const [agents, setAgents] = useState([]) const [loading, setLoading] = useState(true) const [open, setOpen] = useState(false) const [isHovered, setIsHovered] = useState(false) + const [searchQuery, setSearchQuery] = useState('') + const [highlightedIndex, setHighlightedIndex] = useState(0) + const listRef = useRef(null) useEffect(() => { loadAgents() @@ -82,19 +90,113 @@ export function AgentModelSelector({ ) const displayName = currentModel?.name || currentAgentName - function handleAgentSelect(agentId: string): void { - if (agentId !== currentAgentId) { - onAgentChange(agentId) - } - setOpen(false) - } + // Show search when there are many models + const showSearch = (modelState?.availableModels.length ?? 0) >= MIN_MODELS_FOR_SEARCH - function handleModelSelect(modelId: ModelId): void { - if (modelId !== modelState?.currentModelId) { - onModelChange(modelId) + // Filter models based on search query + const filteredModels = useMemo(() => { + if (!modelState?.availableModels) return [] + if (!searchQuery.trim()) return modelState.availableModels + const query = searchQuery.toLowerCase() + return modelState.availableModels.filter((m) => m.name.toLowerCase().includes(query)) + }, [modelState?.availableModels, searchQuery]) + + // Reset search, highlight, and scroll position when dropdown opens + // Use -1 to indicate no item is highlighted until user interacts + useEffect(() => { + if (open) { + setSearchQuery('') + setHighlightedIndex(-1) + // Reset scroll position after DOM updates + requestAnimationFrame(() => { + if (listRef.current) { + listRef.current.scrollTop = 0 + } + }) } - setOpen(false) - } + }, [open]) + + // Reset highlight when filtered results change + useEffect(() => { + setHighlightedIndex(-1) + }, [filteredModels.length]) + + const handleAgentSelect = useCallback( + (agentId: string): void => { + if (agentId !== currentAgentId) { + onAgentChange(agentId) + } + setOpen(false) + onSelectionComplete?.() + }, + [currentAgentId, onAgentChange, onSelectionComplete] + ) + + const handleModelSelect = useCallback( + (modelId: ModelId): void => { + if (modelId !== modelState?.currentModelId) { + onModelChange(modelId) + } + setOpen(false) + onSelectionComplete?.() + }, + [modelState?.currentModelId, onModelChange, onSelectionComplete] + ) + + // Keyboard navigation handler for search input + const handleSearchKeyDown = useCallback( + (e: React.KeyboardEvent) => { + const modelCount = filteredModels.length + if (modelCount === 0) return + + switch (e.key) { + case 'ArrowDown': + e.preventDefault() + // If nothing highlighted, start from first item; otherwise move down + setHighlightedIndex((prev) => (prev < 0 ? 0 : (prev + 1) % modelCount)) + break + case 'ArrowUp': + e.preventDefault() + // If nothing highlighted, start from last item; otherwise move up + setHighlightedIndex((prev) => + prev < 0 ? modelCount - 1 : (prev - 1 + modelCount) % modelCount + ) + break + case 'Tab': + e.preventDefault() + if (e.shiftKey) { + setHighlightedIndex((prev) => + prev < 0 ? modelCount - 1 : (prev - 1 + modelCount) % modelCount + ) + } else { + setHighlightedIndex((prev) => (prev < 0 ? 0 : (prev + 1) % modelCount)) + } + break + case 'Enter': + e.preventDefault() + // Only select if there's a valid highlighted item + if (highlightedIndex >= 0 && filteredModels[highlightedIndex]) { + handleModelSelect(filteredModels[highlightedIndex].modelId) + } + break + case 'Escape': + e.preventDefault() + setOpen(false) + break + } + }, + [filteredModels, highlightedIndex, handleModelSelect, setOpen] + ) + + // Scroll highlighted item into view (only when user actively highlights an item) + useEffect(() => { + if (!listRef.current || highlightedIndex < 0) return + const items = listRef.current.querySelectorAll('[data-model-item]') + const highlightedItem = items[highlightedIndex] as HTMLElement | undefined + if (highlightedItem) { + highlightedItem.scrollIntoView({ block: 'nearest' }) + } + }, [highlightedIndex]) // Show skeleton during initialization if (isInitializing) { @@ -121,6 +223,7 @@ export function AgentModelSelector({ 'flex items-center gap-1.5 text-xs text-muted-foreground transition-colors px-2 py-1 rounded-md', 'hover:bg-accent hover:text-accent-foreground', 'data-[state=open]:bg-accent data-[state=open]:text-accent-foreground', + 'outline-none focus-visible:ring-1 focus-visible:ring-ring', (disabled || loading || isSwitching) && 'opacity-50 cursor-not-allowed' )} > @@ -144,103 +247,177 @@ export function AgentModelSelector({ Select agent and model - - {/* Current Agent Header */} -
-
- {currentAgentIcon && ( - {currentAgentName} - )} - {currentAgentName} + e.preventDefault()} + > + {/* Top section with padding: header + search */} +
+ {/* Current Agent Header */} +
+
+ {currentAgentIcon && ( + {currentAgentName} + )} + {currentAgentName} +
+ Active
- Active + + {/* Search input for many models */} + {showSearch && ( +
+
+ + setSearchQuery(e.target.value)} + placeholder="Search models..." + className="w-full h-7 pl-7 pr-2 text-xs bg-muted/50 border-none rounded-md outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50" + onClick={(e) => e.stopPropagation()} + onKeyDown={handleSearchKeyDown} + /> +
+
+ )}
- {/* Current Agent's Models */} + {/* Full-width scrollable section - scrollbar flush to edge */} {modelState && modelState.availableModels.length > 0 ? ( - modelState.availableModels.map((model) => { - const isSelectedModel = model.modelId === modelState.currentModelId - return ( - handleModelSelect(model.modelId)} - className="flex items-center justify-between gap-6 pl-6 py-1.5" - > - {model.name} - {isSelectedModel && } - - ) - }) +
+ {filteredModels.length > 0 ? ( + filteredModels.map((model, index) => { + const isSelectedModel = model.modelId === modelState.currentModelId + const isHighlighted = showSearch && index === highlightedIndex + + // When search is active, use plain div to avoid Radix's roving focus + // which steals focus from the search input on hover + if (showSearch) { + return ( +
handleModelSelect(model.modelId)} + onMouseEnter={() => setHighlightedIndex(index)} + className={cn( + 'flex items-center gap-6 pl-6 pr-2 py-1.5 cursor-pointer text-sm rounded-sm', + 'hover:bg-accent hover:text-accent-foreground', + isHighlighted && 'bg-accent text-accent-foreground' + )} + > + {model.name} + + {isSelectedModel && } + +
+ ) + } + + // Standard DropdownMenuItem when no search (preserves full accessibility) + return ( + handleModelSelect(model.modelId)} + className="flex items-center gap-6 pl-6 pr-2 py-1.5" + > + {model.name} + + {isSelectedModel && } + + + ) + }) + ) : ( +
+ No models match “{searchQuery}” +
+ )} +
) : ( -
No models available
+
+
+ No models available +
+
)} - {/* Separator if there are other agents */} - {otherAgents.length > 0 && } + {/* Bottom section: separator + other agents */} + {otherAgents.length > 0 && ( + <> + +
+ {otherAgents.map((agent) => { + const icon = AGENT_ICONS[agent.id] + const needsInvert = INVERT_IN_DARK.has(agent.id) + const isInstalled = agent.installed - {/* Other Agents */} - {otherAgents.map((agent) => { - const icon = AGENT_ICONS[agent.id] - const needsInvert = INVERT_IN_DARK.has(agent.id) - const isInstalled = agent.installed - - if (!isInstalled) { - // Not installed - show disabled state - return ( -
-
- {icon && ( - {agent.name} - )} - {agent.name} -
- Setup required -
- ) - } - - // Installed - show Switch button - return ( -
handleAgentSelect(agent.id)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault() - handleAgentSelect(agent.id) + if (!isInstalled) { + // Not installed - show disabled state + return ( +
+
+ {icon && ( + {agent.name} + )} + {agent.name} +
+ Setup required +
+ ) } - }} - > -
- {icon && ( - {agent.name} - )} - {agent.name} -
- - Switch - + + // Installed - show Switch button + return ( +
handleAgentSelect(agent.id)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + handleAgentSelect(agent.id) + } + }} + > +
+ {icon && ( + {agent.name} + )} + {agent.name} +
+ + Switch + +
+ ) + })}
- ) - })} + + )} ) diff --git a/src/renderer/src/components/MessageInput.tsx b/src/renderer/src/components/MessageInput.tsx index bc0b203d09..be4fe8deab 100644 --- a/src/renderer/src/components/MessageInput.tsx +++ b/src/renderer/src/components/MessageInput.tsx @@ -43,6 +43,8 @@ interface MessageInputProps { const MAX_IMAGE_SIZE = 10 * 1024 * 1024 // 10MB const SUPPORTED_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'] +// Minimal delay for focusing textarea after dropdown closes +const DROPDOWN_CLOSE_DELAY_MS = 0 // Warning banner component for missing directory function DirectoryWarningBanner({ @@ -285,6 +287,14 @@ export function MessageInput({ textareaRef.current?.focus() }, []) + // Focus textarea after selector completion (model/agent/mode selection) + const handleSelectorComplete = useCallback(() => { + // Delay to allow dropdown close animation and Radix auto-focus to complete + setTimeout(() => { + textareaRef.current?.focus() + }, DROPDOWN_CLOSE_DELAY_MS) + }, []) + // Handle submit const handleSubmit = useCallback(() => { const trimmed = value.trim() @@ -431,6 +441,7 @@ export function MessageInput({ disabled={isProcessing} isSwitching={isSwitchingAgent} isInitializing={isInitializing} + onSelectionComplete={handleSelectorComplete} /> )} @@ -441,6 +452,7 @@ export function MessageInput({ onModeChange={onModeChange} disabled={isProcessing} isInitializing={isInitializing} + onSelectionComplete={handleSelectorComplete} /> )}
diff --git a/src/renderer/src/components/ModeSelector.tsx b/src/renderer/src/components/ModeSelector.tsx index 0cba9b29ae..0434d403a6 100644 --- a/src/renderer/src/components/ModeSelector.tsx +++ b/src/renderer/src/components/ModeSelector.tsx @@ -20,13 +20,15 @@ interface ModeSelectorProps { onModeChange: (modeId: SessionModeId) => void disabled?: boolean isInitializing?: boolean + onSelectionComplete?: () => void } export function ModeSelector({ modeState, onModeChange, disabled = false, - isInitializing = false + isInitializing = false, + onSelectionComplete }: ModeSelectorProps): React.JSX.Element | null { const [open, setOpen] = useState(false) const [isHovered, setIsHovered] = useState(false) @@ -53,6 +55,7 @@ export function ModeSelector({ onModeChange(modeId) } setOpen(false) + onSelectionComplete?.() } return ( @@ -67,6 +70,7 @@ export function ModeSelector({ 'flex items-center gap-1.5 text-xs text-muted-foreground transition-colors px-2 py-1 rounded-md', 'hover:bg-accent hover:text-accent-foreground', 'data-[state=open]:bg-accent data-[state=open]:text-accent-foreground', + 'outline-none focus-visible:ring-1 focus-visible:ring-ring', disabled && 'opacity-50 cursor-not-allowed' )} > @@ -80,7 +84,8 @@ export function ModeSelector({ e.preventDefault()} > {modeState.availableModes.map((mode) => { const isSelected = mode.id === modeState.currentModeId diff --git a/src/renderer/src/styles/index.css b/src/renderer/src/styles/index.css index 5f44d46f60..b31f04fd00 100644 --- a/src/renderer/src/styles/index.css +++ b/src/renderer/src/styles/index.css @@ -41,6 +41,28 @@ body { background-color: var(--muted-foreground); } +/* Thin scrollbar variant for compact UI elements */ +.scrollbar-thin::-webkit-scrollbar { + width: 4px; + height: 4px; +} + +.scrollbar-thin::-webkit-scrollbar-thumb { + background-color: oklch(0.7 0 0 / 20%); +} + +.scrollbar-thin::-webkit-scrollbar-thumb:hover { + background-color: oklch(0.7 0 0 / 35%); +} + +.dark .scrollbar-thin::-webkit-scrollbar-thumb { + background-color: oklch(1 0 0 / 15%); +} + +.dark .scrollbar-thin::-webkit-scrollbar-thumb:hover { + background-color: oklch(1 0 0 / 25%); +} + @theme inline { --font-mono: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; --radius-sm: calc(var(--radius) - 4px);