Composer Options Menu and Tool Profile Submenu

Scope: the options (+) menu in ControlsCluster and the submenu opened by the gear on the right of its Tools row. This page is the design and the acceptance list for both.

The options menu is the same radix dropdown as every other menu in the app — DropdownMenu / DropdownMenuContent / DropdownMenuItem / DropdownMenuSub from components/ui/dropdown-menu.tsx — on the shared menu tokens in components/chat/top-bar/menu-styles.ts: the glass MENU_PANEL frame (10px radius, 6px padding), itemCls rows (24px tall, 13px text, 6px radius, hover is the only tint), GROUP_LABEL, full-bleed MENU_SEPARATOR, and the right-aligned check column (CHECK_SLOT / CHECK_SLOT_PAD). It has no module CSS of its own. The Tool Profile submenu is a radix DropdownMenuSub whose open state React controls: the gear responds only to clicks, moving the pointer away does not close it, and outside presses, Escape, picking a profile and closing the parent menu do.

Rows

Every row is PlusMenuRow (controls/menu-pieces.tsx): a DropdownMenuItem wearing itemCls(false) plus MENU_ITEM_STATES, which maps radix's keyboard highlight, open sub-trigger and disabled attributes onto the same hover rule. Layout is the shared grammar — a 16px Solar icon column, the label, optional right-side metadata, then the check column. A checked row shows a 14px ink check-circle that pops in once when it becomes checked; every other row reserves the column with CHECK_SLOT_PAD, so labels and checks sit in the same columns as the top-bar menus. Toggles (Tools, Web Search, Sandbox, Unattended) keep the menu open by preventing radix's select; Add files and the profile rows let it close. An unavailable Sandbox is a disabled row that keeps its native title with the reason.

Reference comparison

OptionCapabilitiesDecision
Radix dropdown + submenu on the shared menu tokensControlled open state, portal, floating position with flip and collision handling, roving focus, typeahead, Escape, outside press; the same wrappers and tokens as the top-bar, Recents and tab menusAdopted; one menu grammar for the whole app
Base UI Menu with CSS-module stylingThe same behaviours, but a second menu library and a second, hand-maintained copy of the panel and row styles that drifted from the shared onesRemoved; the options menu was the only consumer
Manual fixed positioning and backdropClick state is possible, but portal ordering, automatic flip and complete keyboard/focus behaviour are missingRejected; duplicates existing infrastructure
Native Popover APITop layer and light dismissal, but lacks the menu roving-focus, submenu and positioning contractsNot adopted; would maintain two menu semantics

Target state and boundaries

State

  • profileMenuOpen is the only submenu open state.
  • Closing the parent menu clears it.
  • Profile selection continues using switchProfile.

DOM and ordering

  • Both levels render through radix portals on body.
  • The parent content is z-[200] and the submenu z-[201]; radix copies the content's z-index onto its popper wrapper.
  • Radix handles anchoring, flipping and collisions; the parent opens side="top" align="start" sideOffset={9}, the submenu align="end" sideOffset={6}.

Explicit exclusions

  • Do not change the Tools row toggle semantics.
  • Do not add dependencies or a global overlay manager.
  • Do not change profile data, the backend or other overlays.

No trust, privacy, concurrency or cancellation boundary is involved: this is local presentation only, without new persisted data or network requests.

The Tools split row

The Tools visual row is a role="none" container with two sibling, non-nested menuitems: the Tools PlusMenuRow and the gear DropdownMenuSubTrigger. Mouse and keyboard activate each independently. Tools toggles the setting and closes the profile menu; the gear only toggles the profile menu. Layout order is icon · label · gear · check column: the gear is a 22×22px button with the 14px settings-minimalistic icon, laid absolutely over a 22px slot the row reserves immediately before its check column — 10px row inset + 14px check + 8px gap = 32px from the right edge. Because every row reserves the check column, the gear never moves when Tools toggles. Hovering or focusing the gear tints the whole row like a single row (the container is a Tailwind group), and the gear itself gets a notch of ink over that fill on hover, keyboard highlight and while its submenu is open.

Radix opens submenus on pointer rest and closes them when focus moves to a sibling row; the component overrides both for this click-only gear: onPointerMove on the trigger prevents the hover-open, onFocusOutside on the submenu content prevents the focus-driven close, and onPointerDownOutside closes the submenu on any press that is not on the gear. Everything else — outside press on the parent, Escape, picking a profile, parent closure, keyboard open and close — stays radix's.

Acceptance

Production files: apps/web/components/chat/composer/controls/controls-cluster.tsx, controls/menu-pieces.tsx, components/chat/top-bar/menu-styles.ts. Regression entry: npm run check:composer-tool-profile. Compatibility: the Tools row still toggles tools, profile rows use the existing callback, and the other rows' behaviour (attach, toggles that keep the menu open, the disabled Sandbox) is unchanged.

Verification

ItemHow
Menu grammar and submenu behaviournpm run check:composer-tool-profile: radix wrappers, shared tokens, no module CSS, controlled submenu, click-only open, stays open on pointer travel, sibling ordering, gear geometry, z-order, parent closure
Type checkingnpx tsc --noEmit -p . in apps/web
Complete frontend checksnpm run check and npm run test:unit in apps/web
Visible acceptance/Applications/OpenProgram.app, the default profile and port 18100; a second instance is not acceptance evidence