Skip to content

refactor(dashboard): consolidate ConfirmBtn onto useDialogFocusTrap and unify the synthetic IME claim - #7006

Merged
bolichen97 merged 1 commit into
mainfrom
fix/confirmbtn-dialog-trap-ime-seam-5542
Aug 31, 2026
Merged

refactor(dashboard): consolidate ConfirmBtn onto useDialogFocusTrap and unify the synthetic IME claim#7006
bolichen97 merged 1 commit into
mainfrom
fix/confirmbtn-dialog-trap-ime-seam-5542

Conversation

@chenmingwei23

Copy link
Copy Markdown
Contributor

1. What is the problem?

Two shapes that the shared keyboard seams were built to eliminate are still spelled by hand.

ConfirmBtn's popover re-implements the dialog contract. DevFleetPage's Pull+Build confirm popover is a plain role="dialog" two-button surface, and useDialogFocusTrap -- used by 8 dialogs -- already owns Escape, the boundary-Tab ring, the IME claim on each, and focus entry. ConfirmBtn had its own document-capture listener for all of it. PR #5533 latch-guarded that hand-rolled trap rather than deleting it, because a direct hook call does not work here: the hook's focus effects key on component MOUNT and every other call site mounts when its dialog opens, whereas ConfirmBtn is a persistent component whose popover is conditionally rendered -- the effect would run once at page load against a null container.

Four React handlers hand-spell the synthetic half of an IME claim. ImeLatch.claimKey takes a NATIVE event, so a React onKeyDown claiming through it must reach into .nativeEvent and then remember e.stopPropagation() itself, because the native call cannot set React's own propagation flag (React walks that flag when dispatching to component ancestors). ArtifactPanel, MarkdownPanel, ChatSidebar's column popover and -- not listed in the issue but the same pair -- Modal's key isolation each carried that two-part spelling.

2. Why this issue matters to the user

