feat(picker): migrate model menus to native popup protocol
Problem
Model and effort menus are still rendered by web Base UI and the overflow host scrapes hidden DOM rows. This consumer migration makes the native WebPopupHost declarative model the production path for the compact picker and full picker.
“make native WebPopupHost sole path for every web menu/picker/popover, web describes declarative rows over GENERATED protocol/native renders choicesRPC.”
Design spec: leo-workspace design/model-picker.md (this PR keeps its settled designs; the harness rail stays the 44 pt left column, and whichever of this and #27889 lands second carries the top symbols row).
Change
- QuickModelMenu sends generated rows for models, provider/effort/account/fork/More models, search, Fast, login, Add harness, and update actions.
QuickModelMenuFallbackis the web menu as it stands on base; the browser gallery mounts it, and so does an app opening whose native show fails. - ModelPicker sends the 44 pt harness rail, search, this folder's profiles, model/favorite/auth rows, More models, Fast/update footer, and native event handling.
- Native transport owns rendering, focus, keyboard and IME behaviour. Stale events stay filtered by popup id and revision.
- Generated protocol:
webviews/src/ui/popupProtocol.schema.json→ TS + Swift (gen-popup-protocol.py --checkruns in the web check).
Carried from base (merge of feat-cmux-next)
- #27948: each model's effort is a side dropdown beside its row. Natively, a model row is an action with its levels as children: a click or Return lands the model, while its trailing item or Right opens the levels one per line, with the recommended one named.
- #28020: the Account switcher (Cmux under a Provider heading); the Chief's own-model menu; #26503: Add harness is the last item in the harness strip; #26962: No models vs no matches.
Review fixes
Sol BLOCK at 124eadaf (https://github.com/manaflow-ai/cmux/pull/27140#issuecomment-6523885343), all five findings fixed:
- 1–9 shortcuts: a digit on the list, or typed into an empty search field, lands its numbered row (before typeahead).
- Folder profiles: the full picker lists this folder's profiles natively, and picking one opens it.
- Fallback: when native
show()returns{shown:false},useNativePopupreports the transport unavailable for that opening and the consumer draws its web popup. - Return in search: keys start on the list, not the rail.
- Revision: events from an earlier revision of the same open popup still land (row IDs are stable keys).
Astra BLOCK at 8f4777ba: the generated Swift files are present, and native rail clicks pass clear=true.
Astra BLOCK at 54287f55 (https://github.com/manaflow-ai/cmux/pull/27140#issuecomment-6543038679): Up and Down stay in the list, while Left and Right on an empty field (or on a row without a side dropdown) step the rail. The native menu always keeps its search field, because the web typed/long looks give native typing no way into search.
A closed picker describes no native rows, so its catalog stays unread until the first click (model-picker-idle).
Validation
- Web (cmux-ci, Linux pool):
vp fmtclean, lint 0 warnings, typecheck passes.native-model-menu.test.tsxandmodel-picker-idle.test.tsxpass, with a regression test for each fix. Across the 93 picker/menu/popup test files, the only failures are twoclassic-new-tab-pickerstests that fail the same way on base bcec3ce42ba. - Swift (cmux-ci, fleet Mac):
NativePopupContentTests(digit shortcut, Return skips the rail, Right opens a side dropdown, rail keys),NativePopupProtocolTestsandPagePopupMotionTestspass.
Screenshots: none attached; Leo tries the build (a gallery before/after is a follow-up).
Agent session: agent session
🤖 Generated with Claude Code