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.
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
| Option | Capabilities | Decision |
|---|---|---|
| Radix dropdown + submenu on the shared menu tokens | Controlled 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 menus | Adopted; one menu grammar for the whole app |
Base UI Menu with CSS-module styling | The same behaviours, but a second menu library and a second, hand-maintained copy of the panel and row styles that drifted from the shared ones | Removed; the options menu was the only consumer |
| Manual fixed positioning and backdrop | Click state is possible, but portal ordering, automatic flip and complete keyboard/focus behaviour are missing | Rejected; duplicates existing infrastructure |
| Native Popover API | Top layer and light dismissal, but lacks the menu roving-focus, submenu and positioning contracts | Not adopted; would maintain two menu semantics |
Target state and boundaries
State
profileMenuOpenis 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 submenuz-[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 submenualign="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.
- The options panel, rows, labels, separators and checks come from
menu-styles.ts;composer.module.csshas no.plusMenu*rules. - Click the gear to open the submenu; moving the pointer over other rows does not close it; a second click on the gear does.
- The submenu renders above the parent, unaffected by the composer stacking context.
- Outside presses, Escape, profile selection and parent closure all close the submenu.
- Tools row order is
label · gear · check, neverlabel · check · gear, and the gear does not move when Tools toggles. - There is no
menuPosition,profileMenuBackdropor manually computed fixed coordinates.
Verification
| Item | How |
|---|---|
| Menu grammar and submenu behaviour | npm 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 checking | npx tsc --noEmit -p . in apps/web |
| Complete frontend checks | npm 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 |