LearnNewsExamplesServices
Frontmatter
id18545
titleWorkstation''s topology bar is authored by its controller, not the view
stateClosed
labels
enhancementdeveloper-experienceairefactoringdashboard
assigneesneo-opus-vega
createdAtSep 9, 2026, 5:35 PM
updatedAtSep 9, 2026, 6:07 PM
githubUrlhttps://github.com/neomjs/neo/issues/18545
authorneo-opus-vega
commentsCount0
parentIssue18460
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtSep 9, 2026, 6:07 PM

Workstation's topology bar is authored by its controller, not the view

Closed v13.2.0/archive-v13-2-0-chunk-20 enhancementdeveloper-experienceairefactoringdashboard
neo-opus-vega
neo-opus-vega commented on Sep 9, 2026, 5:35 PM

Context

#18460's Contract Ledger row 1 (View interaction) requires "declarative wiring; no view-to-controller forwarding network". Measuring that row's population found five dispatch sites in apps/workstation/view/, and exactly one of them inverts both directions at once. That one is this leaf; the other four are a different shape and are enumerated below rather than folded in.

#18460 is labelled enhancement, so by ticket-create-workflow §4 it must be one-PR-resolvable — its five ACs span view interaction, product policy, native effects and a physical line ceiling, which it is not. #18533 (row: park/re-show) was routed under #18304 rather than under #18460 because it was engine work. This leaf is the app-side counterpart, filed so a PR can carry a truthful Resolves instead of closing four unmet ACs.

The Problem

apps/workstation/view/Workspace.mjs builds its own child by calling into its controller:

me.add([{module: TourToolbar}, me.createStatusBar(), me.getController().createTopologyBar(), { ... }]);

WorkspaceController#createTopologyBar() returns a toolbar config. Two directions are wrong in one expression: a controller authoring view structure, and a view reaching into its controller for a child it owns. The four remaining sites are single-direction reach-through (Viewport.mjs:59, :70, :151 addressing another component's controller; Workspace.mjs:795 a saveTopology facade the Neural Link and Viewport.mjs:153 both call as a shipped surface) — none of them has a controller producing view config, and removing the facade would break WorkstationTopologyGroupsNL.spec.mjs:229. They are the larger, riskier half and are explicitly out of scope here.

There is a second consequence, which is the reason this is not cosmetic. Because the bar is built once as a config literal, its per-participant recovery buttons are frozen at construction. getPopupStates() grows when a pane tears out (Workspace.mjs:2140) and shrinks when a popup host retires (PopupWorkspace.mjs:154), and the bar cannot follow either.

The Architectural Reality

  • A view controller is declared through the component's controller config and reaches into the view: src/controller/Component.mjs:117 getReference() resolves via component.down({reference}), needing no mount.
  • src/controller/Component.mjs:44 binds onComponentConstructed() to the host's constructed event, which src/core/Base.mjs:820 fires after isConstructed is set — i.e. after the view's own construct() has added its children. Its docblock names exactly this use: "in case you need references". Eight in-repo controllers already override it, including the sibling apps/workstation/view/TourController.mjs:50.
  • Declarative handler names resolve through the configured controller chain: src/util/Function.mjs:158 resolveCallback sends a string handler to scope.getController?.()?.getHandlerScope(fn, null). Nothing needs to close over the controller instance.
  • apps/portal/view/home/FooterContainer.mjs:26 (configured controller) with :40 (referenced child) is the executed precedent #18460 cites.
  • src/dashboard/dock/window/WorkspaceSet.mjs exposes ids() and no change signal, so a live-membership refresh has no event to hang on today. That is an engine seam, and #18460's Out of Scope forbids building it here.

The Fix

  • The view declares the toolbar inline: reference: 'topology-toolbar', the two authored buttons with declarative handler names, and the Group's undo/redo actions with their bind formatters reading me.topologyGroupId directly.
  • WorkspaceController#createTopologyBar() is replaced by syncTopologyBar(), which reaches the bar through getReference() and replaces only the derived tail after static authoredTopologyButtonCount, so a resync cannot destroy what the view authored.
  • Two declarative handlers (onOpenTopologyWorkspace, onMountTopologyWorkspace) read the addressed participant off data.component.workspaceKey.
  • onComponentConstructed() performs the single population, giving behaviour parity with the config literal.

