LearnNewsExamplesServices
Frontmatter
id17876
titleDock tab headers admit only the close action — host-authored header actions cannot be projected or routed
stateClosed
labels[]
assigneesneo-opus-grace
createdAtAug 30, 2026, 1:53 AM
updatedAtAug 30, 2026, 2:23 PM
githubUrlhttps://github.com/neomjs/neo/issues/17876
authorneo-opus-grace
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtAug 30, 2026, 2:23 PM

Dock tab headers admit only the close action — host-authored header actions cannot be projected or routed

neo-opus-grace
neo-opus-grace commented on Aug 30, 2026, 1:53 AM

A Dock host cannot put its own actions into a projected tab header. The tab subsystem supports it — tab.Container#headerActions is a public reactive config, and #17874 makes focus gating a general showOnFocus — but the Dock's projection owns that slot and admits exactly one action.

Three gaps, all in the projection

# site what it does
1 dashboard/dock/projection/LayoutAdapter.mjs:942 builds headerActions only from enableDockCloseAction; no option carries additional actions
2 dashboard/dock/projection/LayoutAdapter.mjs:1012 the headerAction listener forwards only data.action === 'close'; every other intent is swallowed
3 dashboard/dock/Workspace.mjs:1505 onDockHeaderAction is wired inside the enableDockCloseAction spread, so a host wanting custom actions without the close action gets no handler at all

Gap 2 is the one that makes a workaround impossible: even if a host got an action rendered, pressing it would reach nothing.

What already works, and should not be rebuilt

  • enableDockCloseAction projects a persistent close action, routes it through the model as closeItem on the resolved active item, owns focus succession, and keeps hidden synchronised against each item's closable policy. A host wanting only "an always-visible close button that closes the active tab" needs no new engine code — just the flag.
  • Focus gating is generic after #17874: an action carrying showOnFocus: true (the tab header default) is exposed while its TabContainer holds focus. No Dock-specific gating is needed.

So this ticket is only about admitting host-authored actions into the projection and routing their intent back out.

Shape

getDockProjectionOptions() is the documented host hook ("the default contributes nothing"), so the option should arrive through it and be threaded into LayoutAdapter.project. Per-tabs-node resolution matters: a host will want different actions on different zones, so an array alone is likely too weak — a resolver receiving the node id is the shape to weigh against it.

Intent should travel the existing onDockHeaderAction seam rather than a parallel callback; the Workspace already returns null for actions it does not own, so forwarding everything is safe and keeps one path.

Contract Ledger

Added 2026-08-30 at reviewer request (PR #17879 RA-1). This is the consumed surface the implementation is held to; the PR reconciles against these rows.

resolveDockHeaderActions — the host resolver

aspect contract
supplied via DockWorkspace#getDockProjectionOptions(), the documented host hook. Absent ⇒ projection byte-identical to before this feature.
signature (nodeId: String) => Object[] | null | undefined
why nodeId alone The active item is deliberately not passed. A resolver that could see it would be invited to return a per-item action LIST, and a list that changes between projections replaces the action group — destroying the stable instances actionVisibilityChange consumers such as tab Overflow bind to. Per-item behaviour belongs on the instance.
return Action configs in the shape toolbar.Base#createActionItemConfig accepts. null / undefined / [] all mean "no host actions for this node".
lifecycle Node-static. Resolved per tabs node; the resulting action instances live for that node's retained lifetime and are not rebuilt on reprojection. Vary an action per active item by moving hidden / disabled on its stable instance — the mechanism Workspace#syncDockCloseAction already uses for the engine's own close action.
re-resolution The resolver IS asked again when a commit re-projects a node — measured, not assumed. For a retained node the returned set is then discarded: the existing action instances win. So a resolver returning a different set on a later call has no effect, which is the mechanical reason node-static is a contract the machinery can actually keep rather than a convention.
ordering Host actions project before the close action, so close keeps the rightmost position it already occupies for every consumer that enables it.
focus gating Inherited from the tab header's actionDefaults (showOnFocus: true), so a host action is exposed with its TabContainer unless it opts out. No Dock-specific gating exists.

Name rules — enforced, not documented

rule behaviour
every host action carries a semantic action name missing ⇒ throws at projection
names unique per tabs node duplicate ⇒ throws, naming the action and the node
close reserved while enableDockCloseAction is on ⇒ throws, naming the node
close usable when enableDockCloseAction is off allowed — scoped rule, nothing to shadow

Throwing rather than dropping is deliberate and matches toolbar.Base#createActionItemConfig, which already throws for an unknown action and for an icon-only action with no accessible name. Names address actions — getActionItem(name) returns the first match and intents route by name — so a duplicate leaves one control permanently unreachable, and a host close projecting first would capture both the engine's hidden policy sync and its close intent. Both failures are silent to a host that only sees a header missing a control.

Intent — the dockHeaderAction event

aspect contract
emitted by DockWorkspace#onDockHeaderAction, for every action this class does not own
owned by the class exactly close, and only while enableDockCloseAction is on; that one is routed through the model and is not re-emitted
payload {action, dockNodeId, tabContainer} plus the emitter's own source (the workspace id), added by fire
return onDockHeaderAction returns null for an unowned action — the workspace declines to act, it does not swallow
host requirement none: listen on the workspace. No subclass and no protected-method override.
wiring bound unconditionally, so a host projecting only its own actions — with the close action off — still receives intents.

Acceptance Criteria

  • A Dock host can project additional actions into a tabs node's header without patching the adapter, using the documented projection-options hook.
  • A host-authored action's intent reaches the host, with the tabs node identified — proven by an arm that fails if the listener still filters on close.
  • Host actions compose with enableDockCloseAction: both present together, and either usable alone. In particular a host with custom actions and no close action still receives intents.
  • Host actions are focus-gated by the tab-header default unless they opt out, with no Dock-specific gating code.
  • Reconciliation keeps host actions stable across a re-projection — an action instance is not replaced on every commit, so actionVisibilityChange consumers such as tab Overflow keep working.

Out of Scope

  • The gating vocabulary itself — #17874 owns showOnFocus.
  • Changing close-action semantics.