Context
Operator flag during the #17328 implementation review (2026-08-18): the switcher "fakes a menu button as custom vdom" and its "vdom generator fns return extremely inefficient delta updates". The flag was raised explicitly as out-of-scope for that PR, so #17328 ships the hand-rolled shape and this ticket owns the correction.
The flag is verified, not taken on trust. AgentOS.view.fleet.InstanceSwitcher composes a trigger + menu entirely as raw vdom ({tag: 'button', 'aria-haspopup': 'menu', cn: [...]}) and rebuilds vdom.cn wholesale from updateSwitcher() on every reactive change, while the framework already provides both halves.
The Problem
Two costs, one of them behavioral rather than aesthetic.
1. Missing menu behavior. The hand-rolled trigger + panel has no keyboard model and no outside-dismissal. Concretely absent today: Escape to close, arrow-key navigation between rows, Enter activation, and focus-leave dismissal (clicking anywhere else leaves the menu open). Positioning is a CSS guess (position: absolute; right: 0; z-index: 50) rather than the framework's alignment, so the panel is also clip- and stacking-fragile inside the top chrome. The switcher is the operator's scope control on every cockpit surface — a scope control a keyboard user cannot dismiss is a real gap, and the §06 design bar names WCAG discipline as design law.
2. Delta inefficiency. updateSwitcher() is the single render path for four reactive configs (boundProfileId, instanceState, instanceStore, menuOpen) plus the roster load event. Each call reassigns the whole vdom.cn, including rows.map(row => me.menuRowVdom(row)) for the entire profile list, so a state-word change re-renders every menu row. The engine's whole value proposition is surgical deltas; the cockpit that advertises it should not hand the differ a fresh subtree per keystroke of state.
The Architectural Reality
src/button/Base.mjs:342 afterSetMenu() — a menu config lazily imports menu/List.mjs and creates it floating: true, hidden: true, align: {edgeAlign: 't0-b0', target: me.id}, parentComponent: me, and chains stateProvider: {parent: <the button's provider>}. Alignment, layering, and provider inheritance all come for free.
src/menu/List.mjs — onKeyDownEscape(), onKeyDownEnter(), onFocusEnter() / onFocusLeave() with a focusTimeoutId deferral (the outside-dismissal path), sub-menu focus bubbling; arrow navigation rides list.Base's keys.
src/menu/List.mjs:187 createItemContent(record, index) — the extension seam: it returns vdom children per record, so rich rows (state dot · display name · endpoint · custodian badge) are a subclass override, not a reason to hand-roll. It is store-driven, which is the shape the data already has.
apps/agentos/store/FleetInstances.mjs + apps/agentos/model/FleetInstance.mjs — the Store and Model already exist and are provider-scoped; the switcher currently reads .items and hand-maps them to vdom instead of letting a list component render the records.
apps/agentos/view/fleet/InstanceSwitcher.mjs — menuRowVdom(), updateSwitcher(), and the domListeners triple (.fm-instance-trigger / .fm-instance-row / .fm-instance-manage) are the surface this replaces.
resources/scss/src/apps/agentos/fleet/InstanceSwitcher.scss — the hand-rolled .fm-instance-menu panel rules (absolute positioning, z-index, the reveal keyframe) become skin-only overrides on the framework's menu classes.
The Fix
AgentOS.view.fleet.InstanceMenuList extends Neo.menu.List — overrides createItemContent(record) to render the row anatomy from the FleetInstance record, and marks the bound row structurally (never hue-alone). Store-bound; no hand-mapping.
- The trigger becomes a real
Neo.button.Base carrying menu: {module: InstanceMenuList, store: <the provider-scoped fleetInstances store>, …}, so keyboard, escape, focus-leave dismissal, alignment, and floating layer arrive with the primitive.
- The accessible name survives the swap.
Instance: <label> — <state word> is the tested surface (adopted from the #17351 review round) and must remain asserted after the rewrite; the two-channel rule does not weaken because the framework renders the button.
- Delete rather than port:
menuRowVdom(), the wholesale updateSwitcher() composition of menu rows, the three domListeners, and the hand-rolled panel SCSS. What remains is the trigger's own label/state, which is the only thing that legitimately re-renders on state change.
- The "Manage instances…" terminal row becomes a menu item that fires the existing
manageinstances intent — the intent contract with ViewportController does not change, so nothing downstream of the switcher moves.
Acceptance Criteria
Out of Scope
The manage-instances drawer (InstanceManager) · the custody/rebind arc · #17264's chip-family reconciliation · any change to the profile Store/Model schema · the #17317 reveal-overlay dismissal defect (a different overlay, sharing only the symptom class).
Avoided Traps
- Do not keep the hand-rolled panel "because it works today". It works only for a pointer user who never presses a key and never clicks away; that is the trap the framework primitive exists to close.
- Do not port
menuRowVdom into createItemContent verbatim. The record is the input there — reaching back into a hand-held array would reproduce the hand-mapping this ticket removes.
- Do not widen
menu.List for this. The rich row is a subclass override; changing the shared menu primitive to suit one cockpit surface would be the wrong substrate.
Decision Record impact
none — this aligns an app surface with existing framework primitives; no ADR authority moves.
Related
#17328 (the switcher ships hand-rolled there) · PR #17365 (the shipping PR; this ticket is its named follow-up) · Epic #14560 (parent) · #17264 (chip family — the row anatomy's token owner) · #17317 (adjacent overlay-dismissal defect, different surface) · #15037 (density freeze the row anatomy respects).
Live latest-open sweep: latest 20 open checked 2026-08-18T18:35Z, no equivalent; A2A claim window (last 30 messages, all read-states) clean.
Origin Session ID: 0c7dd38c-aad2-4f22-b996-2d9c039cb7d9
Retrieval Hint: query_raw_memories("instance switcher menu.List button.menu createItemContent hand-rolled vdom")
Context
Operator flag during the #17328 implementation review (2026-08-18): the switcher "fakes a menu button as custom vdom" and its "vdom generator fns return extremely inefficient delta updates". The flag was raised explicitly as out-of-scope for that PR, so #17328 ships the hand-rolled shape and this ticket owns the correction.
The flag is verified, not taken on trust.
AgentOS.view.fleet.InstanceSwitchercomposes a trigger + menu entirely as raw vdom ({tag: 'button', 'aria-haspopup': 'menu', cn: [...]}) and rebuildsvdom.cnwholesale fromupdateSwitcher()on every reactive change, while the framework already provides both halves.The Problem
Two costs, one of them behavioral rather than aesthetic.
1. Missing menu behavior. The hand-rolled trigger + panel has no keyboard model and no outside-dismissal. Concretely absent today:
Escapeto close, arrow-key navigation between rows,Enteractivation, and focus-leave dismissal (clicking anywhere else leaves the menu open). Positioning is a CSS guess (position: absolute; right: 0; z-index: 50) rather than the framework's alignment, so the panel is also clip- and stacking-fragile inside the top chrome. The switcher is the operator's scope control on every cockpit surface — a scope control a keyboard user cannot dismiss is a real gap, and the §06 design bar names WCAG discipline as design law.2. Delta inefficiency.
updateSwitcher()is the single render path for four reactive configs (boundProfileId,instanceState,instanceStore,menuOpen) plus the rosterloadevent. Each call reassigns the wholevdom.cn, includingrows.map(row => me.menuRowVdom(row))for the entire profile list, so a state-word change re-renders every menu row. The engine's whole value proposition is surgical deltas; the cockpit that advertises it should not hand the differ a fresh subtree per keystroke of state.The Architectural Reality
src/button/Base.mjs:342afterSetMenu()— amenuconfig lazily importsmenu/List.mjsand creates itfloating: true,hidden: true,align: {edgeAlign: 't0-b0', target: me.id},parentComponent: me, and chainsstateProvider: {parent: <the button's provider>}. Alignment, layering, and provider inheritance all come for free.src/menu/List.mjs—onKeyDownEscape(),onKeyDownEnter(),onFocusEnter()/onFocusLeave()with afocusTimeoutIddeferral (the outside-dismissal path), sub-menu focus bubbling; arrow navigation rideslist.Base'skeys.src/menu/List.mjs:187createItemContent(record, index)— the extension seam: it returns vdom children per record, so rich rows (state dot · display name · endpoint · custodian badge) are a subclass override, not a reason to hand-roll. It is store-driven, which is the shape the data already has.apps/agentos/store/FleetInstances.mjs+apps/agentos/model/FleetInstance.mjs— the Store and Model already exist and are provider-scoped; the switcher currently reads.itemsand hand-maps them to vdom instead of letting a list component render the records.apps/agentos/view/fleet/InstanceSwitcher.mjs—menuRowVdom(),updateSwitcher(), and thedomListenerstriple (.fm-instance-trigger/.fm-instance-row/.fm-instance-manage) are the surface this replaces.resources/scss/src/apps/agentos/fleet/InstanceSwitcher.scss— the hand-rolled.fm-instance-menupanel rules (absolute positioning,z-index, the reveal keyframe) become skin-only overrides on the framework's menu classes.The Fix
AgentOS.view.fleet.InstanceMenuList extends Neo.menu.List— overridescreateItemContent(record)to render the row anatomy from theFleetInstancerecord, and marks the bound row structurally (never hue-alone). Store-bound; no hand-mapping.Neo.button.Basecarryingmenu: {module: InstanceMenuList, store: <the provider-scoped fleetInstances store>, …}, so keyboard, escape, focus-leave dismissal, alignment, and floating layer arrive with the primitive.Instance: <label> — <state word>is the tested surface (adopted from the #17351 review round) and must remain asserted after the rewrite; the two-channel rule does not weaken because the framework renders the button.menuRowVdom(), the wholesaleupdateSwitcher()composition of menu rows, the threedomListeners, and the hand-rolled panel SCSS. What remains is the trigger's own label/state, which is the only thing that legitimately re-renders on state change.manageinstancesintent — the intent contract withViewportControllerdoes not change, so nothing downstream of the switcher moves.Acceptance Criteria
Neo.button.Base(or subclass) with amenuconfig; no{tag: 'button', 'aria-haspopup': …}hand-rolled trigger remains.Neo.menu.Listsubclass'screateItemContent, bound to the provider-scopedfleetInstancesStore — a grep finds norows.map(...)vdom mapping in the switcher.Escapecloses; arrow keys move between profiles;Enteractivates the focused profile and firesswitchinstancewith itsprofileId.Instance: <label> — <state word>, asserted as the string, and the bound row is still marked non-hue-alone.InstanceSwitcher.scsscarries skin-only rules; positioning/layering/reveal come from the framework's menu, and noz-indexliteral remains in that file.switchinstance/manageinstancesintent contract withViewportControlleris unchanged — no controller edits required by this ticket.Out of Scope
The manage-instances drawer (
InstanceManager) · the custody/rebind arc ·#17264's chip-family reconciliation · any change to the profile Store/Model schema · the#17317reveal-overlay dismissal defect (a different overlay, sharing only the symptom class).Avoided Traps
menuRowVdomintocreateItemContentverbatim. The record is the input there — reaching back into a hand-held array would reproduce the hand-mapping this ticket removes.menu.Listfor this. The rich row is a subclass override; changing the shared menu primitive to suit one cockpit surface would be the wrong substrate.Decision Record impact
none— this aligns an app surface with existing framework primitives; no ADR authority moves.Related
#17328 (the switcher ships hand-rolled there) · PR #17365 (the shipping PR; this ticket is its named follow-up) · Epic #14560 (parent) ·
#17264(chip family — the row anatomy's token owner) ·#17317(adjacent overlay-dismissal defect, different surface) ·#15037(density freeze the row anatomy respects).Live latest-open sweep: latest 20 open checked 2026-08-18T18:35Z, no equivalent; A2A claim window (last 30 messages, all read-states) clean.
Origin Session ID: 0c7dd38c-aad2-4f22-b996-2d9c039cb7d9
Retrieval Hint:
query_raw_memories("instance switcher menu.List button.menu createItemContent hand-rolled vdom")