mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-20 15:33:54 +00:00
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.
110 lines
3.1 KiB
TypeScript
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 }
|
|
}
|