# Menus, dialogs, and tray

> App menus, context menus, system dialogs, tray status items, and how OS-owned UI coexists with engine-drawn widgets.

- Repository: vercel-labs/native
- GitHub: https://github.com/vercel-labs/native
- Human docs: https://grok-wiki.com/public/docs/vercel-labs-native-8ccc3580636a
- Complete Markdown: https://grok-wiki.com/public/docs/vercel-labs-native-8ccc3580636a/llms-full.txt

## Source Files

- `docs/src/app/menus/layout.tsx`
- `docs/src/app/dialogs/layout.tsx`
- `docs/src/app/tray/layout.tsx`
- `src/runtime/canvas_widget_context_menu.zig`
- `examples/command-app/README.md`

---

---
title: "Menus, dialogs, and tray"
description: "App menus, context menus, system dialogs, tray status items, and how OS-owned UI coexists with engine-drawn widgets."
---

Native SDK routes app menus, tray actions, and (where supported) system dialogs through platform hosts, while canvas widgets stay engine-drawn. Menu and tray selections re-enter the app as `Event.command` with a typed `CommandSource`; context menus dispatch typed `Msg` values from declared items; file and message dialogs run as blocking platform services on the effect/capability boundary.

```text
                    ┌─────────────────────────────────────┐
                    │  Entry points                        │
                    │  app.zon menus · toolbar · shortcut  │
                    │  tray · bridge · context-menu item   │
                    └──────────────┬──────────────────────┘
                                   │
          ┌────────────────────────┼────────────────────────┐
          ▼                        ▼                        ▼
   configureMenus()         createTray() /            showOpenDialog /
   (native app menu)        status_item               showSaveDialog /
          │                        │                  showMessageDialog
          ▼                        ▼                        │
   Event.command             Event.command                  │
   source=.menu              source=.tray                   ▼
          │                        │               OpenDialogResult /
          └────────────┬───────────┘               MessageDialogResult
                       ▼                           (or bridge JSON)
              on_command / event_fn
              (same command id map)
```

## Ownership model

| Surface | Owner | Authoring | Result path |
| --- | --- | --- | --- |
| App / window menu bar | OS host (`configureMenus`) | `app.zon` `.menus` or `runWithOptions.menus` | `Event.command`, `source = .menu` |
| Widget context menu | OS `NSMenu` when available; else anchored canvas surface | `<context-menu>` or `ElementOptions.context_menu` | Typed `Msg` via selection |
| System file/message dialogs | OS file panels / alert sheets | `Runtime.show*Dialog` or bridge `native-sdk.dialog.*` | Paths / `MessageDialogResult` |
| Tray / menu-bar extra | OS status item | `Runtime.createTray` or `UiApp.Options.status_item` | `Event.command`, `source = .tray` |
| Canvas `dialog` / `dropdown-menu` widgets | Engine-drawn surfaces | Markup / component catalog | Model flags + `on-dismiss` / `on-press` |

Declare one command id (for shell actions) or one context-menu item set (for row actions). Do not fork a second menu tree for hosts that lack a native presenter — the runtime chooses presentation.

## App menus

Menus are declarative top-level bars with command-backed items. Generated runners load `app.zon` menus; Zig can override at `runWithOptions`.

### Manifest shape (`app.zon`)

```zon
.menus = .{
    .{
        .title = "View",
        .items = .{
            .{ .label = "Sync", .command = "app.sync", .key = "s", .modifiers = .{ "primary" } },
        },
    },
},
```

`examples/command-app` lists `menus` under `capabilities` and wires the same `app.sync` command from toolbar, menu, tray, shortcut, and bridge.

### Zig types

```zig
const view_items = [_]native_sdk.MenuItem{
    .{
        .label = "Refresh",
        .command = "app.refresh",
        .key = "r",
        .modifiers = .{ .primary = true },
    },
    .{ .separator = true },
};

const menus = [_]native_sdk.Menu{
    .{ .title = "View", .items = &view_items },
};

try runner.runWithOptions(app.app(), .{
    .app_name = "native-shell",
    .menus = &menus,
}, init);
```

| Field | Type | Notes |
| --- | --- | --- |
| `Menu.title` | `[]const u8` | Required; non-empty |
| `Menu.items` | `[]const MenuItem` | Shared across menus under item budget |
| `MenuItem.label` | `[]const u8` | Required unless `separator` |
| `MenuItem.command` | `[]const u8` | Valid command id |
| `MenuItem.key` / `modifiers` | shortcut fields | Optional accelerator |
| `MenuItem.separator` | `bool` | Divider; skips label/command checks |
| `MenuItem.enabled` / `checked` | `bool` | Defaults `true` / `false` |

