mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-20 10:11:58 +00:00
Follow-up to #37937. That fix guarded the composer's keyup with `shouldSkipTriggerRefreshOnKeyUp(key, trigger !== null)`. The `trigger !== null` check is timing-fragile for Escape: Escape's *keydown* sets `trigger = null` and closes the menu, but in a real browser the *keyup* fires after a re-render, so the handler closure sees `trigger === null`, the guard returns false, `refreshTrigger` runs, re-detects the still-present `/` in the input, and instantly reopens the menu. (jsdom batches state synchronously so a unit test could not observe this -- only the running app does.) Replace the value-based guard with a `triggerKeyConsumedRef` set synchronously in keydown whenever the open popover consumes a nav/control key (Arrow/Enter/Tab/Escape). keyup consults and clears that ref, so it is immune to the keydown->re-render->keyup timing. Applied to both the main composer (chat/composer/index.tsx) and the message-edit composer (assistant-ui/thread.tsx). Removes the now-unused `shouldSkipTriggerRefreshOnKeyUp` helper and its unit test. The real-DOM regression test now fires keydown+keyup pairs through the ref-based handlers and asserts Esc closes and stays closed. Verified by running a production renderer build (Vite v8) under Electron against a local backend: ArrowDown/ArrowUp cycle the full list and Esc dismisses the menu without reopening.
91 lines
2.1 KiB
TypeScript
91 lines
2.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@/]*)$/
|
|
|
|
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 }
|
|
}
|