Merge pull request #74602 from NousResearch/bb/pointer-intent

Keyboard-first pickers: give typing focus back, ignore a parked cursor
This commit is contained in:
brooklyn! 2026-07-30 00:41:45 -05:00 committed by GitHub
commit 98e43be8e0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 230 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}`
@ -266,7 +268,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, profile = 'default', re
: 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 kbActiveKey = kbIndex >= 0 ? kbRows[kbIndex].key : null
const stepKb = (delta: -1 | 1) => {
if (kbRows.length === 0) {
@ -305,17 +307,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
@ -362,7 +372,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
@ -485,7 +495,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
@ -507,7 +517,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

@ -0,0 +1,69 @@
import { act, renderHook } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { onReleaseTypingFocus, releaseTypingFocus, usePointerQuiet } from './keyboard-first'
const move = () => act(() => void window.dispatchEvent(new MouseEvent('mousemove', { bubbles: true })))
const scroll = () => act(() => void window.dispatchEvent(new WheelEvent('wheel', { bubbles: true })))
afterEach(() => {
vi.restoreAllMocks()
})
describe('usePointerQuiet', () => {
it('starts quiet so a list opening under a parked cursor cannot hover-select', () => {
const { result } = renderHook(() => usePointerQuiet())
expect(result.current).toBe(true)
})
it('wakes on real pointer movement and stays awake', () => {
const { result } = renderHook(() => usePointerQuiet())
move()
expect(result.current).toBe(false)
// A later re-render must not re-arm the guard mid-interaction.
move()
expect(result.current).toBe(false)
})
it('treats a scroll as a hand on the mouse', () => {
const { result } = renderHook(() => usePointerQuiet())
scroll()
expect(result.current).toBe(false)
})
it('re-arms for the next overlay (each open mounts a fresh guard)', () => {
const first = renderHook(() => usePointerQuiet())
move()
expect(first.result.current).toBe(false)
first.unmount()
expect(renderHook(() => usePointerQuiet()).result.current).toBe(true)
})
it('drops its listeners on unmount', () => {
const remove = vi.spyOn(window, 'removeEventListener')
renderHook(() => usePointerQuiet()).unmount()
expect(remove.mock.calls.map(([type]) => type)).toEqual(expect.arrayContaining(['mousemove', 'wheel']))
})
})
describe('releaseTypingFocus', () => {
it('notifies subscribers, and stops once unsubscribed', () => {
const handler = vi.fn()
const off = onReleaseTypingFocus(handler)
releaseTypingFocus()
expect(handler).toHaveBeenCalledTimes(1)
off()
releaseTypingFocus()
expect(handler).toHaveBeenCalledTimes(1)
})
})

View file

@ -0,0 +1,75 @@
import { useEffect, useState } from 'react'
/**
* KEYBOARD-FIRST OVERLAYS the shared contract for anything you open with a
* hotkey and drive from a search field: the composer model menu, the K
* palette, every cmdk picker.
*
* Both halves exist because a mouse that is merely PRESENT is not a mouse the
* user is using.
*/
const RELEASE_EVENT = 'hermes:release-typing-focus'
/**
* True while the pointer must be treated as absent.
*
* A list that opens under a parked cursor, or that re-flows under one as the
* query filters it, fires pointerenter on whatever row happens to slide beneath
* and hover-selecting menus (Radix) or hover-highlighting lists take that as
* intent, stealing the row the user typed toward. Nothing about that came from
* the user.
*
* So the pointer stays inert until it actually MOVES (or scrolls, which is also
* a hand on the mouse). One real movement hands hover back for the rest of the
* overlay's life. Spread the result as `pointer-events-none` on the list the
* blunt instrument is the right one here, because it suppresses the synthetic
* enter events at the source rather than racing them.
*
* Mount-scoped: overlays mount when they open, so "quiet until moved" needs no
* knowledge of whether a hotkey or a click opened it.
*/
export function usePointerQuiet(): boolean {
const [quiet, setQuiet] = useState(true)
useEffect(() => {
const wake = () => setQuiet(false)
const options = { capture: true } as const
window.addEventListener('mousemove', wake, options)
window.addEventListener('wheel', wake, options)
return () => {
window.removeEventListener('mousemove', wake, options)
window.removeEventListener('wheel', wake, options)
}
}, [])
return quiet
}
/**
* Hand the keyboard back to wherever the user was typing.
*
* Dismissing a keyboard-driven overlay ends its claim on focus. Radix returns
* focus to the TRIGGER on close, which for the model menu is a toolbar button
* so committing with Enter left the next keystroke going nowhere instead of
* into the message you were about to write. Call this on close; the composer
* bus (subscribed once in `useKeybinds`) does the focusing, so this primitive
* stays ignorant of what "typing" means on any given surface.
*/
export function releaseTypingFocus(): void {
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent(RELEASE_EVENT))
}
}
export function onReleaseTypingFocus(handler: () => void): () => void {
if (typeof window === 'undefined') {
return () => undefined
}
window.addEventListener(RELEASE_EVENT, handler)
return () => window.removeEventListener(RELEASE_EVENT, handler)
}

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