### Limits and validation

| Constant | Value |
| --- | --- |
| `max_menus` | 16 |
| `max_menu_items` (total items across all menus) | 128 |
| `max_menu_title_bytes` | 64 |
| `max_menu_item_label_bytes` | 128 |
| `max_menu_command_bytes` | 128 |
| `max_menu_key_bytes` | 32 |

Invalid menus surface as `error.InvalidMenuOptions`, `error.InvalidCommand`, or `error.InvalidShortcut`. Shortcuts that require a modifier without one fail validation.

### Dispatch

On install, the runtime calls `PlatformServices.configureMenus`. Selecting a command-backed item emits `Event.command` with:

- `name` — item command string  
- `source` — `.menu`  
- `window_id` — active native window (tray/menu-before-focus may use `0`; handlers often treat `0` as window `1`)

macOS, Linux, and Windows system-WebView backends apply native app/window menus. Hosts that cannot implement menus return `error.UnsupportedService` for non-empty lists. Feature probe: `PlatformFeature.menus`.

## Context menus

Per-widget secondary-click menus are declared once. Presentation is host-chosen; authoring is not.

### Authoring

**Markup** — `<context-menu>` is a direct child of a pressable host. It takes no attributes. Children are `menu-item` (requires `on-press`), `separator`, and structure tags (`if` / `else` / `for`):

```html
<list-item on-press="open:{entry.id}" label="{entry.title}">
  <text grow="1">{entry.title}</text>
  <context-menu>
    <menu-item on-press="open:{entry.id}">Open</menu-item>
    <separator />
    <menu-item on-press="delete:{entry.id}">Delete</menu-item>
  </context-menu>
</list-item>
```

`examples/notes` uses this pattern for folder and note rows (Rename/Delete, Copy/Delete, and so on). Conditional items go **inside** the menu, not around a second `<context-menu>`.

**Zig** — `ElementOptions.context_menu`:

```zig
ui.listItem(.{
    .on_press = Msg{ .select = entry.index },
    .context_menu = &.{
        .{ .label = "Open", .msg = Msg{ .select = entry.index } },
        .{ .separator = true },
        .{ .label = "Delete", .msg = Msg{ .delete = entry.index } },
    },
}, entry.title)
```

### Resolution order (secondary button)

1. Deepest hit-route widget with a declared context menu.  
2. Editable text target → standard Cut / Copy / Paste / Select All (clipboard paths).  
3. Static text with a live selection → Copy only.  
4. No menu → `canvas_widget_context_press` (feeds `on_hold` / press-and-hold). Declared context menus always win over hold handlers.

### Presentation

| Host | Behavior |
| --- | --- |
| macOS | Native `NSMenu` at the pointer (`popUpMenuPositioningItem`; async nested tracking loop) |
| Linux / Windows today | Same declared items mounted as an anchored canvas surface via `canvas_widget_context_menu_request` |
| No presenter + app-declared menu | Fallback request → app loop mounts the same items |
| No presenter + zero-code edit/copy defaults | Degrade to keyboard clipboard paths only (no synthetic surface) |

Platform feature: `PlatformFeature.context_menus` (`show_context_menu_fn != null`).

### Budgets

| Budget | Cap | Meaning |
| --- | --- | --- |
| `max_context_menu_items` (platform) | 32 | One presented menu truncated at the pointer |
| `max_canvas_widget_context_menu_items_per_view` | 512 | Retained declared items across all widgets in a view |

Overflow of the per-view declaration budget is `error.WidgetContextMenuLimitReached`.

### Automation

Snapshots list declared labels (`context_menu=["Open","Delete"]`). Drive selections without the OS tracking loop:

```text
native automate widget-context-menu <view-label> <widget-id> <item-index>
```

Refuses undeclared menus, out-of-range indices, separators, and disabled items. Also: `widget-context-press` for the secondary-click gesture.

## System dialogs

Blocking OS dialogs for open/save/message. Distinct from engine-drawn `<dialog>` widgets (model-owned open flags, `on-dismiss`, theme tokens).

### Runtime API

```zig
const open = try runtime.showOpenDialog(.{
    .title = "Select a file",
    .default_path = "",
    .filters = &filters,
    .allow_directories = false,
    .allow_multiple = true,
}, &path_buffer);

const saved = try runtime.showSaveDialog(.{
    .title = "Save as",
    .default_name = "untitled.txt",
    .filters = &filters,
}, &path_buffer);

const answer = try runtime.showMessageDialog(.{
    .style = .warning,
    .title = "Confirm",
    .message = "Delete this item?",
    .informative_text = "This action cannot be undone.",
    .primary_button = "Delete",
    .secondary_button = "Cancel",
});
```

