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} )