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 the enriched permission 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 break contract took the first permission snapshot and locked it in β€” baking in whatever had propagated by first render (the access race behind the old Menu_Editor_ShowsCreateItems flake). See Aggregating Providers.

Default Provider AddDefaultMeshMenu() Custom Provider A AddNodeMenuItems() Custom Provider B NodeMenuItemProvider IObservable<Items> re-emits on perm change CombineLatest + Permission filter in each .Select() RenderMenus renderer ImmutableSortedSet merged & ordered by Order property host.UpdateArea() $Menu Portal LayoutAreaView IMenuItemsProvider β†’ PortalLayoutBase

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:

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:

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 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

Reconnecting…
The server was updated. Reloading the page to pick up the latest version.