From 913882cdbbc21a5a7426d2c5f6a3b4765b267d1c Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 30 Jul 2026 00:06:52 -0500 Subject: [PATCH 1/2] feat(desktop): keyboard-first overlay primitives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two rules every hotkey-opened, search-driven overlay needs, in one place so each picker doesn't reinvent them: usePointerQuiet — a mouse that is merely PRESENT is not a mouse in use. A list that opens under a parked cursor, or re-flows under one as its filter narrows, fires pointerenter on whatever row slides beneath; menus that select on hover take that as intent and steal the row you typed toward. The pointer stays inert until it actually moves (or scrolls), then hover works for the rest of the overlay's life. releaseTypingFocus — dismissing the overlay ends its claim on the keyboard. Handlers subscribe once, so the primitive stays ignorant of what typing means on any given surface. --- .../src/components/ui/keyboard-first.test.ts | 69 +++++++++++++++++ .../src/components/ui/keyboard-first.ts | 75 +++++++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 apps/desktop/src/components/ui/keyboard-first.test.ts create mode 100644 apps/desktop/src/components/ui/keyboard-first.ts diff --git a/apps/desktop/src/components/ui/keyboard-first.test.ts b/apps/desktop/src/components/ui/keyboard-first.test.ts new file mode 100644 index 00000000000..326bdbf91d7 --- /dev/null +++ b/apps/desktop/src/components/ui/keyboard-first.test.ts @@ -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) + }) +}) diff --git a/apps/desktop/src/components/ui/keyboard-first.ts b/apps/desktop/src/components/ui/keyboard-first.ts new file mode 100644 index 00000000000..17253744485 --- /dev/null +++ b/apps/desktop/src/components/ui/keyboard-first.ts @@ -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) +} From aaf3298d61cf663ba5226a8cd9964c7cccb03bcf Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 30 Jul 2026 00:06:52 -0500 Subject: [PATCH 2/2] fix(desktop): pickers stop eating the keyboard and the pointer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/app/chat/composer/model-pill.tsx | 16 +++++++-- apps/desktop/src/app/hooks/use-keybinds.ts | 19 ++++++++++ .../src/app/shell/model-menu-panel.tsx | 36 ++++++++++++------- apps/desktop/src/components/ui/command.tsx | 9 ++++- apps/desktop/src/store/command-palette.ts | 29 +++++++++++---- 5 files changed, 86 insertions(+), 23 deletions(-) diff --git a/apps/desktop/src/app/chat/composer/model-pill.tsx b/apps/desktop/src/app/chat/composer/model-pill.tsx index 26fbe0266af..e41e3189286 100644 --- a/apps/desktop/src/app/chat/composer/model-pill.tsx +++ b/apps/desktop/src/app/chat/composer/model-pill.tsx @@ -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 ( - +