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 <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing
2026-01-21 18:58:33 +08:00
parent b9b48e1c45
commit 3cfc98b409
4 changed files with 319 additions and 103 deletions

View File

@@ -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<string, string> = {
// 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<AgentCheckResult[]>([])
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<HTMLDivElement>(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<HTMLInputElement>) => {
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({
</TooltipTrigger>
<TooltipContent side="top">Select agent and model</TooltipContent>
</Tooltip>
<DropdownMenuContent side="top" align="start" className="min-w-[220px] p-1.5">
{/* Current Agent Header */}
<div className="flex items-center justify-between gap-3 px-2 py-1.5">
<div className="flex items-center gap-1.5">
{currentAgentIcon && (
<img
src={currentAgentIcon}
alt={currentAgentName}
className={cn('h-3.5 w-3.5', currentAgentNeedsInvert && 'dark:invert')}
/>
)}
<span className="text-xs text-muted-foreground">{currentAgentName}</span>
<DropdownMenuContent
side="top"
align="start"
className="min-w-[220px] p-0 data-[state=open]:animate-none data-[state=closed]:animate-none"
onCloseAutoFocus={(e) => e.preventDefault()}
>
{/* Top section with padding: header + search */}
<div className="px-1.5 pt-1.5">
{/* Current Agent Header */}
<div className="flex items-center justify-between gap-3 px-2 py-1.5">
<div className="flex items-center gap-1.5">
{currentAgentIcon && (
<img
src={currentAgentIcon}
alt={currentAgentName}
className={cn('h-3.5 w-3.5', currentAgentNeedsInvert && 'dark:invert')}
/>
)}
<span className="text-xs text-muted-foreground">{currentAgentName}</span>
</div>
<span className="text-xs text-green-600/70">Active</span>
</div>
<span className="text-xs text-green-600/70">Active</span>
{/* Search input for many models */}
{showSearch && (
<div className="px-2 py-1.5">
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground/50" />
<input
type="text"
autoFocus
value={searchQuery}
onChange={(e) => 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}
/>
</div>
</div>
)}
</div>
{/* 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 (
<DropdownMenuItem
key={model.modelId}
onClick={() => handleModelSelect(model.modelId)}
className="flex items-center justify-between gap-6 pl-6 py-1.5"
>
<span className="text-sm">{model.name}</span>
{isSelectedModel && <Check className="h-3.5 w-3.5 text-primary/70" />}
</DropdownMenuItem>
)
})
<div ref={listRef} className="max-h-[200px] overflow-y-auto scrollbar-thin">
{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 (
<div
key={model.modelId}
data-model-item
role="option"
aria-selected={isSelectedModel}
onClick={() => 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'
)}
>
<span className="flex-1">{model.name}</span>
<span className="w-3.5 flex-shrink-0">
{isSelectedModel && <Check className="h-3.5 w-3.5 text-primary/70" />}
</span>
</div>
)
}
// Standard DropdownMenuItem when no search (preserves full accessibility)
return (
<DropdownMenuItem
key={model.modelId}
data-model-item
onClick={() => handleModelSelect(model.modelId)}
className="flex items-center gap-6 pl-6 pr-2 py-1.5"
>
<span className="flex-1 text-sm">{model.name}</span>
<span className="w-3.5 flex-shrink-0">
{isSelectedModel && <Check className="h-3.5 w-3.5 text-primary/70" />}
</span>
</DropdownMenuItem>
)
})
) : (
<div className="pl-6 pr-2 py-2 text-xs text-muted-foreground/70">
No models match &ldquo;{searchQuery}&rdquo;
</div>
)}
</div>
) : (
<div className="pl-6 pr-2 py-2 text-xs text-muted-foreground/70">No models available</div>
<div className="px-1.5">
<div className="pl-6 pr-2 py-2 text-xs text-muted-foreground/70">
No models available
</div>
</div>
)}
{/* Separator if there are other agents */}
{otherAgents.length > 0 && <DropdownMenuSeparator />}
{/* Bottom section: separator + other agents */}
{otherAgents.length > 0 && (
<>
<DropdownMenuSeparator className="mx-1.5" />
<div className="px-1.5 pb-1.5">
{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 (
<div
key={agent.id}
className="flex items-center justify-between gap-3 px-2 py-1.5 opacity-40"
>
<div className="flex items-center gap-1.5">
{icon && (
<img
src={icon}
alt={agent.name}
className={cn('h-3.5 w-3.5', needsInvert && 'dark:invert')}
/>
)}
<span className="text-xs text-muted-foreground">{agent.name}</span>
</div>
<span className="text-xs text-muted-foreground">Setup required</span>
</div>
)
}
// Installed - show Switch button
return (
<div
key={agent.id}
role="button"
tabIndex={0}
className="flex items-center justify-between gap-3 px-2 py-1.5 hover:bg-accent/50 rounded-sm cursor-pointer transition-colors"
onClick={() => handleAgentSelect(agent.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
handleAgentSelect(agent.id)
if (!isInstalled) {
// Not installed - show disabled state
return (
<div
key={agent.id}
className="flex items-center justify-between gap-3 px-2 py-1.5 opacity-40"
>
<div className="flex items-center gap-1.5">
{icon && (
<img
src={icon}
alt={agent.name}
className={cn('h-3.5 w-3.5', needsInvert && 'dark:invert')}
/>
)}
<span className="text-xs text-muted-foreground">{agent.name}</span>
</div>
<span className="text-xs text-muted-foreground">Setup required</span>
</div>
)
}
}}
>
<div className="flex items-center gap-1.5">
{icon && (
<img
src={icon}
alt={agent.name}
className={cn('h-3.5 w-3.5', needsInvert && 'dark:invert')}
/>
)}
<span className="text-xs text-muted-foreground">{agent.name}</span>
</div>
<span className="text-xs px-2 py-0.5 rounded bg-muted text-muted-foreground hover:text-foreground transition-colors">
Switch
</span>
// Installed - show Switch button
return (
<div
key={agent.id}
role="button"
tabIndex={0}
className="flex items-center justify-between gap-3 px-2 py-1.5 hover:bg-accent/50 rounded-sm cursor-pointer transition-colors"
onClick={() => handleAgentSelect(agent.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
handleAgentSelect(agent.id)
}
}}
>
<div className="flex items-center gap-1.5">
{icon && (
<img
src={icon}
alt={agent.name}
className={cn('h-3.5 w-3.5', needsInvert && 'dark:invert')}
/>
)}
<span className="text-xs text-muted-foreground">{agent.name}</span>
</div>
<span className="text-xs px-2 py-0.5 rounded bg-muted text-muted-foreground hover:text-foreground transition-colors">
Switch
</span>
</div>
)
})}
</div>
)
})}
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)

View File

@@ -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}
/>
)}
</div>

View File

@@ -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({
<DropdownMenuContent
side="top"
align="start"
className="min-w-[160px] max-h-[300px] overflow-y-auto p-1.5"
className="min-w-[160px] max-h-[300px] overflow-y-auto p-1.5 data-[state=open]:animate-none data-[state=closed]:animate-none"
onCloseAutoFocus={(e) => e.preventDefault()}
>
{modeState.availableModes.map((mode) => {
const isSelected = mode.id === modeState.currentModeId

View File

@@ -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);