fix(desktop): stop Esc reopening the slash/@ menu; harden keyup guard
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.
This commit is contained in:
parent
c77c470d27
commit
49f1b9e4b4
5 changed files with 89 additions and 76 deletions
|
|
@ -44,7 +44,7 @@ import {
|
|||
renderComposerContents,
|
||||
RICH_INPUT_SLOT
|
||||
} from '@/app/chat/composer/rich-editor'
|
||||
import { detectTrigger, shouldSkipTriggerRefreshOnKeyUp, textBeforeCaret, type TriggerState } from '@/app/chat/composer/text-utils'
|
||||
import { detectTrigger, textBeforeCaret, type TriggerState } from '@/app/chat/composer/text-utils'
|
||||
import { ComposerTriggerPopover } from '@/app/chat/composer/trigger-popover'
|
||||
import { extractDroppedFiles, HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions'
|
||||
import { ClarifyTool } from '@/components/assistant-ui/clarify-tool'
|
||||
|
|
@ -873,6 +873,10 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
|
|||
const [trigger, setTrigger] = useState<TriggerState | null>(null)
|
||||
const [triggerActive, setTriggerActive] = useState(0)
|
||||
const [triggerItems, setTriggerItems] = useState<readonly Unstable_TriggerItem[]>([])
|
||||
// See index.tsx: set in keydown when the open popover consumes a nav/control
|
||||
// key so the matching keyup skips refreshTrigger (timing-immune vs reading
|
||||
// `trigger`, which keyup sees as already-null after Escape).
|
||||
const triggerKeyConsumedRef = useRef(false)
|
||||
const [triggerPlacement, setTriggerPlacement] = useState<'bottom' | 'top'>('top')
|
||||
const [focusRequestId, setFocusRequestId] = useState(0)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
|
@ -1238,6 +1242,7 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
|
|||
if (trigger && triggerItems.length > 0) {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
setTriggerActive(idx => (idx + 1) % triggerItems.length)
|
||||
|
||||
return
|
||||
|
|
@ -1245,6 +1250,7 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
|
|||
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
setTriggerActive(idx => (idx - 1 + triggerItems.length) % triggerItems.length)
|
||||
|
||||
return
|
||||
|
|
@ -1252,6 +1258,7 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
|
|||
|
||||
if (event.key === 'Enter' || event.key === 'Tab') {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
const item = triggerItems[triggerActive]
|
||||
|
||||
if (item) {
|
||||
|
|
@ -1263,6 +1270,7 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
|
|||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
closeTrigger()
|
||||
|
||||
return
|
||||
|
|
@ -1282,12 +1290,16 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
|
|||
}
|
||||
}
|
||||
|
||||
const handleKeyUp = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
// Arrow/Enter/Tab/Escape while the trigger menu is open are fully handled
|
||||
// in keydown and never edit text. Refreshing the trigger here would reset
|
||||
// the highlight to the top (breaking ArrowDown/ArrowUp) and re-open a menu
|
||||
// that Escape just closed, so skip it.
|
||||
if (shouldSkipTriggerRefreshOnKeyUp(event.key, trigger !== null)) {
|
||||
const handleKeyUp = () => {
|
||||
// If this keyup belongs to a key the open trigger popover already consumed
|
||||
// in keydown (Arrow/Enter/Tab/Escape), skip the refresh. Those keys never
|
||||
// edit text, and for Escape the keydown already closed the menu — a refresh
|
||||
// here would re-detect the still-present `/` and instantly reopen it. We
|
||||
// read a ref set during keydown rather than `trigger`, because by keyup
|
||||
// time React has re-rendered and `trigger` may already be null.
|
||||
if (triggerKeyConsumedRef.current) {
|
||||
triggerKeyConsumedRef.current = false
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue