feat(desktop): full cmdk-style keyboard selection in the model dropdown

Enter-commits-first was still filter-only past the first row: arrows
handed focus to Radix's own item focus, hover fought the keyboard for
which row Enter meant, and unfiltered MoA presets sat below zero model
matches as phantom commits.

The search input now owns the whole keyboard interaction over one flat
row list that mirrors exactly what's rendered (collapse, filter,
presets included — presets filter by query now too):

- no query → selection sits on the current model, Enter just closes
- typing → first match auto-selected, ↑/↓ step with wraparound (reset
  on every keystroke), Enter commits the highlighted row
- selection scrolls into view; the highlight yields while the pointer
  is in the list so hover and keyboard never disagree about Enter
This commit is contained in:
Brooklyn Nicholson 2026-07-29 22:09:13 -05:00
parent 789d69271a
commit b614521fa7
2 changed files with 171 additions and 20 deletions

View File

@ -201,6 +201,61 @@ describe('ModelMenuPanel search', () => {
expect(onSelectModel).not.toHaveBeenCalled()
})
it('arrows move the selection without leaving the input; Enter commits the stepped row', async () => {
const { content, onSelectModel } = renderPanel()
await content.findByText('DeepSeek')
const input = screen.getByRole('textbox', { name: 'Search models' })
fireEvent.change(input, { target: { value: 'gemini' } })
await vi.waitFor(() => {
expect(rowWithText(content, /Gemini 3\.1 Pro/i)).not.toBeNull()
})
// First match auto-selected; ↓ steps to the second match.
fireEvent.keyDown(input, { key: 'ArrowDown' })
fireEvent.keyDown(input, { key: 'Enter' })
await vi.waitFor(() => {
expect(onSelectModel).toHaveBeenCalledWith({ model: 'gemini-2.5-flash', provider: 'google', sessionId: 'runtime-1' })
})
})
it('with no query the selection sits on the current model, so Enter closes without switching', async () => {
$currentProvider.set('google')
$currentModel.set('gemini-3.1-pro')
const { content, onSelectModel } = renderPanel()
await content.findByText('DeepSeek')
const input = screen.getByRole('textbox', { name: 'Search models' })
fireEvent.keyDown(input, { key: 'Enter' })
expect(onSelectModel).not.toHaveBeenCalled()
})
it('filters MoA presets by the query instead of leaving them as phantom first matches', async () => {
const { content, onSelectModel } = renderPanel()
await content.findByText('MoA: BeastMode')
const input = screen.getByRole('textbox', { name: 'Search models' })
fireEvent.change(input, { target: { value: 'beast' } })
await vi.waitFor(() => {
expect(rowWithText(content, /MoA: BeastMode/)).not.toBeNull()
})
expect(rowWithText(content, /MoA: default/)).toBeNull()
// The surviving preset IS the first row, so Enter commits it.
fireEvent.keyDown(input, { key: 'Enter' })
await vi.waitFor(() => {
expect(onSelectModel).toHaveBeenCalledWith({ model: 'BeastMode', provider: 'moa', sessionId: 'runtime-1' })
})
})
})
describe('ModelMenuPanel provider collapse', () => {

View File

@ -1,6 +1,6 @@
import { useStore } from '@nanostores/react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { createContext, useContext, useMemo, useState } from 'react'
import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'
import { useSessionView } from '@/app/chat/session-view'
import { Codicon } from '@/components/ui/codicon'
@ -213,35 +213,129 @@ export function ModelMenuPanel({ gateway, onSelectModel, profile = 'default', re
[pickerProviders, search, optionsModel, optionsProvider, effectiveVisibleModels]
)
// Enter in the search field commits the FIRST match — the VS Code pattern
// ("so Enter works without pressing DownArrow first"): ⌘⇧M → "grok" → Enter
// is the whole switch. Radix highlights nothing until an arrow key, so
// without this Enter would dead-end. Arrow-selected rows keep their own
// Enter (focus has left the input by then).
const commitFirstMatch = () => {
const group = groups[0]
const family = group?.families[0]
const q = normalize(search)
if (!family) {
// Presets are searchable rows like everything else — an unfiltered preset
// sitting under zero model matches would otherwise become the "first match"
// Enter commits.
const shownMoaPresets = useMemo(
() => (q ? moaPresets.filter(preset => `moa ${preset}`.toLowerCase().includes(q)) : moaPresets),
[moaPresets, q]
)
// ── Keyboard selection (cmdk semantics on a Radix menu) ───────────────────
// One flat list mirroring EXACTLY what's rendered (collapse, filter, presets),
// so the selection can never sit on a hidden row. The selected index is
// derived — current model with no query (Enter = close), first match while
// typing — with an arrow-key override that resets on every keystroke. Focus
// stays in the search input throughout: ⌘⇧M → type → ↑/↓ → Enter.
type KbRow =
| { family: ModelFamily; key: string; kind: 'family'; provider: ModelOptionProvider }
| { key: string; kind: 'moa'; preset: string }
const kbRows = useMemo<KbRow[]>(
() => [
...groups.flatMap(group =>
collapsedProviders.includes(group.provider.slug) && !search
? []
: group.families.map(
(family): KbRow => ({
family,
key: `${group.provider.slug}:${family.id}`,
kind: 'family',
provider: group.provider
})
)
),
...shownMoaPresets.map((preset): KbRow => ({ key: `moa:${preset}`, kind: 'moa', preset }))
],
[groups, collapsedProviders, search, shownMoaPresets]
)
const [kbOverride, setKbOverride] = useState<null | number>(null)
// Gates the keyboard highlight: hovering a row moves Radix's focus off the
// input, and two live highlights would fight over which row Enter means.
const [searchFocused, setSearchFocused] = useState(true)
const currentKey = optionsProvider === 'moa' ? `moa:${optionsModel}` : `${optionsProvider}:${optionsModel}`
const autoIndex = q
? kbRows.length > 0
? 0
: -1
: kbRows.findIndex(
row => row.key === currentKey || (row.kind === 'family' && row.family.fastId === optionsModel)
)
const kbIndex = kbOverride !== null && kbOverride < kbRows.length ? kbOverride : autoIndex
const kbActiveKey = searchFocused && kbIndex >= 0 ? kbRows[kbIndex].key : null
const stepKb = (delta: -1 | 1) => {
if (kbRows.length === 0) {
return
}
void selectFamily(family, group.provider)
const from = kbIndex >= 0 ? kbIndex : delta === 1 ? -1 : 0
setKbOverride((from + delta + kbRows.length) % kbRows.length)
}
const commitKbRow = () => {
const row = kbIndex >= 0 ? kbRows[kbIndex] : undefined
if (!row) {
return
}
if (row.kind === 'moa') {
void selectMoaPreset(row.preset)
return
}
if (row.key !== currentKey && row.family.fastId !== optionsModel) {
void selectFamily(row.family, row.provider)
}
closeMenu()
}
// Keep the selected row in view while arrowing through the scrollable list.
const listRef = useRef<HTMLDivElement>(null)
useEffect(() => {
listRef.current?.querySelector('[data-kb-active]')?.scrollIntoView({ block: 'nearest' })
}, [kbActiveKey])
const kbRowProps = (key: string) =>
kbActiveKey === key
? { className: cn(dropdownMenuRow, 'bg-(--ui-control-active-background) text-foreground'), 'data-kb-active': '' }
: { className: dropdownMenuRow }
return (
<>
<DropdownMenuSearch
aria-label={copy.search}
onBlur={() => setSearchFocused(false)}
onFocus={() => setSearchFocused(true)}
onKeyDown={event => {
if (event.key === 'Enter' && normalize(search)) {
// Claim arrows and Enter from Radix so DOM focus stays in the input
// and Enter commits the highlighted row without a DownArrow first
// (VS Code's checked-or-first pattern).
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
event.stopPropagation()
commitFirstMatch()
stepKb(event.key === 'ArrowDown' ? 1 : -1)
} else if (event.key === 'Enter') {
event.preventDefault()
event.stopPropagation()
commitKbRow()
}
}}
onValueChange={setSearch}
onValueChange={value => {
setSearch(value)
setKbOverride(null)
}}
placeholder={copy.search}
value={search}
/>
@ -270,7 +364,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, profile = 'default', re
{copy.noModels}
</DropdownMenuItem>
) : (
<div className="max-h-[max(150px,30dvh)] overflow-y-auto py-0.5">
<div className="max-h-[max(150px,30dvh)] overflow-y-auto py-0.5" ref={listRef}>
{groups.map(group => {
const slug = group.provider.slug
@ -354,7 +448,6 @@ export function ModelMenuPanel({ gateway, onSelectModel, profile = 'default', re
return (
<DropdownMenuSub key={`${group.provider.slug}:${family.id}`}>
<DropdownMenuSubTrigger
className={dropdownMenuRow}
hideChevron
onClick={activate}
onKeyDown={event => {
@ -362,6 +455,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, profile = 'default', re
activate()
}
}}
{...kbRowProps(`${group.provider.slug}:${family.id}`)}
>
<span className="min-w-0 flex-1 truncate">
<HighlightMatches query={search} text={name} />
@ -392,22 +486,24 @@ export function ModelMenuPanel({ gateway, onSelectModel, profile = 'default', re
<DropdownMenuSeparator className="mx-0" />
{moaPresets.length > 0 ? (
{shownMoaPresets.length > 0 ? (
<>
<DropdownMenuLabel className={dropdownMenuSectionLabel}>MoA presets</DropdownMenuLabel>
{moaPresets.map(preset => {
{shownMoaPresets.map(preset => {
const isCurrentMoa = optionsProvider === 'moa' && optionsModel === preset
return (
<DropdownMenuItem
className={dropdownMenuRow}
key={`moa:${preset}`}
onSelect={event => {
event.preventDefault()
void selectMoaPreset(preset)
}}
{...kbRowProps(`moa:${preset}`)}
>
<span className="min-w-0 flex-1 truncate">MoA: {preset}</span>
<span className="min-w-0 flex-1 truncate">
MoA: <HighlightMatches query={search} text={preset} />
</span>
{isCurrentMoa ? <Codicon className="ml-auto text-foreground" name="check" size="0.75rem" /> : null}
</DropdownMenuItem>
)