Skip to content

fix(claude): stop the voice menu self-closing on a real click (#494)#495

Merged
rosscado merged 1 commit into
mainfrom
fix/claude-voice-menu-open-race
Jul 4, 2026
Merged

fix(claude): stop the voice menu self-closing on a real click (#494)#495
rosscado merged 1 commit into
mainfrom
fix/claude-voice-menu-open-race

Conversation

@rosscado

@rosscado rosscado commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Fixes #494 — a regression from #492: on claude.ai, a real click on the voice selector trigger did nothing (menu self-closed in the same tick it opened; zero console errors).

Root cause

Every open runs toggleMenu → refreshMenu → renderMenu. Both fetches are memory-cached (voices cache + preference map) and warm from the constructor's initial render, so they resolve in microtasks. Trusted clicks run microtask checkpoints between listener invocations, so the wipe-and-rebuild render detached the click's event.target before the document-level outside-click listener ran — this.element.contains(event.target) → false → display:none. Pre-#492 the old base refreshMenu's 100ms sleep pushed the rebuild past the bubble phase; #492 removed the sleep and left the Claude dropdown as the one surface still rebuilding instead of reconciling.

It escaped #492's Layer-4 verification because the probe used page.evaluate(el.click()) — synchronous dispatch, no microtask interleaving. Only trusted input reproduces it.

Fix

Apply the refactor's own principle to the dropdown: the trigger button and menu container are stable nodes — created once, then updated/refilled in place (replaceChildren; new updateSignInAffordance keeps the signed-out styling correct across re-renders). Duplicate defense (cleanupExistingElements) stays in the constructor. Bonus: a re-render landing while the menu is open (auth/preference refresh) now refills in place instead of tearing the open menu down.

Verification

  • Fail-first specs (ClaudeVoiceMenu-open-race.spec.ts): node identity across re-renders, open-menu survival + refill, in-place trigger label/sign-in affordance — all failed on the old render path, pass now.
  • Full gate green: npm test (tsc + Jest + Vitest).
  • Live claude.ai, trusted-click CDP probe (Playwright page.click → CDP Input.dispatchMouseEvent): old build reproduces {display:"none", ariaExpanded:"false"}; this build opens cleanly on both rounds {display:"block", items:8}, zero console errors, screenshot shows the full curated menu (shortlist, ▶ previews, HD chips, footnote, door, checkmark).

🤖 Generated with Claude Code

https://claude.ai/code/session_019NoPTGPfcHjnjkcBWZqR6C

Every open runs renderMenu via refreshMenu, whose fetches resolve from
warm in-memory caches in microtasks — which trusted clicks let run
BETWEEN the trigger's listener and the document outside-click listener.
The wipe-and-rebuild render detached the click's event.target mid-
bubble, so contains(event.target) read the opening click as "outside"
and closed the menu in the tick it opened. (Masked pre-#492 by the old
base refreshMenu's 100ms sleep; invisible to synthetic el.click(),
which dispatches without microtask checkpoints between listeners.)

The trigger and menu container are now stable nodes: created once,
updated/refilled in place (replaceChildren), duplicate defense stays in
the constructor. Verified on live claude.ai with a trusted-click CDP
probe: repro'd {display:none, aria-expanded:false} on the old build,
double-open green on this one.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_019NoPTGPfcHjnjkcBWZqR6C
@rosscado rosscado merged commit 66ecd77 into main Jul 4, 2026
2 checks passed
@rosscado rosscado deleted the fix/claude-voice-menu-open-race branch July 4, 2026 20:37
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.

Claude voice menu won't open on a real click (self-close race introduced by #492)

1 participant