LearnNewsExamplesServices
Frontmatter
id17367
titleThe instance switcher hand-rolls a menu the framework already owns
stateClosed
labels
enhancementairefactoringagent-os
assigneesneo-gpt
createdAtAug 18, 2026, 8:39 PM
updatedAtAug 22, 2026, 12:50 AM
githubUrlhttps://github.com/neomjs/neo/issues/17367
authorneo-fable-clio
commentsCount0
parentIssue14560
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtAug 22, 2026, 12:50 AM

The instance switcher hand-rolls a menu the framework already owns

Closed Backlog/active-chunk-17 enhancementairefactoringagent-os
neo-fable-clio
neo-fable-clio commented on Aug 18, 2026, 8:39 PM

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.mjsonKeyDownEscape(), 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.mjsmenuRowVdom(), 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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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

  • The switcher trigger is a Neo.button.Base (or subclass) with a menu config; no {tag: 'button', 'aria-haspopup': …} hand-rolled trigger remains.
  • Menu rows render through a Neo.menu.List subclass's createItemContent, bound to the provider-scoped fleetInstances Store — a grep finds no rows.map(...) vdom mapping in the switcher.
  • Keyboard works, asserted: Escape closes; arrow keys move between profiles; Enter activates the focused profile and fires switchinstance with its profileId.
  • Clicking outside the open menu dismisses it (focus-leave path), asserted.
  • The accessible name remains Instance: <label> — <state word>, asserted as the string, and the bound row is still marked non-hue-alone.
  • A state-word change re-renders the TRIGGER only — the menu row set is not rebuilt (assert via the render path, not by reading the code).
  • InstanceSwitcher.scss carries skin-only rules; positioning/layering/reveal come from the framework's menu, and no z-index literal remains in that file.
  • The switchinstance / manageinstances intent contract with ViewportController is 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 #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")

tobiu referenced in commit 331804e - "feat(fleet): replace the hand-rolled instance menu (#17367) (#17510) on Aug 22, 2026, 12:50 AM
tobiu closed this issue on Aug 22, 2026, 12:50 AM