The portal's node context menu β the cube icon on every node β is fully data-driven. Menu items are registered in the node's HubConfiguration as reactive providers (IObservable<IReadOnlyCollection<NodeMenuItemDefinition>>). A predicate-based renderer subscribes to every provider, merges and sorts their items per context, and pushes the result to the $Menu slot in the entity store via host.UpdateArea on every emission. The portal reads $Menu directly from the layout stream β no separate RPC required.
π¨ The menu is reactive, not a one-time snapshot. Each provider emits its complete item set and re-emits whenever its inputs change β most importantly, the viewer's effective permissions. A runtime
AccessAssignment(for example, granting Editor) reaches the menu on theenrichedpermission stream after the synced query catches up; a reactive provider re-emits the moment it does, and the menu self-corrects automatically.The old
IAsyncEnumerable+await foreach β¦ yield breakcontract took the first permission snapshot and locked it in β baking in whatever had propagated by first render (the access race behind the oldMenu_Editor_ShowsCreateItemsflake). See Aggregating Providers.
Reactive menu pipeline: multiple providers combine live into a permission-filtered, sorted menu pushed to the $Menu slot on every emission.
Default Menu Items
AddDefaultMeshMenu() β called automatically by AddDefaultLayoutAreas() β registers two default providers, one per menu context.
Node menu (DefaultNodeMenuProvider) β per-node operations, emitted as one flat list. The provider re-stamps each item's Order so the sections come out in a fixed shape regardless of what each layout area declares:
| Item | Area | Permission | Order | Icon | Notes |
|---|---|---|---|---|---|
| Edit | Edit |
Update |
10 | βοΈ | |
| Pin | Pin |
β | 12 | π | Viewer-scoped, not permission-gated; hidden on the viewer's own home |
| Hide / Show in presentation mode | HideInPresentation / ShowInPresentation |
β | 13 | πΆοΈ / π | Viewer-scoped; label AND area flip on the viewer's own marks; hidden on their own home |
| Move | Move |
Delete |
14 | β‘οΈ | Requires Delete on the source |
| Copy | Copy |
Create |
16 | π | Duplicates the subtree |
| Delete | Delete |
Delete |
18 | ποΈ | |
| Files | Files |
Read |
30 | π | |
| Data | Data |
Read |
31 | π§Ύ | The raw record, reachable even when Overview is a designed page |
| Versions | Versions |
Read |
32 | π | |
| Stop sync | StopSync |
Update or Sync |
34 | π | Only on a synced node |
| Recycle | Recycle |
Update |
50 | β»οΈ |
_separator entries are derived, never emitted by a provider: the aggregator inserts one wherever two adjacent entries in the FINISHED menu fall in different Order bands (20 and 40). Since the derivation runs after every provider is merged and after the MenuPresentation overlay, a divider can never lead, trail or double, an empty middle band produces one rule rather than two, and hiding a section's last entry through the catalog does not strand the rule behind it. See The Menu Contribution Boundary.
Edit, Move, Copy and Delete are suppressed on a protected partition root (a user's home). Deleting that node would wipe the whole partition; PartitionRootDeletionGuard blocks it server-side, and the menu keeps it out of reach in the first place. Pin stays.
Threads are not in this menu β they live in the dedicated top-bar AI menu (AiMenuContext).
Mesh menu (DefaultMeshMenuProvider) β mesh-level operations, which keep the Order their layout area declares:
| Item | Area | Permission | Order |
|---|---|---|---|
| Create | Create |
Create |
0 |
| Import | ImportMeshNodes |
Create |
1 |
| Export | Export |
Export |
26 |
Items with a required permission are checked inside the provider. Only items the viewer is permitted to see ever reach the portal.
How the Menu Pipeline Works
When the portal subscribes to the layout stream, the node hub runs the RenderMenus renderer. That renderer collects all registered providers, combines their live streams with CombineLatest, applies per-provider permission checks in .Select, merges the results into an ImmutableSortedSet ordered by Order, and writes the final list to $Menu:{ctx} via host.UpdateArea. Every time any provider re-emits, the whole pipeline re-runs and the portal receives a fresh, authoritative menu β no reload needed.
Portal (LayoutAreaView)
β
β Subscribes to layout stream
β βββββββββββββββββββββββββββββββββββΊ Node Hub
β β
β β WithRenderer(_ => true, RenderMenus)
β β β CollectMenuItemStreamsByContext(host, ctx)
β β β CombineLatest each provider's IObservable
β β β permission checks inside each .Select
β β β merged into ImmutableSortedSet by Order
β β β host.UpdateArea($Menu:{ctx}, MenuControl)
β β on EVERY emission (re-emits on perm change)
β β
β $Menu stream update(s) β
β βββββββββββββββββββββββββββββββββββ β
β
β LayoutAreaView β IMenuItemsProvider
β PortalLayoutBase renders items in menu
An entry is never offered for an area this hub cannot render
π¨ Most of the default entries link to a renderer the platform does not own. Delete, Copy, Move,
Versions, Pin, Import, Stop sync, Access control and Groups all render from
MeshWeaver.Graph.Views, which ships as the optional DefaultViews package β the platform keeps
the area name and the menu descriptor, the package brings the view. On a mesh without that package
every one of those entries used to be offered anyway, and every click landed on the layout engine's
diagnostic page: "Area not found β No renderer is registered for area Delete" (MeshWeaver#3604).
DefaultNodeMenuProvider therefore drops, as its last step, any entry that is a plain navigation to
this node's own area URL for an area no named renderer serves
(NodeMenuItemsExtensions.WithoutUnrenderableAreas). The check is deliberately narrow: an
action entry is never dropped (Recycle runs in place and its href is the node's landing page), nor
is a submenu parent, a separator, a group, or an entry linking anywhere but /{node}/{area} β an
absolute href such as Cast's /RemoteControl/Start/Cast?target=β¦ names an area this hub was never
asked about.
π¨ It fails OPEN, and the Overview probe is why. The one rule is
MeshNodeLayoutAreas.CanRenderArea. HasNamedRenderer answers a boolean about something it had to
read, and "this definition is empty, or is not the one that serves this node" must never be collapsed
into "this area has no renderer" β that direction silently deletes Delete, Copy and Move from every
portal at once. A node hub always carries Overview (registered by the same AddDefaultLayoutAreas
call that registers this menu, and nothing can unregister it), so a definition that does not know
Overview is one that cannot be trusted to answer for the rest: every entry is kept, and the visible
diagnostic page remains the outcome.
The node header's button row is the second way in, and it uses the same probe.
MeshNodeLayoutAreas.BuildHeaderActionRow renders Edit / Copy / Move / Delete as buttons carrying
the identical /{node}/{area} hrefs, so hiding the menu entry alone would have left the dead link
one click away. Each button is gated on CanRenderArea for its own area. On a portal carrying the
package, neither surface changes at all.
Contributed providers are not filtered β a provider owns the applicability of what it emits, which is
what RequiredPermission already expresses. If you contribute an entry pointing at an area, register
its renderer on the same hub.
Adding Custom Menu Items
Use AddNodeMenuItems() in your node type's HubConfiguration to add items beyond the defaults. The provider is a reactive stream β compose the live permission observable with .Select and return the complete item set per emission. Emit [] when you contribute nothing; never return Observable.Empty.
config => config
.AddNodeMenuItems((host, ctx) =>
// GetEffectivePermissions is IObservable<Permission> β re-emits when the viewer's
// permissions change. .Select off it so the menu re-renders when a role is granted.
host.Hub.GetEffectivePermissions(host.Hub.Address.ToString())
.Select(perms => perms.HasFlag(Permission.Update)
? (IReadOnlyCollection<NodeMenuItemDefinition>)
[new NodeMenuItemDefinition("Suggest", "Suggest",
RequiredPermission: Permission.Update, Order: 11)]
: []))
.AddLayout(layout => layout
.WithView("Suggest", MyEditArea.Suggest))
Items from AddNodeMenuItems() are merged with the defaults and sorted by Order.
Hierarchical Sub-Menus
Set the Children property to nest items under a parent entry. A provider emits its complete set β including the parent and all its children β on every emission.
// Group multiple items under a parent β a provider emits its complete set per emission.
private static IObservable<IReadOnlyCollection<NodeMenuItemDefinition>> MoreActionsProvider(
LayoutAreaHost host, RenderingContext ctx)
=> Observable.Return<IReadOnlyCollection<NodeMenuItemDefinition>>(
[
new NodeMenuItemDefinition("More Actions", NodeMenuItemDefinition.GroupArea, Icon: "π§°", Order: 50,
Children:
[
new("Action 1", "Action1Area", Icon: "1οΈβ£", Order: 1),
new("Action 2", "Action2Area", Icon: "2οΈβ£", Order: 2),
]),
]);
A parent is never activatable
Any entry carrying Children is a sub-menu parent, and no client will activate it β its own Area / Href is ignored for activation. That is not a policy invented here; it is what both web component libraries do. FAST's fluent-menu-item (Blazor) and Fluent React v9's MenuTrigger-wrapped item both toggle the sub-menu on click or Enter rather than invoking the parent, so "a parent that also navigates somewhere" is not expressible in either.
Give a pure grouping parent an area from NodeMenuItemDefinition.GroupArea(name) β _group:Export β the sibling of the long-standing "_separator". It makes the wire self-describing: a client that cannot nest can still tell "this is a group, not an action" instead of rendering a dead row that navigates to /{path}/.
π¨ A prefix, not one shared "_group" constant. Area is also the stable key the menu-presentation catalog matches on, and the key another entry names to become a child. One shared sentinel would make every group the same key β an admin could not re-word, re-icon, re-order or hide a specific group, and only the first would be addressable as a parent.
Nesting has two origins, and they compose
A sub-menu can come from code (a provider emitting Children) or from data (a catalog entry's parent moving an item under another β MenuAsData). RenderMenus runs the overlay first and normalizes afterwards, so both origins land in the same shape and obey the same rules. A grouping created purely by a node edit sorts and prunes exactly like a compiled one.
The catalog also descends into compiled groups, so grouping entries in code does not make them un-editable: ExportDocx sits inside π¦ Export and is still addressable by its own area.
The aggregator normalizes the tree
Two things happen once, in RenderMenus β after the overlay, so data-created groupings are covered too:
- Children are sorted by
Orderat every depth, with the same comparer the top level uses. Before this, only the top level was sorted and a sub-menu came out in whatever order its provider appended. - A
_group:parent with no surviving children is dropped. Items are permission-filtered by the provider, never by the renderer, so a provider that gates each child individually can legitimately end up emitting a parent whose children all vanished for this viewer β as can a catalog that hides them. Rendering that would give a sub-menu that opens onto nothing. Pruning runs bottom-up, so a group whose only child was itself an emptied group disappears too. A parent with a realAreaof its own survives β it still has somewhere to go, which is also what keeps the overlay's "a danglingparentleaves the entry top-level" behaviour intact.
How each client renders it
Parity across clients means equivalent capability, not an identical gesture β the mobile client deliberately differs:
| Client | Rendering |
|---|---|
Blazor portal (NodeMenuItemList.razor) |
<FluentMenuItem MenuItems="β¦"> β <fluent-menu slot="submenu"> β FAST's native flyout |
portal-next / React (HeaderMenus.tsx β MenuEntries) |
nested Fluent v9 <Menu> + <MenuTrigger> inside the parent <MenuList> |
React Native (leftMenu.tsx β LeftMenuView) |
drill-down: tapping a parent replaces the list with its children plus a back control |
MAUI (PortalShellPage) |
recursive inline expander |
The web clients get the conventional nested flyout, with the component library supplying roles, aria-haspopup / aria-expanded and the keyboard model (Enter / ArrowRight to open, ArrowLeft / Escape to close) β a sub-menu is never hover-only. The mobile client drills down instead: a flyout that opens a second panel beside the first needs hover and width, and a phone has neither, so exactly one level is on screen at a time, parents carry a βΊ chevron, and rows clear the 44 pt touch target.
Nesting depth is unbounded β every renderer recurses. Nothing ships deeper than two levels today.
The built-in node menu does not use this pattern. DefaultNodeMenuProvider (in NodeMenuItemsExtensions, registered alongside DefaultMeshMenuProvider) emits Edit, Pin, Move, Copy, Delete and the rest as one flat list β no Children, no "Actions" parent β grouped by Order band, with the dividers derived from the merged result rather than by nesting:
| Order band | Section | Icons |
|---|---|---|
| 10β18 | edit / organize | βοΈ π πΆοΈ β‘οΈ π ποΈ |
| 27β30 | export / share / approval (contributed by other packages) β PDF π, Email π€, DOCX π grouped under π¦ Export, Request Approval β | π¦ (β π π€ π) β |
| 30β38 | content / history / sync | π π§Ύ π π π |
| 50 | lifecycle | β»οΈ |
π¨ Every entry needs an Icon, and it must be an EMOJI. The renderer treats a non-emoji value
as an image URL, so a Fluent icon name ("DocumentPdf") silently becomes a broken
<img src="DocumentPdf"> rather than failing. An entry that omits Icon altogether renders as a
bare label and reads as a foreign group wedged between the iconed ones β which is exactly what the
export/share block did before it was given π π€ π. MarkdownExportMenuTest asserts this as an
invariant over the whole menu, so a new icon-less entry fails the build rather than shipping.
Prefer a short label + a translated tooltip over a sentence-shaped label. The export group is
the worked example: the entries are PDF, Email, DOCX β not "Export to PDF" β with the
explanation moved to TooltipKey (menu.exportPdf.tooltip, β¦). This is the AGENTS.md-preferred
shape (language-neutral glyph + short label + translated tooltip) and it shrinks the translation
surface: PDF and DOCX are format names and are deliberately identical in every catalog,
while Email β German E-Mail is a real word and is translated. Once a label is this short the
tooltip is the only remaining explanation, so TooltipKey stops being optional polish β the same
test asserts the group carries one.
Because the aggregator re-sorts every provider's items by Order, a plugin's item slots into the right section just by picking a number in that band β which is why the built-in set stays mostly flat. Reach for Children when several entries share one sentence: the export block (π PDF / π€ Email / π DOCX) is grouped under a single π¦ Export parent precisely because "take this document somewhere else" describes all three, and because it was the largest contiguous run in a menu that had grown to roughly fifteen flat rows.
NodeMenuItemDefinition Reference
| Parameter | Type | Description |
|---|---|---|
Label |
string |
Display text shown in the menu |
Area |
string |
Layout area to navigate to when clicked |
Icon |
string? |
Optional emoji or SVG URL; null to skip |
RequiredPermission |
Permission |
Permission the user must have (e.g., Permission.Update) |
Order |
int |
Sort order within the menu (lower = earlier) |
Href |
string? |
Optional absolute href β when set, navigates directly instead of using Area |
Children |
IReadOnlyList<NodeMenuItemDefinition>? |
Child items for hierarchical sub-menus. Any entry carrying them is a parent and is never activatable; sorted by Order and pruned when empty by the aggregator |
Tooltip |
string? |
Hover tooltip; falls back to Label |
Two sentinel Area values are reserved: NodeMenuItemDefinition.SeparatorArea ("_separator") draws a divider, and NodeMenuItemDefinition.GroupArea ("_group") marks a pure grouping parent. Neither is ever activated.
Advanced: NodeMenuItemProvider
For conditional items that depend on live hub state, register a NodeMenuItemProvider delegate directly. The provider must be IObservable<IReadOnlyCollection<NodeMenuItemDefinition>> β never await, never Task<T>:
config.AddNodeMenuItems(
new NodeMenuItemProvider((host, ctx) =>
CheckSomething(host.Hub) // IObservable<bool>, re-emits as the condition changes
.Select(canDoSpecialThing => canDoSpecialThing
? (IReadOnlyCollection<NodeMenuItemDefinition>)
[new NodeMenuItemDefinition("Special", "SpecialArea", Order: 20)]
: [])))
Named Menu Contexts
By default, items land in the main context menu. You can scope items to a named context β for example, a side panel β by passing a context name to AddNodeMenuItems:
config.AddNodeMenuItems("SidePanel",
new NodeMenuItemDefinition("Quick Action", "QuickAction", Order: 1));
Named contexts are stored at $Menu:{context} and rendered independently from the main menu.
Node Operations
Export
The Export action packages a node and its entire subtree as a ZIP archive. File formats are chosen by node type:
- Markdown nodes β
.mdwith YAML front matter - Code nodes β
.csas plain C# files - Agent nodes β
.mdwith agent-specific YAML - All other nodes β
.jsonwith polymorphic$typecontent
The exported ZIP mirrors the file-system layout exactly, ensuring round-trip compatibility with Import. Export requires Permission.Export, which is included in the Editor and Admin roles but not Viewer.
Copy
The Copy action duplicates a node and all its descendants to a new namespace. The source node's ID is preserved under the target namespace. Use the "Force" option to overwrite existing nodes at the destination.
Move
The Move action relocates a node and all its descendants to a new path. It requires Delete permission on the source and Create permission on the target. The operation is atomic per node: descendants move first, then the root.
Generic Navigation
Menu items navigate to their declared Area by appending it to the current path (for example, /TestOrg/Project/Settings). When Href is set, the portal navigates to that absolute URL instead β used for cross-node navigation such as the node-name β NodeType link.
MenuControl and the Entity Store
MenuControl is stored at $Menu (and $Menu:{context} for named contexts) in the entity store, following the same pattern as DialogControl at $Dialog. It wraps an IReadOnlyList<NodeMenuItemDefinition> that may contain hierarchical items with children.
Reading the menu β GetMenu (the read API)
Because the menu lives in the layout-area stream, reading it is the same reactive stream tech as hub.GetQuery / GetControlStream β there is no renderer-specific menu reader to replicate. MeshWeaver.Mesh.MenuStreamExtensions exposes one common, renderer-agnostic surface (in MeshWeaver.Mesh.Contract):
// On a layout-area stream you already hold (e.g. inside a view):
areaStream.GetMenu("Node") // IObservable<IReadOnlyList<NodeMenuItemDefinition>>
// Hub / workspace shorthand β opens the node's area stream (shared via the remote-stream cache):
hub.GetMenu((Address)nodePath, new LayoutAreaReference("Overview"), "Node")
GetMenu(context) reads $Menu:{context} off the stream (context: null β the root $Menu) and re-emits whenever the node hub re-renders the menu β e.g. a runtime AccessAssignment grants a role. Both renderers consume this one API: the native MAUI shell subscribes to hub.GetMenu(...) to render the node's actions in its top bar; the Blazor LayoutAreaView subscribes to AreaStream.GetMenu(context) and forwards items to IMenuItemsProvider (a per-circuit scoped bridge to PortalLayoutBase, not a menu store β and never static, which would bleed across users/circuits). The menu providers themselves stay where they belong: stateless, idempotently-registered (TryAddEnumerable) reactive lambdas on the hub configuration (AddNodeMenuItems / AddDefaultMeshMenu).
Live Example
The cell below renders the default node menu's item set, illustrating the data that backs a typical menu. Note it uses DataGridControl β structured data always goes through a control, never a hand-built markdown or HTML string.
record MenuRow(string Label, string Area, string Permission, int Order);
var items = new[]
{
new MenuRow("Edit", "Edit", "Update", 10),
new MenuRow("Pin", "Pin", "(none)", 12),
new MenuRow("Move", "Move", "Delete", 14),
new MenuRow("Copy", "Copy", "Create", 16),
new MenuRow("Delete", "Delete", "Delete", 18),
new MenuRow("Files", "Files", "Read", 30),
new MenuRow("Data", "Data", "Read", 31),
new MenuRow("Versions", "Versions", "Read", 32),
new MenuRow("Stop sync", "StopSync", "Update or Sync", 34),
new MenuRow("Recycle", "Recycle", "Update", 50),
};
new DataGridControl(items)
.WithColumn(new PropertyColumnControl<string> { Property = "label" }.WithTitle("Label"))
.WithColumn(new PropertyColumnControl<string> { Property = "area" }.WithTitle("Area"))
.WithColumn(new PropertyColumnControl<string> { Property = "permission" }.WithTitle("Permission"))
.WithColumn(new PropertyColumnControl<int> { Property = "order" }.WithTitle("Order"))
See Also
- DataBinding β How data flows through controls
- Editor β The editor control for form rendering
- Access Control β Permission system
- The Menu Contribution Boundary β which entries may be contributed as data, which stay compiled, and why the dividers are derived