hermes-agent/apps/desktop/src/app/chat/composer/text-utils.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

110 lines
3.1 KiB
TypeScript

import { DATA_IMAGE_URL_RE, dataUrlToBlob } from '@/lib/embedded-images'
export interface TriggerState {
kind: '@' | '/'
query: string
tokenLength: number
}
const TRIGGER_RE = /(?:^|[\s])([@/])([^\s@/]*)$/
/**
* Keys that the open-trigger keydown handler fully consumes to drive the
* completion popover (move highlight, accept, dismiss). None of them mutate
* the editor text, so re-running `refreshTrigger` on their *keyup* is both
* useless and actively harmful: `refreshTrigger` re-detects the trigger and
* resets `triggerActive` to 0 (snapping the highlight back to the top after
* every ArrowDown/ArrowUp) and re-opens a trigger that Escape just closed.
*/
const TRIGGER_NAV_KEYS: ReadonlySet<string> = new Set(['ArrowUp', 'ArrowDown', 'Enter', 'Tab', 'Escape'])
/**
* True when a keyup event should NOT trigger a completion-popover refresh.
* Only applies while a trigger menu is open and the key is one the keydown
* handler already handled — guarding against the highlight-reset / reopen race.
*/
export function shouldSkipTriggerRefreshOnKeyUp(key: string, triggerOpen: boolean): boolean {
return triggerOpen && TRIGGER_NAV_KEYS.has(key)
}
export function extractClipboardImageBlobs(clipboard: DataTransfer): Blob[] {
const blobs: Blob[] = []
const seen = new Set<Blob>()
const push = (blob: Blob | null) => {
if (!blob || blob.size === 0 || seen.has(blob)) {
return
}
seen.add(blob)
blobs.push(blob)
}
if (clipboard.items?.length) {
for (const item of clipboard.items) {
if (item.kind === 'file' && item.type.startsWith('image/')) {
push(item.getAsFile())
}
}
}
if (clipboard.files?.length) {
for (let i = 0; i < clipboard.files.length; i += 1) {
const file = clipboard.files.item(i)
if (file && file.type.startsWith('image/')) {
push(file)
}
}
}
if (blobs.length > 0) {
return blobs
}
const text = clipboard.getData('text/plain').trim()
if (DATA_IMAGE_URL_RE.test(text)) {
push(dataUrlToBlob(text))
}
if (blobs.length === 0) {
const html = clipboard.getData('text/html')
if (html) {
const matches = html.matchAll(/<img\b[^>]*?\bsrc\s*=\s*["'](data:image\/[^"']+)["']/gi)
for (const match of matches) {
push(dataUrlToBlob(match[1]))
}
}
}
return blobs
}
/** Caret-anchored text before the cursor, or null if the selection isn't a collapsed caret inside `editor`. */
export function textBeforeCaret(editor: HTMLDivElement): string | null {
const sel = window.getSelection()
const range = sel?.rangeCount ? sel.getRangeAt(0) : null
if (!range?.collapsed || !editor.contains(range.commonAncestorContainer)) {
return null
}
const before = range.cloneRange()
before.selectNodeContents(editor)
before.setEnd(range.startContainer, range.startOffset)
return before.toString()
}
export function detectTrigger(textBefore: string): TriggerState | null {
const match = TRIGGER_RE.exec(textBefore)
if (!match) {
return null
}
return { kind: match[1] as '@' | '/', query: match[2], tokenLength: 1 + match[2].length }
}