mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
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:
commit
98e43be8e0
7 changed files with 230 additions and 23 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
69
apps/desktop/src/components/ui/keyboard-first.test.ts
Normal file
69
apps/desktop/src/components/ui/keyboard-first.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
75
apps/desktop/src/components/ui/keyboard-first.ts
Normal file
75
apps/desktop/src/components/ui/keyboard-first.ts
Normal 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)
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue