From b614521fa720ad2df45dee42f2073e74a4827c01 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 29 Jul 2026 22:09:13 -0500 Subject: [PATCH] feat(desktop): full cmdk-style keyboard selection in the model dropdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/app/shell/model-menu-panel.test.tsx | 55 +++++++ .../src/app/shell/model-menu-panel.tsx | 136 +++++++++++++++--- 2 files changed, 171 insertions(+), 20 deletions(-) diff --git a/apps/desktop/src/app/shell/model-menu-panel.test.tsx b/apps/desktop/src/app/shell/model-menu-panel.test.tsx index 8d4512925b8c7..3a0e6b25750ab 100644 --- a/apps/desktop/src/app/shell/model-menu-panel.test.tsx +++ b/apps/desktop/src/app/shell/model-menu-panel.test.tsx @@ -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', () => { diff --git a/apps/desktop/src/app/shell/model-menu-panel.tsx b/apps/desktop/src/app/shell/model-menu-panel.tsx index 5ec34d61be422..2e1ff2b3911c8 100644 --- a/apps/desktop/src/app/shell/model-menu-panel.tsx +++ b/apps/desktop/src/app/shell/model-menu-panel.tsx @@ -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( + () => [ + ...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) + // 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(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 ( <> 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} ) : ( -
+
{groups.map(group => { const slug = group.provider.slug @@ -354,7 +448,6 @@ export function ModelMenuPanel({ gateway, onSelectModel, profile = 'default', re return ( { @@ -362,6 +455,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, profile = 'default', re activate() } }} + {...kbRowProps(`${group.provider.slug}:${family.id}`)} > @@ -392,22 +486,24 @@ export function ModelMenuPanel({ gateway, onSelectModel, profile = 'default', re - {moaPresets.length > 0 ? ( + {shownMoaPresets.length > 0 ? ( <> MoA presets - {moaPresets.map(preset => { + {shownMoaPresets.map(preset => { const isCurrentMoa = optionsProvider === 'moa' && optionsModel === preset return ( { event.preventDefault() void selectMoaPreset(preset) }} + {...kbRowProps(`moa:${preset}`)} > - MoA: {preset} + + MoA: + {isCurrentMoa ? : null} )