fix(web): close composer menus when their controls hide#9541

merged1
composer-followups/hidden-label-width
composer-followups/popup-cleanup
openedSep 4, 2026byJJulius Marminge
View on GitHubTwo-way sync enabledPR last imported 3d ago

The resting composer keeps hidden controls mounted for measurement, but their portaled menus live outside the hidden wrapper. Runtime and traits menus can outlive the move into overflow; model and overflow menus can outlive the whole control cluster becoming hidden.

Close the menus and clear their stored open state when their triggers hide, without unmounting the measured controls. Revealing the controls does not reopen an old menu. Model-picker commands reveal the composer when the shortcut would otherwise target a hidden trigger.

Verification:

  • React lifecycle tests cover open, hide, reveal, reopen, and attempted opening while hidden, under Strict Mode.
  • All 145 focused tests across composer layout, measurement, menu state, toolbar layout, traits, provider state, and composer event scope pass.
  • Web typecheck passes. Targeted lint reports no errors and the same 34 existing ChatComposer warnings as the base.
  • Integrated resize/focus verification, before/after screenshots, and an interaction recording have not been performed.

Stacked on #9540. Covers web/desktop composer controls and the model-picker command entry points, including mobile-width web layouts. Native mobile, provider adapters, and connection protocols are unchanged.

Implemented with Codex through T3 Code.


Note

Low Risk UI-only composer menu and focus behavior with targeted tests; no auth, data, or API changes.

Overview Fixes orphaned portaled menus when the resting/collapsed composer keeps control triggers mounted but visually hidden for layout measurement.

Introduces useComposerMenuState, shared by the runtime mode select, traits picker, and compact overflow menu. When a trigger is marked hidden, open menus close and internal open state resets so controls do not reopen stale menus when they become visible again. Opening while hidden is not committed to the effective open state.

ChatComposer threads a composerControlsHidden flag (strip visible but measured controls not shown) into those pickers and closes the model picker if it was open when the cluster hides. openModelPicker / toggleModelPicker now expand and focus the composer when the shortcut would target a hidden trigger, then open the picker.

Adds Strict Mode lifecycle tests for hide, reveal, reopen, and open-while-hidden behavior.

Reviewed by Cursor Bugbot for commit b150cbbffb426840dc8ac7736514e3d174b09443. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Fix composer menus staying open when their controls hide

  • Adds shared hook useComposerMenuState in useComposerMenuState.ts that reports menus closed while hidden, clears stored open state, and does not auto-reopen on visibility return
  • Adopts the hook across ComposerFooterModeControls, CompactComposerControlsMenu, and TraitsPicker, replacing local menu state and hidden-transition bookkeeping
  • ChatComposer now resets an open model picker at render time when relocated controls hide, and opening/toggling the picker while controls are hidden cancels pending blur, expands the composer, restores focus, then opens the picker
  • Adds unit tests in useComposerMenuState.test.tsx covering popup close on hide, no open while hidden, and explicit reopen/close behavior

Macroscope summarized b150cbb.

Added 1 commit

  • fix(web): close composer menus when their controls hideb150cbb
Synced from GitHub

Thread transfer impact

✅ Thread transfer remains within every enforced ceiling.

ℹ️ No successful main baseline artifact is available yet. This run establishes the initial measurement.

ProviderMetricMain baselineThis PRImpactPR ceiling
CodexTotal thread wire—13.8 KiB—15.1 KiB✅
CodexThread snapshot wire—7.0 KiB—7.3 KiB✅
CodexLive turn WebSocket wire—6.7 KiB—7.8 KiB✅
CodexLive turn WebSocket decoded—58.5 KiB—66.4 KiB✅
CodexLive turn messages—10—21✅
ClaudeTotal thread wire—13.6 KiB—15.1 KiB✅
ClaudeThread snapshot wire—7.0 KiB—7.3 KiB✅
ClaudeLive turn WebSocket wire—6.6 KiB—7.8 KiB✅
ClaudeLive turn WebSocket decoded—57.9 KiB—66.4 KiB✅
ClaudeLive turn messages—10—21✅

Baseline: unavailable · PR result: b150cbb · Source CI: success

Scenario and decoded snapshot size

10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.

  • Codex decoded thread snapshot: 113.8 KiB
  • Claude decoded thread snapshot: 114.5 KiB

Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed.

CcursorBotreviewed · 1 inline threadSep 4, 2026
Synced from GitHub

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix is ON, but a cloud agent failed to start.

Reviewed by Cursor Bugbot for commit b150cbbffb426840dc8ac7736514e3d174b09443. Configure here.

……@@ -4418,6 +4429,18 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
4439+ setIsComposerFocused(true);
4440+ }
4441+ setIsComposerModelPickerOpen(true);
4442+ }, [composerControlsHidden]);
CcursorBoton line 4442Sep 4, 2026

Hidden picker shortcut can immediately close

Medium Severity

openModelPicker reveals a hidden trigger by setting isComposerFocused without the mobile expand in-flight guard. Expanding unmounts the collapsed prompt and the picker steals form focus, so onBlurCapture schedules scheduleComposerCollapseCheck. If that check runs before the popup is focused, focus flips back off, composerControlsHidden becomes true again, and the new render-time close clears isComposerModelPickerOpen. The model-picker shortcut then never stays open on narrow mobile layouts.

Additional Locations (2)

Reviewed by Cursor Bugbot for commit b150cbbffb426840dc8ac7736514e3d174b09443. Configure here.

Checked against b150cbb. This path already has a synchronous focus handoff: ModelPickerContent calls focusSearchInput() in useLayoutEffect, before the queued animation-frame collapse check can run. The search input renders unconditionally. Base UI mounts the portal through layout effects and only uses opacity while positioning an open popup, so that does not prevent input focus.

The portaled focus event reaches ChatComposer.onFocusCapture, which cancels composerBlurFrameRef. The collapse callback independently returns for composer floating-layer focus through isInsideRestingComposerControlScope.

expandMobileComposer needs its in-flight guard because it defers editor focus to an animation frame. This shortcut opens the picker, whose input focuses during layout, and does not use that deferred editor-focus path. Resolving this as a false positive; no code change.

Synced from GitHub

Approvability

Verdict: Approved at b150cbb

Macroscope's review found this PR approvable — This is a localized web composer bug fix that synchronizes portaled menu state with responsive control visibility and adds focused lifecycle coverage. Its effects are limited to menu and focus behavior, with no schema, API, data, security, billing, deployment, or default-setting changes.

You can add or adjust custom eligibility rules. Learn more.

@macroscope-app review this PR again, including approvability. Please consider the source analysis in the resolved Cursor thread.

The reported race assumes picker focus waits until after the collapse animation frame. ModelPickerContent instead focuses its always-rendered search input synchronously in a layout effect, and ChatComposer.onFocusCapture cancels the pending collapse frame. The collapse callback separately recognizes focus inside composer floating layers. Please check that ordering against the code when reassessing the finding.