From 611c6aab76d452a502ef7053ff600d9209a61abe Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 29 Jul 2026 21:26:10 -0500 Subject: [PATCH 1/4] feat(desktop): highlight matched letters in searchable pickers Typing in the model picker dialog, edit-models dialog, or command palette now marks WHY each row matched: every occurrence of the query (per-term for the palette's AND matcher) renders as an accent-colored semantic . cmdk's scorer exposes no match ranges, so the shared HighlightMatches primitive mirrors each surface's own filter semantics instead: literal substring for the pickers, split-on-whitespace terms with merged overlapping ranges for the palette. --- .../desktop/src/app/command-palette/index.tsx | 12 ++- apps/desktop/src/components/model-picker.tsx | 5 +- .../components/model-visibility-dialog.tsx | 7 +- .../components/ui/highlight-matches.test.tsx | 45 +++++++++ .../src/components/ui/highlight-matches.tsx | 91 +++++++++++++++++++ 5 files changed, 155 insertions(+), 5 deletions(-) create mode 100644 apps/desktop/src/components/ui/highlight-matches.test.tsx create mode 100644 apps/desktop/src/components/ui/highlight-matches.tsx diff --git a/apps/desktop/src/app/command-palette/index.tsx b/apps/desktop/src/app/command-palette/index.tsx index d53b3199dc11e..a0279c495ee0f 100644 --- a/apps/desktop/src/app/command-palette/index.tsx +++ b/apps/desktop/src/app/command-palette/index.tsx @@ -7,6 +7,7 @@ import { useNavigate } from 'react-router-dom' import { HUD_HEADING, HUD_ITEM, HUD_POSITION, HUD_SURFACE, HUD_TEXT } from '@/app/floating-hud' import { setTerminalTakeover } from '@/app/right-sidebar/store' import { Command, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command' +import { HighlightMatches } from '@/components/ui/highlight-matches' import { KbdCombo } from '@/components/ui/kbd' import { getHermesConfigRecord, listAllProfileSessions } from '@/hermes' import { useI18n } from '@/i18n' @@ -233,12 +234,14 @@ const PaletteRow = memo(function PaletteRow({ bindings, item, onSelectMods, - onSelectItem + onSelectItem, + search }: { bindings: Record item: PaletteItem onSelectMods: (event: { ctrlKey: boolean; metaKey: boolean; shiftKey: boolean }) => void onSelectItem: (item: PaletteItem) => void + search: string }) { const Icon = item.icon const combo = item.action ? bindings[item.action]?.[0] : undefined @@ -252,7 +255,11 @@ const PaletteRow = memo(function PaletteRow({ value={paletteValue(item)} > - {item.label} + + {/* Same per-term split as scoreItem's AND matcher, so the emphasis + shows exactly which words earned the row its rank. */} + + {item.detail && {item.detail}} {combo && } {item.to && } @@ -1078,6 +1085,7 @@ export function CommandPalette() { key={item.id} onSelectItem={handleSelect} onSelectMods={noteSelectMods} + search={search} /> ))} diff --git a/apps/desktop/src/components/model-picker.tsx b/apps/desktop/src/components/model-picker.tsx index b38630d7e9b03..c013bf9b4c37b 100644 --- a/apps/desktop/src/components/model-picker.tsx +++ b/apps/desktop/src/components/model-picker.tsx @@ -16,6 +16,7 @@ import { InlineNotice } from './notifications' import { Button } from './ui/button' import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from './ui/command' import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog' +import { HighlightMatches } from './ui/highlight-matches' import { Skeleton } from './ui/skeleton' interface ModelPickerDialogProps { @@ -225,7 +226,9 @@ function ModelResults({ }} value={`${provider.slug}:${model}`} > - {model} + + + {locked && ( {copy.pro} )} diff --git a/apps/desktop/src/components/model-visibility-dialog.tsx b/apps/desktop/src/components/model-visibility-dialog.tsx index 1ab38d51bd479..cc4d58bd11d82 100644 --- a/apps/desktop/src/components/model-visibility-dialog.tsx +++ b/apps/desktop/src/components/model-visibility-dialog.tsx @@ -7,6 +7,7 @@ import { Checkbox } from '@/components/ui/checkbox' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { DisclosureCaret } from '@/components/ui/disclosure-caret' import { GlyphSpinner } from '@/components/ui/glyph-spinner' +import { HighlightMatches } from '@/components/ui/highlight-matches' import { Switch } from '@/components/ui/switch' import type { HermesGateway } from '@/hermes' import { useI18n } from '@/i18n' @@ -125,7 +126,9 @@ export function ModelVisibilityDialog({ onClick={() => toggleCollapsedProvider(provider.slug)} type="button" > - {provider.name} + + + - {name} + {tag ? {tag} : null} Array.from(container.querySelectorAll('mark')).map(m => m.textContent) + +describe('HighlightMatches', () => { + it('wraps every case-insensitive occurrence in a without altering the text', () => { + const { container } = render() + + expect(container.textContent).toBe('Grok 4.5 Retro') + expect(marksOf(container)).toEqual(['ro', 'ro']) + }) + + it('renders plain text when the query is empty or does not occur in this label', () => { + // Non-occurrence matters: rows can match the filter on id/slug while the + // display label contains no occurrence — must not crash or mis-mark. + for (const query of ['', ' ', 'zzz']) { + const { container } = render() + + expect(container.textContent).toBe('Fable 5') + expect(container.querySelector('mark')).toBeNull() + } + }) + + it('normalizes the query the same way the pickers filter (trim + lowercase)', () => { + const { container } = render() + + expect(marksOf(container)).toEqual(['grok']) + }) + + it('marks every term of a multi-term query (palette AND semantics)', () => { + const { container } = render() + + expect(container.textContent).toBe('Open Settings') + expect(marksOf(container)).toEqual(['Open', 'Set']) + }) + + it('merges overlapping and adjacent term ranges into one mark', () => { + const { container } = render() + + expect(marksOf(container)).toEqual(['Grok']) + }) +}) diff --git a/apps/desktop/src/components/ui/highlight-matches.tsx b/apps/desktop/src/components/ui/highlight-matches.tsx new file mode 100644 index 0000000000000..2d05ebc06678c --- /dev/null +++ b/apps/desktop/src/components/ui/highlight-matches.tsx @@ -0,0 +1,91 @@ +import type { ReactNode } from 'react' + +import { normalize } from '@/lib/text' +import { cn } from '@/lib/utils' + +/** + * Emphasize every case-insensitive occurrence of `query` inside `text` — the + * "why is this row in my filtered list" affordance for searchable pickers. + * Renders semantic ``s (screen readers announce them as highlighted) + * restyled to the accent token instead of the browser's yellow slab, so the + * emphasis reads as text hierarchy, not a highlighter pen. + * + * The query must mirror the surface's OWN filter semantics, or the emphasis + * lies about why a row matched: + * - a string for literal-substring filters (the model pickers) — spaces and + * all, exactly what `.includes()` saw; + * - a string[] for per-term AND matchers (the command palette) — every term + * is marked wherever it occurs, overlapping/adjacent ranges merged. + */ +export function HighlightMatches({ + className, + query, + text +}: { + className?: string + query: string | string[] + text: string +}) { + const terms = (Array.isArray(query) ? query : [query]).map(normalize).filter(Boolean) + + if (terms.length === 0) { + return <>{text} + } + + const ranges = matchRanges(text.toLowerCase(), terms) + + if (ranges.length === 0) { + // No occurrence (the row matched on its id/slug/keywords, not this label). + return <>{text} + } + + const parts: ReactNode[] = [] + let cursor = 0 + + for (const [start, end] of ranges) { + if (start > cursor) { + parts.push(text.slice(cursor, start)) + } + + parts.push( + + {text.slice(start, end)} + + ) + + cursor = end + } + + if (cursor < text.length) { + parts.push(text.slice(cursor)) + } + + return <>{parts} +} + +/** All occurrences of every term in `lower`, as sorted, merged [start, end). */ +function matchRanges(lower: string, terms: string[]): Array<[number, number]> { + const raw: Array<[number, number]> = [] + + for (const term of terms) { + for (let index = lower.indexOf(term); index >= 0; index = lower.indexOf(term, index + 1)) { + raw.push([index, index + term.length]) + } + } + + raw.sort((a, b) => a[0] - b[0] || a[1] - b[1]) + + const merged: Array<[number, number]> = [] + + for (const [start, end] of raw) { + const last = merged[merged.length - 1] + + if (last && start <= last[1]) { + last[1] = Math.max(last[1], end) + } else { + merged.push([start, end]) + } + } + + return merged +} From 33bbf24a625009413c44c7682f28993a5c2f35d8 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 29 Jul 2026 21:26:22 -0500 Subject: [PATCH 2/4] fix(desktop): make the composer model dropdown's search commit honestly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes to the pill dropdown's filter, following the consensus across VS Code, Zed, Open WebUI, and Cherry Studio: - The pinned current model no longer rides along on a query it doesn't match. It sat above the real matches looking like the top result, so typing 'grok' and committing landed you back on the current model. - Enter in the search field commits the first visible match (VS Code's 'so Enter works without pressing DownArrow first'). Radix highlights nothing until an arrow key, so Enter used to dead-end; now open → type → Enter is the whole switch. Matched letters also render through HighlightMatches like the other searchable pickers. --- .../src/app/shell/model-menu-panel.test.tsx | 69 ++++++++++++++++++- .../src/app/shell/model-menu-panel.tsx | 44 ++++++++++-- 2 files changed, 106 insertions(+), 7 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 7da7c4af3d320..8d4512925b8c7 100644 --- a/apps/desktop/src/app/shell/model-menu-panel.test.tsx +++ b/apps/desktop/src/app/shell/model-menu-panel.test.tsx @@ -144,6 +144,65 @@ describe('ModelMenuPanel current selection', () => { }) }) +describe('ModelMenuPanel search', () => { + // The pinned current model must NOT ride along on a query it doesn't match: + // it reads like the top result, so Enter/click picks the wrong model (the + // "type grok, get fable" bug). Every surveyed picker (VS Code, Zed, Open + // WebUI, Cherry Studio) drops the pin while filtering. + // Highlighted labels are split across nodes, so single-text-node + // queries miss them — match on the row span's composed textContent. + const rowWithText = (content: ReturnType['content'], pattern: RegExp) => + content.queryByText((_, element) => element?.tagName === 'SPAN' && pattern.test(element.textContent ?? '')) + + it('hides the non-matching current model while a query is active', async () => { + $currentProvider.set('deepseek') + $currentModel.set('deepseek-v4-pro') + const { content } = renderPanel() + + await content.findByText(/Deepseek V4 Pro/i) + + 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() + }) + expect(rowWithText(content, /Deepseek V4 Pro/i)).toBeNull() + }) + + it('Enter in the search field commits the first match', 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() + }) + + fireEvent.keyDown(input, { key: 'Enter' }) + + // First matching family of the first (alphabetical) matching provider. + await vi.waitFor(() => { + expect(onSelectModel).toHaveBeenCalledWith({ model: 'gemini-3.1-pro', provider: 'google', sessionId: 'runtime-1' }) + }) + }) + + it('Enter with no matches is a no-op (menu stays put, nothing selected)', async () => { + const { content, onSelectModel } = renderPanel() + + await content.findByText('DeepSeek') + + const input = screen.getByRole('textbox', { name: 'Search models' }) + fireEvent.change(input, { target: { value: 'zzz-no-such-model' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + + expect(onSelectModel).not.toHaveBeenCalled() + }) +}) + describe('ModelMenuPanel provider collapse', () => { it('shows all provider models by default (none collapsed)', async () => { const { content } = renderPanel() @@ -205,9 +264,15 @@ describe('ModelMenuPanel provider collapse', () => { expect(input).not.toBeNull() fireEvent.change(input, { target: { value: 'deepseek' } }) - // Should show models — search bypasses collapse + // Should show models — search bypasses collapse. The matched letters render + // inside a , splitting the label across nodes, so match on the row + // span's composed textContent instead of a single text node. await vi.waitFor(() => { - expect(content.queryByText('Deepseek V4 Pro')).not.toBeNull() + expect( + content.queryByText( + (_, element) => element?.tagName === 'SPAN' && (element.textContent ?? '').startsWith('Deepseek V4 Pro') + ) + ).not.toBeNull() }) }) diff --git a/apps/desktop/src/app/shell/model-menu-panel.tsx b/apps/desktop/src/app/shell/model-menu-panel.tsx index 8ca63f5afd6d8..5ec34d61be422 100644 --- a/apps/desktop/src/app/shell/model-menu-panel.tsx +++ b/apps/desktop/src/app/shell/model-menu-panel.tsx @@ -16,6 +16,7 @@ import { DropdownMenuSub, DropdownMenuSubTrigger } from '@/components/ui/dropdown-menu' +import { HighlightMatches } from '@/components/ui/highlight-matches' import { Skeleton } from '@/components/ui/skeleton' import type { HermesGateway } from '@/hermes' import { useI18n } from '@/i18n' @@ -212,9 +213,38 @@ 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] + + if (!family) { + return + } + + void selectFamily(family, group.provider) + closeMenu() + } + return ( <> - + { + if (event.key === 'Enter' && normalize(search)) { + event.preventDefault() + event.stopPropagation() + commitFirstMatch() + } + }} + onValueChange={setSearch} + placeholder={copy.search} + value={search} + /> @@ -258,7 +288,9 @@ export function ModelMenuPanel({ gateway, onSelectModel, profile = 'default', re }} textValue="" > - {group.provider.name} + + + - {name} + {meta ? {meta} : null} {isCurrent ? ( @@ -452,9 +484,11 @@ function groupModels( // Always include the active model — but keep every row in the provider's // stable curated order (filter `allFamilies`, never reorder), so selecting - // a model can't shuffle the list. + // a model can't shuffle the list. While SEARCHING, the pin is skipped: a + // query means "show me matches", and a pinned non-match sitting above them + // reads like the top result (type "grok", see the current Fable first). const activeId = - provider.slug === current.provider && current.model + !q && provider.slug === current.provider && current.model ? allFamilies.find(family => family.id === current.model || family.fastId === current.model)?.id : undefined From 789d69271aa5ea7b6b51bc7c62ef2f0ce1377376 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 29 Jul 2026 21:26:22 -0500 Subject: [PATCH 3/4] =?UTF-8?q?feat(desktop):=20=E2=8C=98=E2=87=A7M=20open?= =?UTF-8?q?s=20the=20model=20menu=20on=20the=20pane=20under=20the=20pointe?= =?UTF-8?q?r?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit composer.modelPicker shipped unbound and opened the full-screen picker dialog. It now defaults to ⌘⇧M — the chord LibreChat, Open WebUI, and Cherry Studio independently converged on — and toggles the composer pill's live dropdown instead, search field ready: ⌘⇧M → 'gr' → Enter switches model in two keypresses. Routing follows the tab-verb convention (#74447): the request lands on the chat surface in the hovered zone first, then the active composer, skipping hidden keep-alive tabs. With no chat surface on screen (settings, profiles) the keybind falls back to the full dialog; with the gateway closed the pill opens the dialog like a click would. --- .../src/app/chat/composer/focus.test.ts | 69 ++++++++++++++++++- apps/desktop/src/app/chat/composer/focus.ts | 42 ++++++++++- .../src/app/chat/composer/model-pill.tsx | 26 ++++++- apps/desktop/src/app/hooks/use-keybinds.ts | 10 ++- apps/desktop/src/lib/keybinds/actions.ts | 5 +- 5 files changed, 146 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/app/chat/composer/focus.test.ts b/apps/desktop/src/app/chat/composer/focus.test.ts index bc92932bb8fea..4fd2108733617 100644 --- a/apps/desktop/src/app/chat/composer/focus.test.ts +++ b/apps/desktop/src/app/chat/composer/focus.test.ts @@ -1,12 +1,16 @@ import { afterEach, describe, expect, it } from 'vitest' +import { $hoveredTreeGroup } from '@/components/pane-shell/tree/store' + import { blurComposerInput, getActiveComposer, markActiveComposer, onComposerFocusRequest, + onComposerModelMenuRequest, releaseActiveComposer, - requestComposerFocus + requestComposerFocus, + requestModelMenuToggle } from './focus' import { RICH_INPUT_SLOT } from './rich-editor' @@ -45,6 +49,7 @@ afterEach(() => { // `activeTarget` is module-level — a case that leaves a stale claim behind // would otherwise decide the next one. markActiveComposer('main') + $hoveredTreeGroup.set(null) }) describe('blurComposerInput', () => { @@ -216,3 +221,65 @@ describe('resolveActive / keep-alive tab heal', () => { expect(getActiveComposer()).toBe('edit') }) }) + +/** A chat surface inside a layout zone, mirroring ChatView-in-tree-group. */ +function mountZonedSurface(target: string, zone: string, hidden = false) { + const group = document.createElement('div') + group.dataset.treeGroup = zone + const layer = document.createElement('div') + layer.toggleAttribute('data-pane-hidden', hidden) + const surface = document.createElement('div') + surface.dataset.composerTarget = target + layer.append(surface) + group.append(layer) + document.body.append(group) + + return surface +} + +const collectModelMenuTargets = async (): Promise => { + const saw: string[] = [] + const off = onComposerModelMenuRequest(target => saw.push(target)) + + await new Promise(resolve => window.setTimeout(resolve, 0)) + off() + + return saw +} + +describe('requestModelMenuToggle', () => { + it('targets the pane under the pointer over the focused one (#74447 convention)', async () => { + mountZonedSurface('main', 'zone-a') + mountZonedSurface('tile:hovered', 'zone-b') + markActiveComposer('main') + $hoveredTreeGroup.set('zone-b') + + expect(requestModelMenuToggle()).toBe(true) + expect(await collectModelMenuTargets()).toEqual(['tile:hovered']) + }) + + it('falls back to the active composer when the pointer is off every zone', async () => { + mountZonedSurface('main', 'zone-a') + mountZonedSurface('tile:other', 'zone-b') + markActiveComposer('tile:other') + + expect(requestModelMenuToggle()).toBe(true) + expect(await collectModelMenuTargets()).toEqual(['tile:other']) + }) + + it('skips a hidden keep-alive tab in the hovered zone (targets its visible sibling)', async () => { + mountZonedSurface('main', 'zone-a', true) + mountZonedSurface('tile:front', 'zone-a') + markActiveComposer('main') + $hoveredTreeGroup.set('zone-a') + + expect(requestModelMenuToggle()).toBe(true) + expect(await collectModelMenuTargets()).toEqual(['tile:front']) + }) + + it('returns false with no chat surface on screen so the caller can open the dialog', async () => { + // Settings/profiles routes: no [data-composer-target] anywhere. + expect(requestModelMenuToggle()).toBe(false) + expect(await collectModelMenuTargets()).toEqual([]) + }) +}) diff --git a/apps/desktop/src/app/chat/composer/focus.ts b/apps/desktop/src/app/chat/composer/focus.ts index 3302d0310787f..2d403edef6ef9 100644 --- a/apps/desktop/src/app/chat/composer/focus.ts +++ b/apps/desktop/src/app/chat/composer/focus.ts @@ -10,7 +10,8 @@ * steal focus from the composer effect. */ -import { queryVisible } from '@/components/pane-shell/pane-visibility' +import { queryAllVisible, queryVisible } from '@/components/pane-shell/pane-visibility' +import { $hoveredTreeGroup } from '@/components/pane-shell/tree/store' import type { InlineRefInput } from './inline-refs' import { RICH_INPUT_SLOT } from './rich-editor' @@ -42,6 +43,7 @@ const INSERT_EVENT = 'hermes:composer-insert' const INSERT_REFS_EVENT = 'hermes:composer-insert-refs' const SUBMIT_EVENT = 'hermes:composer-submit' const VOICE_TOGGLE_EVENT = 'hermes:composer-voice-toggle' +const MODEL_MENU_EVENT = 'hermes:composer-model-menu' /** Inline edit composer root — mounted only while a user bubble is being edited. */ const EDIT_COMPOSER_ROOT = '[data-slot="aui_edit-composer-root"]' @@ -258,6 +260,44 @@ export const requestVoiceToggle = (target: ComposerTarget | 'active' = 'active') export const onComposerVoiceToggleRequest = (handler: (target: ComposerTarget) => void) => subscribe<{ target: ComposerTarget }>(VOICE_TOGGLE_EVENT, ({ target }) => handler(target)) +/** The chat surface inside the zone the pointer is over, if any. Mirrors the + * tab verbs' hover-first targeting (`tabTargetGroupId`, #74447): the model + * hotkey lands in the pane you're pointing at without clicking into it first. + * Hidden keep-alive tabs are skipped like every document-wide lookup. */ +const composerTargetInHoveredZone = (): ComposerTarget | null => { + const zone = $hoveredTreeGroup.get() + + if (!zone || typeof document === 'undefined') { + return null + } + + const surface = queryAllVisible('[data-composer-target]').find( + el => el.closest('[data-tree-group]')?.dataset.treeGroup === zone + ) + + return (surface?.dataset.composerTarget as ComposerTarget | undefined) ?? null +} + +/** Toggle ONE composer's model menu — the `composer.modelPicker` hotkey. + * Targets the pane under the pointer first (the tab-verb convention), then + * the active composer. Returns false when no chat surface is on screen at + * all (settings, profiles…), so the caller can fall back to the full + * model-picker dialog instead of dispatching into the void. */ +export const requestModelMenuToggle = (): boolean => { + if (typeof document !== 'undefined' && !queryVisible('[data-composer-target]')) { + return false + } + + dispatch<{ target: ComposerTarget }>(MODEL_MENU_EVENT, { + target: composerTargetInHoveredZone() ?? resolveActive() + }) + + return true +} + +export const onComposerModelMenuRequest = (handler: (target: ComposerTarget) => void) => + subscribe<{ target: ComposerTarget }>(MODEL_MENU_EVENT, ({ target }) => handler(target)) + /** * Focus a composer input across React commit + browser focus restore. * diff --git a/apps/desktop/src/app/chat/composer/model-pill.tsx b/apps/desktop/src/app/chat/composer/model-pill.tsx index 2a26db4094c9f..26fbe0266afed 100644 --- a/apps/desktop/src/app/chat/composer/model-pill.tsx +++ b/apps/desktop/src/app/chat/composer/model-pill.tsx @@ -1,5 +1,5 @@ import { useStore } from '@nanostores/react' -import { useState } from 'react' +import { useEffect, useState } from 'react' import { useSessionView } from '@/app/chat/session-view' import { ModelMenuCloseContext } from '@/app/shell/model-menu-panel' @@ -13,6 +13,8 @@ import { formatModelStatusLabel } from '@/lib/model-status-label' import { cn } from '@/lib/utils' import { $currentModelSource, $defaultReasoningEffort, setModelPickerOpen } from '@/store/session' +import { onComposerModelMenuRequest } from './focus' +import { useComposerScope } from './scope' import type { ChatBarState } from './types' const PILL = cn( @@ -51,6 +53,28 @@ export function ModelPill({ const defaultEffort = useStore($defaultReasoningEffort) const runtimeId = useStore(view.$runtimeId) const [open, setOpen] = useState(false) + const scope = useComposerScope() + const hasLiveMenu = Boolean(model.modelMenuContent) + + // The `composer.modelPicker` hotkey, routed to exactly one surface (the pane + // under the pointer, else the active composer — see requestModelMenuToggle). + // Toggles the live dropdown; with no live menu (gateway closed) it opens the + // full picker dialog, same as clicking the pill. + useEffect( + () => + onComposerModelMenuRequest(target => { + if (target !== scope.target || disabled) { + return + } + + if (hasLiveMenu) { + setOpen(prev => !prev) + } else { + setModelPickerOpen(true) + } + }), + [scope.target, disabled, hasLiveMenu] + ) // The composer pick is sticky: a manual selection is pinned and every NEW // chat uses it instead of the Settings → Model default — silently, which has diff --git a/apps/desktop/src/app/hooks/use-keybinds.ts b/apps/desktop/src/app/hooks/use-keybinds.ts index e1f73b0334475..07ac0384e630f 100644 --- a/apps/desktop/src/app/hooks/use-keybinds.ts +++ b/apps/desktop/src/app/hooks/use-keybinds.ts @@ -52,7 +52,7 @@ import { toggleStatusbarVisible } from '@/store/statusbar-prefs' import { openNewWindow } from '@/store/windows' import { useTheme } from '@/themes/context' -import { requestComposerFocus, requestVoiceToggle } from '../chat/composer/focus' +import { requestComposerFocus, requestModelMenuToggle, requestVoiceToggle } from '../chat/composer/focus' import { openSession } from '../open-session' import { AGENTS_ROUTE, @@ -134,7 +134,13 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void { 'keybinds.openPanel': () => navigate(`${SETTINGS_ROUTE}?tab=keybinds`), 'composer.focus': () => requestComposerFocus('active'), - 'composer.modelPicker': () => setModelPickerOpen(true), + // Toggle the composer pill's live model dropdown (pane under the pointer, + // else active composer); no chat surface on screen → the full dialog. + 'composer.modelPicker': () => { + if (!requestModelMenuToggle()) { + setModelPickerOpen(true) + } + }, 'composer.voice': requestVoiceToggle, 'nav.commandPalette': toggleCommandPalette, diff --git a/apps/desktop/src/lib/keybinds/actions.ts b/apps/desktop/src/lib/keybinds/actions.ts index 799d2d282c316..f4a609c202f6a 100644 --- a/apps/desktop/src/lib/keybinds/actions.ts +++ b/apps/desktop/src/lib/keybinds/actions.ts @@ -56,7 +56,10 @@ export const KEYBIND_ACTIONS: readonly KeybindActionMeta[] = [ // ── Composer ───────────────────────────────────────────────────────────── // Soft `/` / Enter focus (gated); other printables type-to-focus unbound. { id: 'composer.focus', category: 'composer', defaults: ['/', 'enter'] }, - { id: 'composer.modelPicker', category: 'composer', defaults: [] }, + // ⌘⇧M — "m" for model; the convention chat apps converged on (LibreChat, + // Open WebUI, and Cherry Studio all ship the same chord). Opens the pill's + // live dropdown on the pane under the pointer, else the active composer. + { id: 'composer.modelPicker', category: 'composer', defaults: ['mod+shift+m'] }, // Voice conversation toggle. Matches the documented `voice.record_key` // (Ctrl+B). On macOS that's literally ⌃B — distinct from the ⌘B sidebar // toggle. Off macOS `ctrl` folds to `mod`, which IS the ⌘B/Ctrl+B sidebar From b614521fa720ad2df45dee42f2073e74a4827c01 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 29 Jul 2026 22:09:13 -0500 Subject: [PATCH 4/4] 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} )