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.
50 lines
1.9 KiB
TypeScript
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()
|
|
})
|
|
})
|