A duplicated dialog contract drifts, and it drifts silently. The ConfirmBtn trap is the seventh copy of a shape whose IME defect (#5410, #5427) had to be fixed once per copy; the next fix to the shared hook would not reach this popover. For a CJK user the specific failure is a Tab or Escape composed into a dialog that steals focus and aborts the composition.

The synthetic pair is the same risk one level down: a later edit that drops the caller-side stopPropagation() leaves a declined key reaching an ancestor's keyboard handler, and nothing catches it -- no test asserts the second half at three of the four sites.

3. How our fix solves it

ConfirmBtn -> the shared trap. The popover is now ConfirmPopover, a component that mounts on open, so the hook's mount-keyed focus effects run at the right moment against a real container. The hand-rolled listener, its two Tab branches, its Escape branch, its useDocumentImeLatch call and the cancelRef/confirmRef boundary refs are all deleted -- the ring is derived from DOM order by the hook. What stays with the host is what is genuinely the host's: WHERE the popover sits (portal + flip geometry, outside-click and scroll dismissal) and WHAT dismissal means.

The focus-restore decision the issue asked for, made explicitly. The hook's focus-RETURN half is wrong for a trigger-anchored popover, in two independent ways: its capture is document.activeElement at mount -- which on Safari is NOT a clicked button, so the capture lands on <body> and the unmount-ordered restore runs AFTER close()'s trigger.focus() and blurs it -- and its restore is unconditional, where an outside-click dismissal must leave focus where the browser routed it (#2533). So the hook gained restoreFocus (default unchanged, all 8 existing dialogs keep today's behavior) and ConfirmBtn passes false, keeping its explicit close() restore as the single owner. This is not a stylistic choice: taking the hook's default instead breaks three DevFleetPage tests, two of which predate this PR (see section 4).

The trailing boolean flags became an options object in the same change, because the alternative spelling at the ConfirmBtn call site would have been useDialogFocusTrap(popRef, close, true, true, false). Three call sites changed shape ({ handleEscape: false } x2, { enabled: !paused }); five read identically.

One owner for the synthetic claim. claimSyntheticKey(e) on ImeLatch does the native claim and stops React's flag, so the pair has one home. useImeGuard().claimKey now delegates to it instead of carrying a second copy of the same two lines, and the four call sites became if (!latch.claimSyntheticKey(e)) return. A new ratchet rule rejects any claimKey(...nativeEvent...) outside the hook, so the pair cannot come back; the existing boundary-Tab rule learned the new spelling as an equally valid claim.

4. What tests we did

Targeted suites only (this host cannot take a full run); CI runs the rest.

  • DevFleetPage.test.tsx -- the 9 tests that pin the popover pass unchanged (portal placement, flip up/down, outside-click and Escape close, Start still fires the request, focus containment + restore after Cancel, and the three IME decline tests from fix(dashboard): ratchet boundary-Tab traps onto the shared IME latch #5533). That the pins survive untouched is the main evidence the conversion is behavior-preserving. 94 tests green.
  • Mutation-verified, three ways. (a) Neutering restoreFocus in the hook reddens the new Safari-shape test. (b) Dropping { restoreFocus: false } at the ConfirmBtn call site reddens three tests, including the two pre-existing ones that assert focus returns to the trigger -- that is the measurement behind the decision in section 3. (c) Emptying the hook's focusable ring reddens the popover's Tab-containment test, proving the new path is genuinely exercised rather than passing vacuously. (d) Reverting one call site to the hand-spelled pair reddens the new ratchet rule at that exact line.
  • New useDialogFocusTrap.restoreFocus.test.tsx (3 tests): focus still enters when the return half is off; the default still restores; the host's restore survives in the Safari shape.
  • New claimSyntheticKey tests on the shared latch: a declined key consumes the native event AND stops the synthetic flag; an accepted key is left entirely alone.
  • New ratchet fixture: the shipped pair flags, the half-dropped variant flags, Modal's variant flags, the unified call does not, a native claim does not, and prose describing the defect does not.
  • Consumers of the changed signature and the changed claim: Modal (x3 suites), DialogKeyboardIsolation, CommandBarOverlay, SideSheet, useMenuKeyboard, ConfirmDialog, ArtifactPanelCoverage, ArtifactPanel.copyIcon, AgentImportFlow, PrivacyChapter, AgentSelector.dialog, ChatSidebar.boardColumnTagFilter, useDialogFocusTrap.imeGuard, useImeGuard.documentLatch, ImeEnterClaimRatchet -- all green.
  • Gates: tsc --noEmit clean, eslint 0 errors on every touched file with the warning count for DevFleetPage.tsx unchanged at 1 (pre-existing, verified by linting the stashed base), i18n:check green with I18N_BASE_REF set the way CI sets it.
  • Live on an isolated pod (real Chromium, freshly built bundle), asserted on the DOM rather than eyeballed: aria-modal=true, portaled to <body>, data-placement=down, the shared trap put focus on Cancel, Escape dismissed the dialog, and focus returned to the trigger.

Dev Fleet Pull+Build confirm popover open on a pod, focus on Cancel

5. Any other suggestions on the work

  • The issue's option question, answered: the synthetic claim went on ImeLatch itself rather than a useDocumentImeLatch-adjacent wrapper, because one seam then serves both latch flavours and lets useImeGuard().claimKey stop carrying its own copy. The ratchet rule it needs is a rejection of the old spelling, which is independent of ImeEnterClaimRatchet has no rule for boundary-Tab traps; DevFleetPage carries an unguarded seventh instance #5475's boundary-Tab vocabulary, so it lands here rather than in that lane.
  • Modal was a fourth site the issue did not list, found by grepping for the pair rather than trusting the enumeration. Worth noting because it means the shape had already spread past the three sites review caught.
  • Left deliberately alone: ConfirmBtn's scroll-close still unmounts without rescuing focus if focus was inside the popover, where the sibling MenuBtn restores it to the trigger. It is a real asymmetry but it is today's behavior on both sides of this diff, and folding it in would have mixed a behavior change into a consolidation PR. Happy to file it.
  • The restoreFocus default keeps all 8 existing dialogs exactly as they are. If the maintainers would rather settle the Safari policy for the whole family (make the explicit trigger-anchored restore the norm and have the hook take a target ref), that is a bigger, separate change and this flag is the seam it would grow from.

Closes #5542

…ap and give the synthetic claim one owner

ConfirmBtn's Pull+Build popover in DevFleetPage hand-rolled the dialog
keyboard contract that useDialogFocusTrap already owns for 8 other
dialogs: Escape, the two boundary-Tab branches, the IME claim on each,
and focus entry. The popover is now a child component that mounts on
open, which is what the hook's mount-keyed focus effects need, and the
hand-rolled listener is gone.

The hook's focus-RETURN half is wrong for a trigger-anchored popover, so
it gained a restoreFocus flag (default unchanged). Its capture is
document.activeElement at mount, which on Safari is not a clicked button,
and its restore is unconditional where an outside-click dismissal must
leave focus where the click put it. ConfirmBtn keeps its own close()
restore and passes restoreFocus: false. The trailing booleans became an
options object so the third call shape stays readable.

Four sites claimed an IME key from a React handler by reaching into
claimKey(e.nativeEvent) and then remembering e.stopPropagation()
themselves, because the native call cannot set React's propagation flag.
claimSyntheticKey on ImeLatch owns both halves; useImeGuard().claimKey
delegates to it rather than re-spelling the split, and a new ratchet rule
rejects the hand-spelled pair.

Closes #5542
@chenmingwei23
chenmingwei23 requested a review from a team August 30, 2026 15:30
@chenmingwei23
chenmingwei23 requested a review from a team as a code owner August 30, 2026 15:30
@chenmingwei23
chenmingwei23 requested a review from buluoray August 30, 2026 15:30
@github-actions github-actions Bot added the readiness: checking Automated validation is still running label Aug 30, 2026
@github-actions

Copy link
Copy Markdown
Contributor

UX Review (Fable 5) — ✅ PASS

UX-level review of 1ad3f989d53980b0f86c688e94479763584ea61b — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

All evidence reviewed: the diff is a focus-trap consolidation with no new strings or layout; the screenshot confirms the popover renders unchanged; keyboard behavior (initial focus on Cancel, Tab ring, Escape-to-trigger, outside-click leaving focus at the click target) traces through the shared hook with parity to the removed hand-rolled trap.

UX-Verdict: PASS

Pure focus-trap re-hosting: every keyboard and dismissal behavior a user could feel is preserved verbatim, and Tab-from-outside now re-enters the popover instead of escaping it.

[UX-REVIEWED] 1ad3f98

@github-actions

Copy link
Copy Markdown
Contributor

Design Review (Fable 5) — ✅ PASS

Design-level review of 1ad3f989d53980b0f86c688e94479763584ea61b — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

Design-Verdict: PASS

Consolidates two hand-rolled keyboard contracts onto their existing shared owners, with defaults preserved, mutation-verified pins, and a ratchet closing the regression path.

Suggestions

  • The restoreFocus opt-out documents a Safari capture defect that the 8 default-true dialogs still carry; file the deferred family-wide restore-policy follow-up (section 5) so the flag doesn't quietly become the permanent answer.

[DESIGN-REVIEWED] 1ad3f98

@github-actions

Copy link
Copy Markdown
Contributor

GPT 5.6 Review — ✅ no blocking findings

GPT 5.6 completed its review of 1ad3f989d53980b0f86c688e94479763584ea61b and found no blocking issues.

This comment is updated in place on each push.

Review details

No findings.
[GPT-REVIEWED] 1ad3f98

False positive or not applicable? A repository writer can comment:
/ai-review override gpt 1ad3f989d53980b0f86c688e94479763584ea61b: <one-sentence reason>

@github-actions

Copy link
Copy Markdown
Contributor

First Principles Review (Fable 5) — ✅ PASS

Premise-level review of 1ad3f989d53980b0f86c688e94479763584ea61b — why this exists and whether the shipped surface is the smallest honest version. Updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

All verification is done. The counts hold: zero hand-spelled claimKey(.nativeEvent) pairs remain outside the sanctioned hook, claimSyntheticKey has 5 real consumers, all 12 useDialogFocusTrap call sites (9 prod + 2 test harnesses + the definition) use the new options shape, the screenshot follows the documented temp-screenshots/ PR convention, and the description's claims (fourth site found in Modal, "seventh copy", declared signature change) all check out against the diff. The one residue: ~15 core aria-modal surfaces (ShortcutsModal, UpdateFoundModal, App.tsx, AppDetailPage, etc.) still hand-roll Escape/focus-entry without any trap — a related but distinct defect this PR defers.

First-Principles-Verdict: PASS

Two hand-spelled duplicates of shared keyboard contracts are deleted at cause level, and a ratchet makes each unwritable again; every added surface has counted consumers.

What this change ships

Intent: stop the dialog/IME keyboard contract from being maintained in per-site copies that drift — a FIX (of duplication), not an ADDITION.

  1. Pull+Build confirm popover takes Escape, Tab ring, and IME claim from the shared trap; its hand-rolled listener is deleted — justified, the fix.
  2. New ConfirmPopover component so the popover mounts on open — justified enabler (hook effects key on mount).
  3. Hook gains restoreFocus opt-out, default unchanged for all 8 existing dialogs — justified; derived (3 reddened tests, Dev Fleet confirm popover does not trap focus #2533).
  4. Hook's trailing booleans become an options object; 3 call sites respelled — declared rider, justified by the third flag.
  5. ImeLatch.claimSyntheticKey owns both halves of a declined synthetic key — justified; 5 consumers counted (grep claimSyntheticKey, Modal/ArtifactPanel/MarkdownPanel/ChatSidebar/useImeGuard).
  6. Four handlers drop the hand-spelled pair; useImeGuard().claimKey delegates — justified; 0 pairs remain (grep claimKey\([^)]*\.nativeEvent).
  7. New ratchet rule rejects the old spelling; the boundary-Tab rule accepts the new one — justified guard at cause level.
  8. New tests pinning restore behavior, both claim halves, and outside-click focus — justified.
  9. PR screenshot under temp-screenshots/ — repo convention (PULL_REQUEST_TEMPLATE.md:47).

Watch

The broader cause — the dialog contract spelled by hand — has counted siblings this change defers: ~15 core aria-modal surfaces never call useDialogFocusTrap (grep aria-modal minus the 9 hook consumers: ShortcutsModal.tsx:211, UpdateFoundModal.tsx:252, App.tsx:2972, AppDetailPage.tsx:1105, …). Those hand-roll Escape/focus-entry with no Tab trap at all — a different defect than the drift fixed here, and a general fix is genuinely larger, so accepted-and-deferred.

[FIRST-PRINCIPLES-REVIEWED] 1ad3f98

@github-actions

Copy link
Copy Markdown
Contributor

Opus 4.8 Review — ✅ no blocking findings

Reviewed 1ad3f989d53980b0f86c688e94479763584ea61b — this comment is updated in place on each push.

Review details

No findings.

[OPUS-REVIEWED] 1ad3f98

Verdict parsed from the review's SHA-scoped output markers for commit 1ad3f989d53980b0f86c688e94479763584ea61b.

False positive or not applicable? A repository writer can comment:
/ai-review override fable 1ad3f989d53980b0f86c688e94479763584ea61b: <one-sentence reason>

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Dispositions for the two advisory concerns on 1ad3f989d. Both are accepted-and-deferred; neither changes code here.

Design Review - "file the deferred family-wide restore-policy follow-up so the flag doesn't quietly become the permanent answer." Accepted, and correctly aimed: the 8 default-true call sites do still carry the capture defect this PR only opted ConfirmBtn out of. I verified the trigger rather than restating it. WebKit does not focus a clicked button, so any Modal-family dialog opened BY A CLICK captures <body> at mount and returns focus to <body> on close instead of the trigger the user came from. Reachable at AddReposModal, ConnectRepoModal, RefSheet, DiagramLightbox, McpAppFrame, Modal, SideSheet, CommandBarOverlay.

Captured in this repo's local backlog as f-20260830-06 rather than as a tracker issue, per the convention that a review finding goes to the local store on a re-read clock and only reaches the tracker through an explicit promote. Filing was always the cheap half; the re-read is the part that decides whether it earns a tracker slot. If a maintainer would rather have it as an issue now, say so and I will promote it. On the substance: the honest family-wide answer is a restoreTo target ref (the host names what to return to) instead of an activeElement capture, which is a change to 8 live surfaces and does not belong in a consolidation PR.

First Principles - "~15 core aria-modal surfaces still hand-roll Escape/focus-entry without any trap." Accepted as a real and distinct defect. I verified one before recording the class: ShortcutsModal.tsx is role="dialog" aria-modal="true" with an Escape-only document listener at L211 and zero Tab handling, so a keyboard user Tabs straight out behind the overlay. aria-modal promises containment that surface does not deliver. Same shape in UpdateFoundModal, UpdateModal, OnboardingChapterShell, SlotTagPopover. Captured as f-20260830-07.

Deliberately not folded in: those surfaces need focus ENTRY and a Tab ring they have never had, which is behavior change on roughly 15 dialogs. This PR deletes duplicate implementations of a contract; adding the contract where it was never present is a different change with a different blast radius, and mixing them would make one review answer for both.

One correction to the framing, not to the finding: this PR does not defer that class so much as never include it. Issue 5542 scoped itself to the seventh COPY of the trap plus the synthetic-claim seam, whereas the untrapped surfaces are the population the trap was never applied to. That is why they need their own scope rather than a line in this one.

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running readiness: passed Eligible automated validation passed for the current revision and removed readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention labels Aug 30, 2026
@bolichen97
bolichen97 enabled auto-merge August 30, 2026 20:28

@bolichen97 bolichen97 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Full-diff maintainer review passed: change matches its stated scope, no regressions or trust-boundary weakening found, checks green and no outstanding change requests. Approving.

@bolichen97
bolichen97 merged commit 4cedffb into main Aug 31, 2026
106 of 107 checks passed
@bolichen97
bolichen97 deleted the fix/confirmbtn-dialog-trap-ime-seam-5542 branch August 31, 2026 00:28
@github-actions github-actions Bot removed the readiness: passed Eligible automated validation passed for the current revision label Aug 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Consolidate ConfirmBtn's popover onto useDialogFocusTrap and unify the document-latch synthetic-propagation split

2 participants