### Options summary

**Open** (`OpenDialogOptions` → `OpenDialogResult` with `count` + `paths`)

| Field | Default |
| --- | --- |
| `title` | `""` |
| `default_path` | `""` |
| `filters` | `&.{}` |
| `allow_directories` | `false` |
| `allow_multiple` | `false` |

**Save** (`SaveDialogOptions` → optional path)

| Field | Default |
| --- | --- |
| `title` | `""` |
| `default_path` | `""` |
| `default_name` | `""` |
| `filters` | `&.{}` |

**Message** (`MessageDialogOptions` → `MessageDialogResult`)

| Field | Default |
| --- | --- |
| `style` | `.info` (`info` / `warning` / `critical`) |
| `title` / `message` / `informative_text` | `""` |
| `primary_button` | `"OK"` |
| `secondary_button` / `tertiary_button` | `""` |

Result enum: `primary`, `secondary`, `tertiary`.

**FileFilter**

```zig
const filters = [_]native_sdk.FileFilter{
    .{ .name = "Images", .extensions = &.{ "png", "jpg", "gif" } },
    .{ .name = "All Files", .extensions = &.{ "*" } },
};
```

Runtime validates options before calling the platform (`validateOpenDialogOptions`, `validateSaveDialogOptions`, `validateMessageDialogOptions`). Feature probe: `PlatformFeature.dialogs`.

### Bridge (JavaScript)

Requires the builtin bridge with an explicit policy that grants the `dialog` permission. Commands:

| Command | Maps to |
| --- | --- |
| `native-sdk.dialog.openFile` | `showOpenDialog` |
| `native-sdk.dialog.saveFile` | `showSaveDialog` |
| `native-sdk.dialog.showMessage` | `showMessageDialog` |

JSON fields use camelCase (`defaultPath`, `allowMultiple`, `primaryButton`, …).

```javascript
const files = await window.zero.invoke("native-sdk.dialog.openFile", {
  title: "Select a file",
  allowMultiple: true,
});
```

### Engine-drawn dialogs

Canvas `<dialog>` / `WidgetKind.dialog` is in-app chrome: dismissible surface, theme tokens (`controls.dialog`), model open state. Prefer it for confirmations that should match app design; use system message dialogs for OS-native alerts and file pickers that must touch the host file system.

## System tray and menu-bar extras

### Runtime methods

| Method | Role |
| --- | --- |
| `runtime.createTray(options)` | Create or replace the tray icon and menu |
| `runtime.updateTrayMenu(items)` | Replace items without recreating the tray |
| `runtime.updateTrayTitle(title)` | Retitle the live status button (badge-style updates; no full rebuild) |
| `runtime.removeTray()` | Tear down the tray |
| `trayItemExists` / `trayCommandNameForItem` | Lookup for dispatch |

### TrayOptions / TrayMenuItem

| Field | Type | Default | Notes |
| --- | --- | --- | --- |
| `icon_path` | `[]const u8` | `""` | Icon file path |
| `title` | `[]const u8` | `""` | macOS menu-bar extra title; empty icon+title falls back to app name first letter |
| `tooltip` | `[]const u8` | `""` | Hover text |
| `items` | `[]const TrayMenuItem` | `&.{}` | Menu rows |
| `id` | `TrayItemId` (`u32`) | `0` | Required non-zero for command-backed items; unique |
| `label` | `[]const u8` | `""` | Required for non-separators |
| `command` | `[]const u8` | `""` | Preferred over compatibility fallback |
| `separator` | `bool` | `false` | Divider |
| `enabled` | `bool` | `true` | |

| Limit | Value |
| --- | --- |
| `max_tray_items` | 32 |
| `max_tray_title_bytes` | 64 |
| `max_tray_tooltip_bytes` | 256 |
| `max_tray_item_label_bytes` | 256 |
| `max_tray_item_command_bytes` | 128 |
| `max_tray_icon_path_bytes` | 4096 |

Validation failures return `error.InvalidTrayOptions` (missing labels, duplicate ids, zero id on non-separators, oversize fields).

### Dispatch

Tray clicks emit `Event.command` with `source = .tray` and `tray_item_id`. Command name is the item’s `command` when set; otherwise the compatibility name `"tray.action"`.

