mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
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:
parent
913882cdbb
commit
aaf3298d61
5 changed files with 86 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}`
|
||||
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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