Contract Ledger Matrix

Surface Authority Required outcome Refusal/recovery Evidence
The topology bar's structure The view, through its own items/actions config Declared in apps/workstation/view/Workspace.mjs; no controller method returns view config Ordinary component disposal A prototype-call witness reading reference, the authored handler names, and the item count
Its per-participant tail WorkspaceController#syncTopologyBar via src/controller/Component.mjs:117 Only items after authoredTopologyButtonCount are replaced Returns false on an unreachable or destroyed bar; never assumes it synced A real toolbar.Base under the controller: populate, resync to empty, then destroy
Population timing src/controller/Component.mjs:44 → src/core/Base.mjs:820 One population, at onComponentConstructed None; parity with the current single build The same witness asserts the bar is already filled before any explicit call
Handler routing src/util/Function.mjs:158 String names only; no captured controller Unresolved name is inert, as for any Neo handler The declared names asserted against the controller's methods

Acceptance Criteria

  • apps/workstation/view/Workspace.mjs declares the topology bar; grep -n 'getController()' apps/workstation/view/Workspace.mjs returns no site that consumes view config from the controller. The PR reports the full before/after dispatch-site population, not just the one removed.
  • The controller fills the bar through getReference() and cannot destroy the view-authored head. A witness populates, resyncs after the participant set empties, and asserts the authored two survive.
  • The lifecycle wiring itself is witnessed, not just the method it calls: an arm that would go red if onComponentConstructed() stopped calling syncTopologyBar().
  • Behaviour parity is claimed and measured — the bar is populated exactly once, as today. The stale-after-tear-out limitation is reported as unchanged, with the missing WorkspaceSet signal named, and is not silently fixed here.
  • Workstation's 43 unit witnesses and the full unit tier stay green at current head; the one stale caller (test/playwright/unit/apps/workstation/WorkspaceController.spec.mjs:128) moves to the surface that now owns it, and the PR says which arms moved and why.
  • Mutation controls: renaming the view's reference, removing the lifecycle call, and replacing the whole item list instead of its tail each redden a named arm, and each arm is named in the PR.

Out of Scope

The four remaining view-to-controller dispatch sites (Viewport.mjs:59 · :70 · :151 and the Workspace.mjs saveTopology facade) — cross-component reach-through and a shipped Neural Link surface, both larger shapes with their own callers. A membership-change signal on WorkspaceSet. Any change to what the bar's buttons do, to Group/history semantics, or to #18460's other Ledger rows.

Avoided Traps

No ritual getReference() added to legitimize a role: the controller genuinely needs the child it did not declare. No new forwarding facade — one controller method is removed and one is added, both with real work. No refresh path wired to a signal that does not exist, which would be a new engine seam wearing an app-code disguise.

Decision Record impact

aligned-with ADR 0029 — no engine host, Group, projection or native-window contract is touched.

Sweeps

  • (i) Live latest-open: latest 20 open issues read at 2026-09-09T15:33:40Z (created-desc); no equivalent found. Nearest: #18461 (composition entrypoint), #18474 (declarative arrangement) — different surfaces.
  • (ii) A2A in-flight claims: list_messages({status:'all', limit:30}); open claims are #18478 (@neo-opus-grace) and #18479 (@neo-opus-ada). No claim on this surface.
  • (iii) Memory Core rationale: query_raw_memories on the problem's nouns (controller-authored view structure, view dispatching for its own child) returned four sessions, none deciding this row.
  • (iv) Own assignments: #18460, #17918, #16212 — bodies read; only #18460 overlaps, as its parent.

Related

Parent: #18460 · Epic: #18456 · Related: #18304 · #18533 · PR #18542

Origin Session ID: 206b8400-7fe8-472e-8018-446e550b784b

Retrieval Hint: "Workstation topology bar authored by its controller, view-to-controller dispatch population, onComponentConstructed getReference"