fix(dashboard): claim IME Escape before the command bar consumes it (#5481) - #7245
Conversation
UX Review (Fable 5) — ✅ PASSUX-level review of UX-Verdict: PASS Escape during IME composition now cancels the candidate list instead of killing the command bar — pure recovery of expected behavior for CJK typists, no new surface. The fix reuses the same [UX-REVIEWED] 1856b75 |
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS One-branch fix through the established claim contract ( [DESIGN-REVIEWED] 1856b75 |
First Principles Review (Fable 5) — ✅ PASSPremise-level review of All claims verified. The two First-Principles-Verdict: PASS A reported IME defect fixed with one call into the existing shared guard, at the last unguarded consumer of the named cause — nothing rides along unjustified. What this change shipsIntent: let CJK users cancel an IME candidate list with Escape without the command bar stealing the key — a FIX.
Counts run: [FIRST-PRINCIPLES-REVIEWED] 1856b75 |
Opus 4.8 Review — ✅ no blocking findingsReviewed Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
chenmingwei23
left a comment
There was a problem hiding this comment.
Tier 1 auto-approve: fix (2 files). Criteria: no conflict, no requested changes, security path denylist clean, design-doc gate clean, SAST annotations clean (Semgrep success 0 ann, CodeQL success no PR-scoped alerts), security checklist all-NO, AI reviewers green. Category: fix - claims IME Escape via ime.claimKey before the dialog's preventDefault so a composing Escape stays with the IME; adds a focused test file. Frontend keyboard-handling only.
dwu96
left a comment
There was a problem hiding this comment.
Tier 1 auto-approve: fix (2 files). Criteria: no conflict, no requested changes, security path denylist clean, design-doc gate clean, SAST annotations clean, security checklist all-NO, AI reviewers green. Category: one-branch IME claim guard on the command bar overlay's Escape handler plus a new test file — clear root cause (unconditional preventDefault ran before the composition claim), no behaviour change outside the Escape branch.
iamwhatever
left a comment
There was a problem hiding this comment.
Tier 1 auto-approve: fix (2 files). Criteria: no conflict, no requested changes, security path denylist clean, design-doc gate clean, SAST annotations clean, security checklist all-NO, AI reviewers green. Category: the command bar dialog's Escape branch now runs the shared IME claim before its unconditional preventDefault, so an Escape pressed to cancel an in-flight IME candidate list keeps its default action for the IME instead of popping the search scope or closing the bar; one production hunk in CommandBarOverlay.tsx plus a new test file, no other behaviour touched.
Summary
The command bar overlay is one of the two
handleEscape: falseEscape consumers (issue #5481). Its dialogonKeyDownEscape branch callede.preventDefault()unconditionally before the scope-pop-or-close decision, so an Escape pressed to cancel an in-flight IME candidate list (Chinese/Japanese/Korean input) was consumed by the overlay — popping the search scope or closing the bar — instead of letting the IME cancel its candidates. Modal, the other consumer, was already guarded on main (#7006).The fix is the one-branch edit the guard was designed for:
if (!ime.claimKey(e)) returnbefore thepreventDefault(). The overlay already hadconst ime = useImeGuard()in scope withbindComposition()wired on the input;ime.claimKeyowns the full decline contract (a declined composing Escape keeps its default action for the IME and is stopped from leaking to outer layers; an accepted Escape proceeds normally). The scope-pop-before-close ordering is untouched, as isuseDialogFocusTrapand the deliberatehandleEscape: falseopt-out.Also updates the stale comment at the
useDialogFocusTrapcall site, which still said Escape was left to the input's own handler — the dialog panel handler has owned it since Tab could reach the scope chip.Tests
New
CommandBarOverlay.imeEscape.test.tsx(5 tests), mirroring the coverage pattern ofuseImeGuard.documentLatch.test.tsx(#5505 lineage):compositionendgrace window still declines (fake timers hold the 50ms window open, so the test cannot flake on a loaded runner)Mutation-verified: with the claim line removed, 4 of 5 tests fail.
Gates:
npx tsc -bclean, full frontend vitest suite green (26,876 tests / 1,694 files), eslint 0 errors (the file's 2 warnings pre-exist on main verbatim), backend isort/flake8/mypy clean, black + brand gates pass. The new test file trips none of theImeEnterClaimRatchetsource-scan predicates.Accepted residual (documented, not changed)
A composition abandoned with neither
compositionendnor a blur leaves the latch set, and since the claim consumes what it declines, Escape would silently stop closing the bar until the latch recovers.bindComposition()'s blur reset is the recovery, but this input holds focus for the dialog's whole life, so blur may never fire on this surface; the backdrop click and the open/close chord remain as exits. This is the same cross-tree residualuseImeGuarddocuments — called out here rather than papered over.Why no screenshot: behavior-only keyboard fix — the Escape key's IME claim changes event handling, not any rendered pixel; there is no visual state to photograph.
Pattern harvest
The defect shape (a key branch that consumes-and-acts with no composition signal) is already ratcheted for Enter and Tab by
website/src/test/ImeEnterClaimRatchet.test.ts, but none of its eleven predicates covers an Escape branch — this bug sat in a file that already imported the guard and wiredbindComposition(), invisible to every scan.Rule candidate: an Escape twin of the ratchet's Tab rules — flag an
e.key === 'Escape'branch that callspreventDefault()and then acts (close/dismiss/scope change) with noclaim(Synthetic)?Key(between the key check and the consumption. The issue enumerated exactly twohandleEscape: falseconsumers and both are now guarded, so the ratchet rule is what keeps a third copy from growing unseen.Closes #5481