Files
hermes-agent/apps/desktop/src/app/chat/composer/text-utils.test.ts
kshitijk4poor 188e52db91 fix(desktop): keep slash/@ completion menu navigable and Esc-dismissable
The desktop composer's `onKeyUp` handler unconditionally re-ran
`refreshTrigger` on every keyup, including the Arrow/Enter/Tab/Escape keys
the open-trigger `onKeyDown` branch had already fully handled. Because
`refreshTrigger` re-detects the trigger and resets the active index to 0,
this produced two bugs in the `/` (and `@`) completion popover:

- ArrowDown/ArrowUp moved the highlight on keydown, then keyup snapped it
  straight back to the top — so the user could never cycle past the first
  couple of items.
- Escape closed the menu on keydown, then keyup re-detected the still-present
  `/` and immediately reopened it — so Esc appeared to do nothing.

Fix: skip the keyup-driven refresh for the navigation/control keys while a
trigger menu is open (they never edit text, so refreshing is pointless), and
only reset the highlight in `refreshTrigger` when the detected trigger query
actually changed. Applied to both the main composer (chat/composer/index.tsx)
and the message-edit composer (assistant-ui/thread.tsx), which shared the
same bug. New `shouldSkipTriggerRefreshOnKeyUp` helper is unit-tested.
2026-06-03 11:19:07 +05:30

50 lines
1.9 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { detectTrigger, shouldSkipTriggerRefreshOnKeyUp } from './text-utils'
describe('shouldSkipTriggerRefreshOnKeyUp', () => {
it('skips the trigger refresh for nav/control keys while a menu is open', () => {
// These keys are fully handled by the open-trigger keydown branch and
// never edit text. Refreshing on their keyup resets the highlight to the
// top (breaking ArrowDown/ArrowUp cycling) and re-opens a menu Escape just
// closed — the exact bugs this guard prevents.
for (const key of ['ArrowUp', 'ArrowDown', 'Enter', 'Tab', 'Escape']) {
expect(shouldSkipTriggerRefreshOnKeyUp(key, true)).toBe(true)
}
})
it('does not skip the refresh when no trigger menu is open', () => {
for (const key of ['ArrowUp', 'ArrowDown', 'Enter', 'Tab', 'Escape']) {
expect(shouldSkipTriggerRefreshOnKeyUp(key, false)).toBe(false)
}
})
it('never skips ordinary text-editing keys, so completions still refresh', () => {
for (const key of ['a', '/', '@', ' ', 'Backspace', 'ArrowLeft', 'ArrowRight']) {
expect(shouldSkipTriggerRefreshOnKeyUp(key, true)).toBe(false)
}
})
})
describe('detectTrigger', () => {
it('detects a bare slash trigger with an empty query', () => {
expect(detectTrigger('/')).toEqual({ kind: '/', query: '', tokenLength: 1 })
})
it('detects a slash command query', () => {
expect(detectTrigger('/skill')).toEqual({ kind: '/', query: 'skill', tokenLength: 6 })
})
it('detects a bare at-mention trigger with an empty query', () => {
expect(detectTrigger('@')).toEqual({ kind: '@', query: '', tokenLength: 1 })
})
it('detects an at-mention query', () => {
expect(detectTrigger('@file')).toEqual({ kind: '@', query: 'file', tokenLength: 5 })
})
it('returns null for plain text', () => {
expect(detectTrigger('hello there')).toBeNull()
})
})