fix(desktop): pickers stop eating the keyboard and the pointer

Committing a model with Enter left focus on the pill (Radix restores it
to the trigger), so the next thing typed went nowhere instead of into
the message being written. And with the cursor parked over the list,
rows re-flowing under it as the query narrowed hover-stole the selection
mid-type — Enter then committed whatever the mouse happened to be over.

Both surfaces adopt the shared primitives: the model menu and every cmdk
list (palette, model dialog, session picker, searchable selects) go
pointer-inert until the mouse moves, and the model menu plus the command
palette hand typing focus back on close. The release defers a frame and
yields when something editable already claimed focus, so a palette
action that opens a dialog or navigates keeps its own focus.

Replaces the model menu's focus/blur highlight gate — pointer intent is
the real signal, so the keyboard highlight no longer flickers off when
Radix moves DOM focus onto a hovered row.
This commit is contained in:
Brooklyn Nicholson 2026-07-30 00:06:52 -05:00
parent 913882cdbb
commit aaf3298d61
5 changed files with 86 additions and 23 deletions

View file

@ -6,6 +6,7 @@ import { ModelMenuCloseContext } from '@/app/shell/model-menu-panel'
import { Button } from '@/components/ui/button'
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
import { releaseTypingFocus } from '@/components/ui/keyboard-first'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { ChevronDown } from '@/lib/icons'
@ -143,8 +144,19 @@ export function ModelPill({
)
}
// Closing the menu ends its claim on the keyboard: Radix restores focus to
// this pill (a toolbar button), so without the release the Enter that
// committed a model also swallows whatever you type next.
const setMenuOpen = (next: boolean) => {
setOpen(next)
if (!next) {
releaseTypingFocus()
}
}
return (
<DropdownMenu onOpenChange={setOpen} open={open}>
<DropdownMenu onOpenChange={setMenuOpen} open={open}>
<Tip label={title} side="top">
<DropdownMenuTrigger asChild>
<Button aria-label={title} className={pillClass} disabled={disabled} type="button" variant="ghost">
@ -153,7 +165,7 @@ export function ModelPill({
</DropdownMenuTrigger>
</Tip>
<DropdownMenuContent align="end" className="w-64 p-0" side="top" sideOffset={8}>
<ModelMenuCloseContext.Provider value={() => setOpen(false)}>
<ModelMenuCloseContext.Provider value={() => setMenuOpen(false)}>
{model.modelMenuContent}
</ModelMenuCloseContext.Provider>
</DropdownMenuContent>

View file

@ -5,6 +5,7 @@ import { closeActiveTab } from '@/app/chat/close-tab'
import { $terminalTakeover, setTerminalTakeover } from '@/app/right-sidebar/store'
import { closeActiveTerminal, createTerminal, cycleTerminal } from '@/app/right-sidebar/terminal/terminals'
import { activateTreeTabSlot, cycleTreeTabInFocusedZone, layoutHasRootSide } from '@/components/pane-shell/tree/store'
import { onReleaseTypingFocus } from '@/components/ui/keyboard-first'
import { findBarClaimsCombo } from '@/lib/find-in-page'
import { contributedKeybindHandler, PROFILE_SLOT_COUNT, SESSION_SLOT_COUNT } from '@/lib/keybinds/actions'
import { comboAllowedInInput, comboFromEvent, isEditableTarget } from '@/lib/keybinds/combo'
@ -224,6 +225,24 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
'profile.create': requestProfileCreate
}
// A keyboard-driven overlay closing hands typing back to the composer: Radix
// restores focus to the trigger (a toolbar button for the model pill), so
// without this the Enter that committed a model also eats the next keystroke.
// Deferred one frame and skipped when something else editable has claimed
// focus, because a palette action can legitimately open a dialog or navigate
// — the release must never steal focus from the surface it just opened.
useEffect(
() =>
onReleaseTypingFocus(() =>
requestAnimationFrame(() => {
if (!isEditableTarget(document.activeElement)) {
requestComposerFocus('active')
}
})
),
[]
)
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
// Capture mode: the next real key becomes the binding. Swallow everything

View file

@ -17,6 +17,7 @@ import {
DropdownMenuSubTrigger
} from '@/components/ui/dropdown-menu'
import { HighlightMatches } from '@/components/ui/highlight-matches'
import { usePointerQuiet } from '@/components/ui/keyboard-first'
import { Skeleton } from '@/components/ui/skeleton'
import type { HermesGateway } from '@/hermes'
import { useI18n } from '@/i18n'
@ -253,9 +254,10 @@ export function ModelMenuPanel({ gateway, onSelectModel, profile = 'default', re
)
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)
// A parked cursor is not a cursor in use: until the mouse actually moves,
// hover can't take rows out from under the keyboard (rows re-flow beneath it
// as the filter narrows). One real movement hands hover back.
const pointerQuiet = usePointerQuiet()
const currentKey = optionsProvider === 'moa' ? `moa:${optionsModel}` : `${optionsProvider}:${optionsModel}`
@ -268,7 +270,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, profile = 'default', re
)
const kbIndex = kbOverride !== null && kbOverride < kbRows.length ? kbOverride : autoIndex
const kbActiveKey = searchFocused && kbIndex >= 0 ? kbRows[kbIndex].key : null
const kbActiveKey = kbIndex >= 0 ? kbRows[kbIndex].key : null
const stepKb = (delta: -1 | 1) => {
if (kbRows.length === 0) {
@ -307,17 +309,25 @@ export function ModelMenuPanel({ gateway, onSelectModel, profile = 'default', re
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 }
// The keyboard-selected row, styled + tagged for scrollIntoView. Pointer
// suppression is NOT here — it belongs on the containers (below), so one
// class covers every row inside them.
const kbRowProps = (key: string) => {
const active = kbActiveKey === key
return {
className: cn(dropdownMenuRow, active && 'bg-(--ui-control-active-background) text-foreground'),
...(active ? { 'data-kb-active': '' } : {})
}
}
// Rows are hover-selectable, so they go inert with the pointer (usePointerQuiet).
const quietRows = pointerQuiet && 'pointer-events-none'
return (
<>
<DropdownMenuSearch
aria-label={copy.search}
onBlur={() => setSearchFocused(false)}
onFocus={() => setSearchFocused(true)}
onKeyDown={event => {
// Claim arrows and Enter from Radix so DOM focus stays in the input
// and Enter commits the highlighted row without a DownArrow first
@ -364,7 +374,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" ref={listRef}>
<div className={cn('max-h-[max(150px,30dvh)] overflow-y-auto py-0.5', quietRows)} ref={listRef}>
{groups.map(group => {
const slug = group.provider.slug
@ -487,7 +497,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, profile = 'default', re
<DropdownMenuSeparator className="mx-0" />
{shownMoaPresets.length > 0 ? (
<>
<div className={cn(quietRows)}>
<DropdownMenuLabel className={dropdownMenuSectionLabel}>MoA presets</DropdownMenuLabel>
{shownMoaPresets.map(preset => {
const isCurrentMoa = optionsProvider === 'moa' && optionsModel === preset
@ -509,7 +519,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, profile = 'default', re
)
})}
<DropdownMenuSeparator className="mx-0" />
</>
</div>
) : null}
<DropdownMenuItem

View file

@ -1,6 +1,7 @@
import { Command as CommandPrimitive } from 'cmdk'
import * as React from 'react'
import { usePointerQuiet } from '@/components/ui/keyboard-first'
import { SearchIcon } from '@/lib/icons'
import { cn } from '@/lib/utils'
@ -40,9 +41,15 @@ function CommandInput({ className, right, ...props }: CommandInputProps) {
}
function CommandList({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.List>) {
// cmdk selects on pointer-enter, so a list that opens under a parked cursor —
// or re-flows under one as the query narrows — hands the selection to
// whatever row slid beneath the mouse, and Enter commits THAT. Inert until
// the pointer actually moves (see usePointerQuiet).
const pointerQuiet = usePointerQuiet()
return (
<CommandPrimitive.List
className={cn('max-h-100 overflow-y-auto overflow-x-hidden', className)}
className={cn('max-h-100 overflow-y-auto overflow-x-hidden', pointerQuiet && 'pointer-events-none', className)}
data-slot="command-list"
{...props}
/>

View file

@ -1,5 +1,7 @@
import { atom } from 'nanostores'
import { releaseTypingFocus } from '@/components/ui/keyboard-first'
/** Whether the global command palette (Cmd/Ctrl+K) is currently open. */
export const $commandPaletteOpen = atom(false)
@ -16,19 +18,32 @@ export function openCommandPalettePage(page: string): void {
$commandPaletteOpen.set(true)
}
export function closeCommandPalette(): void {
$commandPaletteOpen.set(false)
$commandPalettePage.set(null)
}
// Closing hands the keyboard back to the composer — a hotkey-opened overlay
// owes typing focus to whatever the user was writing, not to its trigger.
// Skipped when the palette action itself moved focus (navigating to a route,
// opening a dialog): those surfaces claim focus after this runs.
function setOpen(open: boolean): void {
const wasOpen = $commandPaletteOpen.get()
export function setCommandPaletteOpen(open: boolean): void {
$commandPaletteOpen.set(open)
if (!open) {
$commandPalettePage.set(null)
if (wasOpen) {
releaseTypingFocus()
}
}
}
export function toggleCommandPalette(): void {
$commandPaletteOpen.set(!$commandPaletteOpen.get())
export function closeCommandPalette(): void {
setOpen(false)
}
export function setCommandPaletteOpen(open: boolean): void {
setOpen(open)
}
export function toggleCommandPalette(): void {
setOpen(!$commandPaletteOpen.get())
}