```zig
try runtime.createTray(.{
    .tooltip = "native-sdk",
    .items = &.{
        .{ .id = 1, .label = "Refresh", .command = "app.refresh" },
        .{ .separator = true },
        .{ .id = 2, .label = "Quit", .command = "app.quit" },
    },
});
```

### `UiApp` status item

Canvas-first apps install once via `UiApp.Options.status_item` (installing frame). Optional `status_item_fn` runs on install and after rebuilds: title-only changes call `updateTrayTitle` (no flicker); menu changes call `updateTrayMenu`. Static `status_item` still supplies icon/tooltip.

```zig
.status_item = .{
    .title = "NS",
    .tooltip = "Native SDK Canvas Preview",
    .items = &status_items,
},
// optional: .status_item_fn = statusItem,
```

See `examples/canvas-preview` for a live menu-bar extra sharing toolbar commands through `on_command`.

### Platform support

| Platform | Tray |
| --- | --- |
| macOS | Full (`NSStatusItem`; titled menu-bar extras) |
| Windows | Full |
| Linux | `error.UnsupportedService` until a portable status notifier is selected |
| Mobile | None |

Feature probe: `PlatformFeature.tray`. Missing tray-title support logs once and still applies menu updates.

## Shared command routing

Shell chrome converges on one command map:

| Source | `CommandSource` |
| --- | --- |
| App menu item | `.menu` |
| Keyboard shortcut | `.shortcut` |
| Toolbar / native control | `.toolbar` / `.native_view` |
| Tray item | `.tray` |
| WebView bridge | `.bridge` |
| Runtime internal | `.runtime` |

`examples/command-app` proves one `app.sync` handler for toolbar, View menu, tray, shortcut, and bridge. Prefer stable command ids in menus and tray items so `on_command` stays a pure name → action table. Context menus intentionally use typed `Msg` instead of command strings (row identity is in the message payload).

## Verification

| Check | How |
| --- | --- |
| Menu install | `native dev` / run; open app menu; status text or handler side effect |
| Command source | Log `command.source` / `@tagName` as command-app does |
| Context menu (native path) | Right-click declared row; macOS shows OS menu |
| Context menu (fallback) | Host without presenter mounts anchored surface with same labels |
| Automation | `widget-context-menu` / `widget-context-press` under `native test` |
| Dialogs | Call `showMessageDialog` / open dialogs from Zig; bridge only with `dialog` permission |
| Tray | `createTray` or `status_item`; click item → `source=.tray` |
| Feature gates | `runtime.supports(.menus|.tray|.dialogs|.context_menus)` / bridge `native-sdk.platform.supports` |
| Headless | `zig build test -Dplatform=null` (null platform fakes tray/dialogs for tests) |

## Troubleshooting

| Symptom | Likely cause |
| --- | --- |
| Empty menus ignored / `UnsupportedService` | Host lacks menu service or empty list is a no-op path |
| `InvalidMenuOptions` / `InvalidCommand` | Empty title/label, bad command id, item budget exceeded |
| Tray create fails on Linux | Expected; tray not implemented |
| `InvalidTrayOptions` | Non-unique or zero `id` on command rows; missing label; >32 items |
| Context menu never shows | No declared menu and no text selection; or zero-code path without presenter |
| Bridge dialog rejected | Missing `dialog` permission / policy |
| Context automation errors | `ContextMenuUndeclared`, `ContextMenuItemOutOfRange`, `ContextMenuItemSeparator`, `ContextMenuItemDisabled` |
| Hold handler not firing on right-click | Declared `<context-menu>` on the route wins over `on_hold` |

## Related pages

<CardGroup>
  <Card title="Commands and keyboard shortcuts" href="/commands-shortcuts">
    Single command routing across toolbar, menu, tray, shortcut, and bridge entry points.
  </Card>
  <Card title="Capabilities" href="/capabilities">
    Guarded OS services including dialogs, clipboard, and effect-channel access boundaries.
  </Card>
  <Card title="Native UI markup" href="/native-ui">
    Context-menu authoring, dismissible surfaces, and press/hold interaction rules.
  </Card>
  <Card title="Windows and surfaces" href="/windows-surfaces">
    OS window chrome, multi-window scenes, and host presentation.
  </Card>
  <Card title="Bridge and builtin commands" href="/bridge">
    `native-sdk.dialog.*` payloads, permissions, and async invoke.
  </Card>
  <Card title="Automation" href="/automation">
    `widget-context-menu`, snapshots, and deterministic UI driving.
  </Card>
  <Card title="Platform support and security" href="/platform-security">
    Host capability matrix for menus, tray, dialogs, and security boundaries.
  </Card>
</CardGroup>
