cmux NEXT composer: an outside press only closes a menu; Escape returns to the chip
Problem
With a composer menu open (the + menu, model, reasoning, permission, or folder chip), the menus closed in ways that didn't match Codex desktop or a macOS menu:
- A press outside the menu closed it and clicked whatever was under the pointer. A press on the mic started dictation, and a press in the field moved the caret. A macOS menu, and Codex's, swallows the press that dismisses it.
- Escape closed the permission, reasoning and folder menus but sent the keyboard to the prompt, not back to the chip. In Chromium, Escape from the + menu left the + button focused with no ring.
Already right on the base, and now covered by tests: a second press on a chip closes its menu, one press on another chip switches menus, and picking a mode or a level returns the keyboard to the prompt.
Change
menuPressGuard.ts(new), installed by the chat composer and the New Tab page. While a composer chip's menu is open, a primary press that lands outside any menu and outside any menu button is stopped at its target. Each press event (pointerdownthroughclick) still reaches the target, so Base UI's dismiss and the pickers' own listeners close the menu. The guard then stops propagation there, before React's root handlers, and cancels themousedownandclickdefaults so neither focus nor the caret moves. A press on another menu button passes through, so one press still switches menus.- The model picker's, the shell's and the + menu's outside-press listeners now listen in the capture phase, so they still close on a press the guard stops.
- Escape returns the keyboard to the chip, with its focus ring:
ui/Menu.tsxfocuses the menu's trigger after an Escape from a top-level popup with nofinalFocusof its own.focusAfterEscape(new,ui/escapeFocus.ts) defers this one task and skips triggers that are disconnected or inert. A pick or an outside press keeps Base UI's own return, so focus already moved elsewhere stays put and one-press switching still works.- The + menu blurs and refocuses its button so the ring shows.
- The location strip's folder picker does the same from its combobox's Escape.
The footer layout (composerRowFit.ts), the PendingChip and EffortPicker wiring from #20946, and the menu-layout lines from #20787 and #20796 are untouched.
This stacks on #21051 (chip press polish). Once #21051 lands, only the two commits above it remain.
Codex reference. Codex desktop's composer menus close on an outside press without passing it through. Escape puts focus back on the picker that opened the menu. One press on another picker switches menus.
Borrowed from AI Elements. AI Elements' menus are Radix menus. They are modal, so an outside press only dismisses, and Escape returns focus to the trigger (onCloseAutoFocus). We keep Base UI's non-modal menus so one press still switches between chips, and add the press guard and the Escape return on top.
Verified
-
webviews/test/composer-menu-dismiss.test.ts(new) uses real pointer events in Chromium and WebKit, at 760 and 300 px. It runs on a chat's composer, a new chat's and the New Tab page's, and covers:- A second press closes the menu.
- One press switches menus.
- A press on the mic or the field only closes the menu. The page's own document listeners see no
pointerdown,mousedownorclick, and the same press does reach the field once no menu is open. - Escape leaves focus on the chip, matching
:focus-visible. - A pick returns focus to the prompt.
Commit f0c587a adds the test alone; 24 of 60 fail on #21051's code. With the fix, all 60 pass, and
composer-chip-press(#21051) still passes all 50. -
The related suites pass locally per file, browser lane included: menu-focus, model-picker-, new-tab-, composer-location-, popup-, reasoning-own-control, access-chip-pending-warning and text-field-focus.
new-tab-chat-title-widthandrow-focusfail the same way on the base on this Mac. -
vp checkpasses. -
In-app: the tagged build
cg-composerat 9acd655 (CMUX_SKIP_ZIG_BUILD=1 ./scripts/reload.sh --tag cg-composer) builds. This Mac's display is locked, so the webview is hidden (visibilityState: hidden, no animation frames) and menus never finish opening or closing. Outside-press, Escape focus, the Tab order Model โ Effort and the model-chip-only check (left from #20946) couldn't be observed in the app. The browser tests above cover them; a waiver or an unlock window has been requested from cg-ui-coord.
Stills
WebKit on the gallery stage, after typing a draft. The permission menu was opened with a click, then Escape was pressed. Before: no ring, because the keyboard went to the prompt. After: the chip has its ring and the keyboard.
| Latte 760 | Mocha 760 | Latte 400 | Mocha 400 | |
|---|---|---|---|---|
| Before | ![]() | ![]() | ![]() | ![]() |
| After | ![]() | ![]() | ![]() | ![]() |
An outside press leaves no visible trace in a still: the mic in the gallery has no recording state, so the before and after frames are identical. The test above checks it through the events the page sees. For the one-press switch, see the frame strips in #21051. The behavior is unchanged here, and the test covers it on all three stages.
๐ค Generated with Claude Code







