feat(plugins): render a task menu action as a submenu (#3874)

Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
Clément Fiere
2026-09-23 12:00:23 +02:00
committed by GitHub
parent 40b13df86d
commit f8708da1e0
34 changed files with 2491 additions and 95 deletions
@@ -0,0 +1,63 @@
---
requirements:
- REQ-PLUGINS-TASK-MENU-SUBMENUS-001
- REQ-PLUGINS-TASK-MENU-SUBMENUS-002
- REQ-PLUGINS-TASK-MENU-SUBMENUS-003
system_design:
- ../../specs/plugins/system-design/plugin-task-menu-submenus.md
created: 2026-09-23
status: draft
---
# Implementation Plan: Plugin task menu submenus
## Overview
`registerTaskMenuAction` contributes exactly one selectable item per plugin
action, so a plugin whose natural contribution is a short list of frequent
choices (the Tags plugin's recently used tags) has nowhere to put them. This plan
lands the host half of a one-level submenu contract: an optional
`items(context)` beside the required `run`, a single defensive boundary that
reads plugin values, an entry key that is injective for any plugin-authored
string, and flattening that makes `primary` children reachable from the command
palette and the sidebar's task commands.
The plugin-facing half lives in its own repository and consumes this contract; it
is not part of this plan. Rendering needs no new component, because the menu entry
model already supports submenus and both renderers recurse.
Implementation is entirely in `apps/web` plus the plugin SDK type and two
documents. There is no backend, state, persistence or protocol change: a host that
predates `items` ignores the field, and `run` keeps its existing behavior, so the
contract is additive for every shipped plugin.
## Work orders
| Work order | Scope | Status |
| --- | --- | --- |
| [task-01-host-submenu-contract.md](task-01-host-submenu-contract.md) | The contract, the boundary, keys, icon resolution, palette flattening and their tests | done |
## Design notes
- `KanbanCardMenuEntry` already models `kind: "submenu"` for the built-in Move
to / Priority / Link menus, and `ContextEntry`/`DropdownEntry` recurse into
`children`, so cards, the shared task-row menu, the preview/detail menus and the
palette all inherit plugin submenus from one entry builder.
- The contract's fallback rule is what keeps one implementation valid on two host
generations: a host predating `items` reads only `label`/`icon`/`run`, and a
build whose `items()` yields nothing usable renders that same flat item.
- Plugin bundles are plain JavaScript, so the boundary is written for values the
registration types do not describe: a throwing getter, a Proxy, a thenable, a
`Symbol` id, a lone surrogate, an element where a component is expected.
- Keys are identity, not display: the palette uses an entry key as both a React
key and cmdk's value, which is why injectivity is a requirement rather than a
nicety.
## Verification
- `cd apps/web && ./node_modules/.bin/tsc --noEmit --incremental false`
- `cd apps/web && pnpm vitest run components/plugins/task-menu-actions.test.tsx components/kanban-card-edit-submenu.test.tsx components/kanban-card-menu-items.test.tsx components/kanban-card-menu-grouping.test.tsx components/kanban-card-plugin-context.test.tsx lib/kanban/task-actions-menu-entries.test.ts lib/plugins/sdk-contract.test.ts lib/plugins/icons.test.ts components/task/task-switcher-context-menu.test.tsx components/task-command-choices.test.tsx components/task-commands.test.tsx`
- `cd apps/web && ./node_modules/.bin/eslint --max-warnings 0 <changed files>` and `pnpm i18n:check`
- `python3 scripts/list-docs.py validate` and `python3 scripts/lint-spec-files.py --all`
- The consumer plugin's own suite (`node --test ui/bundle.test.js` in its
repository) exercises the declared `items`/`run` pair against this contract.
@@ -0,0 +1,97 @@
---
id: "01-host-submenu-contract"
title: "Host: task menu action submenu contract"
status: done
wave: 1
depends_on: []
plan: "plan.md"
requirements:
- REQ-PLUGINS-TASK-MENU-SUBMENUS-001
- REQ-PLUGINS-TASK-MENU-SUBMENUS-002
- REQ-PLUGINS-TASK-MENU-SUBMENUS-003
acceptance_criteria:
- AC-PLUGINS-TASK-MENU-SUBMENUS-001.1
- AC-PLUGINS-TASK-MENU-SUBMENUS-001.2
- AC-PLUGINS-TASK-MENU-SUBMENUS-001.3
- AC-PLUGINS-TASK-MENU-SUBMENUS-001.4
- AC-PLUGINS-TASK-MENU-SUBMENUS-001.5
- AC-PLUGINS-TASK-MENU-SUBMENUS-001.6
- AC-PLUGINS-TASK-MENU-SUBMENUS-002.1
- AC-PLUGINS-TASK-MENU-SUBMENUS-002.2
- AC-PLUGINS-TASK-MENU-SUBMENUS-002.3
- AC-PLUGINS-TASK-MENU-SUBMENUS-002.4
- AC-PLUGINS-TASK-MENU-SUBMENUS-002.5
- AC-PLUGINS-TASK-MENU-SUBMENUS-003.1
- AC-PLUGINS-TASK-MENU-SUBMENUS-003.2
- AC-PLUGINS-TASK-MENU-SUBMENUS-003.3
system_design:
- ../../specs/plugins/system-design/plugin-task-menu-submenus.md
---
# Task 01: Host task menu action submenu contract
- **Acceptance:**
1. `TaskMenuActionRegistration.items?(context)` is optional and synchronous in
the SDK and the web re-export; `run` stays required; a host that predates the
field renders the flat item (AC-PLUGINS-TASK-MENU-SUBMENUS-001.1).
2. A usable `items()` renders the action as a one-level submenu: `label` is the
unselectable trigger, children render in order under the same context, the
entry's own `disabled` reaches every child, and a child's own `disabled`
holds (AC-PLUGINS-TASK-MENU-SUBMENUS-001.2).
3. Every unusable result — absent, `null`, non-array, empty, a throw, a
thenable, or only-unusable children — falls back to the flat `run` item, with
the defect reported once per action and kind
(AC-PLUGINS-TASK-MENU-SUBMENUS-001.3,
AC-PLUGINS-TASK-MENU-SUBMENUS-001.4).
4. A child is read once inside the boundary's guard into a snapshot: blank or
non-string `id`/`label`, a non-callable `run`, a `disabled` that is neither
boolean nor `null`, and an `icon` that is none of name/component/element/
`null` all drop that child; a repeated `id` keeps the first occurrence; a
throwing getter or a Proxy fails only that child
(AC-PLUGINS-TASK-MENU-SUBMENUS-001.5).
5. No malformed registration can fail a render: an unusable action `label` or
`id` contributes no entry, the defect report never throws on a `Symbol`,
null-prototype or throwing-accessor registration, a `primary`-group submenu
whose entries are all unusable leaves the native `Edit` item flat, and the
key encoder neither throws on a lone surrogate nor collides for any two
plugin-authored ids (AC-PLUGINS-TASK-MENU-SUBMENUS-002.1 .. 002.4).
6. Icon resolution admits `memo`/`forwardRef` component objects, excludes
`lazy` and elements for non-menu surfaces, resolves own keys only, and keeps
the menu-only element tolerance documented
(AC-PLUGINS-TASK-MENU-SUBMENUS-002.5).
7. Submenu children of a `primary` action reach the command palette and the
sidebar's task commands, one command each, carrying the trigger label as
`context`; `edit` stays card-only; a caller that forces the flat `Edit` form
never reuses a prebuilt entry set
(AC-PLUGINS-TASK-MENU-SUBMENUS-003.1 .. 003.3).
- **Verification:**
- `cd apps/web && ./node_modules/.bin/tsc --noEmit --incremental false`
- `cd apps/web && pnpm vitest run components/plugins/task-menu-actions.test.tsx components/kanban-card-edit-submenu.test.tsx components/kanban-card-menu-items.test.tsx components/kanban-card-menu-grouping.test.tsx components/kanban-card-plugin-context.test.tsx lib/kanban/task-actions-menu-entries.test.ts lib/plugins/sdk-contract.test.ts lib/plugins/icons.test.ts components/task/task-switcher-context-menu.test.tsx components/task-command-choices.test.tsx components/task-commands.test.tsx`
- `cd apps/web && ./node_modules/.bin/eslint --max-warnings 0 <changed files>`
- `cd apps/web && pnpm i18n:check`
- `python3 scripts/list-docs.py validate`
- `node .github/scripts/pr-docs.cjs` (CI's documentation-coverage evaluator)
- **Files likely touched:**
- `apps/packages/plugin-sdk/src/index.ts`
- `apps/web/lib/plugins/types.ts`
- `apps/web/lib/plugins/icons.ts` (+ test)
- `apps/web/components/plugins/task-menu-actions.ts` (+ test)
- `apps/web/components/kanban-card-edit-submenu.tsx` (+ test)
- `apps/web/components/kanban-card-menu-items.tsx` (+ test)
- `apps/web/components/kanban-card-menu.tsx`
- `apps/web/components/task-command-choices.tsx` (+ test)
- `apps/web/components/task-command-items.tsx` (+ test)
- `apps/web/lib/kanban/task-actions-menu-entries.ts` (+ test)
- `apps/web/lib/plugins/sdk-contract.test.ts`
- `apps/web/AGENTS.md`, `docs/plans/plugins/PLUGIN-API.md`, `docs/public/plugins-authoring.md`
- **Dependencies:** None.
- **Parallelism:** sequential.
- **Inputs:**
- Requirements: docs/specs/plugins/requirements/plugin-task-menu-submenus.md
- System design: docs/specs/plugins/system-design/plugin-task-menu-submenus.md
- Existing submenu entry model and renderers: `apps/web/components/kanban-card-menu-items.tsx`
- Plugin contract documentation: `docs/plans/plugins/PLUGIN-API.md`, `docs/public/plugins-authoring.md`
- Consumer expectations: the plugin repository's `ui/bundle.js` registration and its tests
- **Output contract:** summary, files changed, exact verification commands with
results, the adversarial review rounds with their dispositions, task status →
`done`, plan checkbox update.
+66 -4
View File
@@ -900,6 +900,7 @@ The continuation-renew endpoint from the predecessor transport is not part of
the source contract. Load-more and retry use a current source read and preserve
the public state until that read succeeds. No browser code imports a persistence
store or writes conversation history.
## `registry: PluginRegistry`
```ts
@@ -916,6 +917,11 @@ store or writes conversation history.
// renders on no surface. Hosts predating a section value, or seeing an
// unrecognised one, simply degrade to "main"'s placement — nothing is ever
// silently dropped.
// A curated name (`PLUGIN_ICONS` in the host) or a plugin-owned component.
// Task menu entries additionally render a ready-made React element unchanged,
// for plugins that registered one before icons were resolved this way; that
// tolerance is menu-only (other surfaces map an element to the fallback glyph)
// and is not part of the type.
type PluginIcon = string | React.ComponentType<{ className?: string }>;
export type PluginNavSection =
| "main"
@@ -1123,7 +1129,8 @@ interface PluginRegistry {
// Contributes an item to the kanban card's Edit submenu (group "edit") or
// a flat, top-level card menu item after "Move to"/"Send to workflow"
// and before "Archive"/"Delete" (group "primary"). See "Kanban card contributions" below.
// and before "Archive"/"Delete" (group "primary"), optionally as a
// submenu of plugin-provided children (`items`). See "Kanban card contributions" below.
registerTaskMenuAction(registration: TaskMenuActionRegistration): void;
// Contributes a client-side filter section to the kanban board's display
@@ -1415,15 +1422,39 @@ interface PluginTaskMenuContext {
presentation: PluginPresentation; // the actual kanban layout: desktop or mobile
}
interface TaskMenuSubItemRegistration {
id: string; // unique within the action; contributes to the entry's React key
label: string;
icon?: PluginIcon;
disabled?: boolean;
run(context: PluginTaskMenuContext): void | Promise<void>; // a rejection is caught and logged
}
interface TaskMenuActionRegistration {
id: string;
label: string;
icon?: React.ReactNode;
icon?: PluginIcon;
// "edit" nests the item in the card's Edit submenu; "primary" renders it
// as a flat, top-level item after the "Move to"/"Send to workflow"
// submenus and before the "Archive"/"Delete" items.
group: "edit" | "primary";
visible?(context: PluginTaskMenuContext): boolean; // default: always visible
// Declaring this renders the action as a submenu instead of a flat item:
// `label` becomes an unselectable trigger and these are its children, in
// order. Must be synchronous, and is evaluated on every menu build (see
// "Kanban card contributions"). Nesting stops at this one level. A child
// needs a non-blank id and label, a callable run, and optional fields of the
// shapes above (a boolean or `null` `disabled`, an `icon` that is a name,
// component or element; `null` means absent for both) -- ids unique within the
// action: children the host cannot read or render are dropped (and reported),
// duplicate ids keep their first occurrence, and a result with nothing usable
// left falls back to `run`.
items?(
context: PluginTaskMenuContext,
): readonly TaskMenuSubItemRegistration[];
// Flat behavior, and the fallback whenever `items` is absent, yields no
// entries, or throws (caught and logged) -- so a host that predates
// `items` still renders a working flat item.
run(context: PluginTaskMenuContext): void | Promise<void>; // a rejection is caught and logged
}
@@ -1535,13 +1566,44 @@ action calls `run(context)`; a rejected promise is caught and logged to the
console, and the menu still closes either way (Radix's own close-on-select,
independent of the async result).
Group `"primary"` renders each visible action as its own flat, top-level menu
item instead of nesting it under `Edit`. It appears after the movement items
Group `"primary"` renders each visible action as its own top-level menu item
instead of nesting it under `Edit`. It appears after the movement items
and before the Archive/Delete items on cards and on the shared desktop/mobile
task-row menu. Group `"edit"` remains card-only. Visibility filtering,
registration order, and `run()`/error handling are identical; the two groups
are independent lists (an action only ever belongs to one).
An action from either group that declares `items(context)` renders as a
submenu instead of a flat item: `label` is the trigger (there is nothing to
run on the trigger itself), and the returned items are its children in order,
each invoked with the same `PluginTaskMenuContext` as the action. `items()` is
called synchronously while the host builds that card's or row's menu entries —
on every render, with a card's dropdown and context variants sharing one
evaluation, whether or not a menu is open — so it must read cached state rather
than fetch, and anything expensive behind it should be memoized on the state it
reads. A child's `disabled` (or the
action's own host-level disabled state, e.g. while a row-local move is
running) still renders the entry, unlike `visible()`, which filters.
`run` is not a second action for a submenu: it stays the flat behavior for a
host that predates `items` — such a host ignores the unknown field and renders
the item it has always rendered — and the fallback whenever `items` yields no
usable children. That boundary is deliberately runtime-defensive, because a
bundle is plain JavaScript and these types are not enforced at run time: an
empty list, a throw, a promise (the contract is synchronous, and its rejection
is observed so it cannot escape as an unhandled rejection), a non-array, and an
array whose entries lack a non-blank `id` or `label`, a callable `run`, a
boolean `disabled` or a recognizable `icon` (a name, component, element or
`null`) all fall back to the flat item instead of crashing the render or
producing a trigger nothing can open. Children are read once, inside the same guard, so a
throwing getter or a Proxy fails that child rather than the card's render;
unusable children are dropped when others remain, duplicate ids keep their
first occurrence, and each defect is logged once per action and kind rather
than on every menu build. An action can therefore ship both: a
quick child list on a host that supports it, and its existing flat behavior
elsewhere. Submenu nesting stops at this one level; a `run` rejection is
caught and logged, and the menu closes either way.
`"task-card-indicators"` (documented above with the other slots) is the
matching read-only surface: a small icon/badge rendered beside the PR status
icon on every card, receiving `{ taskId, workspaceId, workflowStepId }`.
+36 -2
View File
@@ -456,7 +456,7 @@ closing future reads.
| registerTaskAction | Child action inside the task menu's native Link section | Active ui.bundle | Action is revoked on unload; host supplies current task/workspace and desktop/mobile presentation | registry.registerTaskAction({ id: "link-pr", placement: "link", ... }) |
| registerReviewProvider | Normalized task reviews, workspace associations, unlink, and shared Review panel | ui.bundle and matching `repository_providers[]` id | Snapshots/subscriptions are owner-scoped and revoked on unload; host owns status chrome, indicators, unlink UI, and responsive Review placement | registry.registerReviewProvider({ id: "acme", ...reviews }) |
| registerTaskPanel | { id, title, titleKey?, icon?, Component, mobileEnabled?, visible?(context) }; adds a row to the task workspace's "+" (add panel) menu; Component receives { panelId, taskId, sessionId, sessionKind, presentation, conversation: { openMessage(messageId), history } }; `titleKey` is a plugin translation key with literal `title` fallback | Active ui.bundle | Panel renders behind its own error boundary with reactive localized titles; a throwing `visible` hides the item; handles are generation-bound and independently scoped, inert after unmount, identity change, disable, reload, or uninstall; `host.conversation` outside a panel returns stable empty state; desktop preserves layout identity on navigation and mobile uses the full-height Chat surface | registry.registerTaskPanel({ id: "notes", title: "Notes", titleKey: "panels.notes", Component: NotesPanel }) |
| registerTaskMenuAction | { id, label, icon?, group: "edit" \| "primary", visible?(context), run(context) }; "edit" is card-only inside Edit, while "primary" is a flat item on cards and desktop/mobile task-row menus | Active ui.bundle | Action is revoked on disable/uninstall; a throwing/rejecting run is caught and logged | registry.registerTaskMenuAction({ id: "enhance", label: "Enhance", group: "primary", run: doEnhance }) |
| registerTaskMenuAction | { id, label, icon?, group: "edit" \| "primary", visible?(context), items?(context), run(context) }; "edit" is card-only inside Edit, while "primary" is a top-level item on cards and desktop/mobile task-row menus; a synchronous `items(context)` returning TaskMenuSubItemRegistration[] ({ id, label, icon?, disabled?, run(context) }) renders the action as a submenu of those children instead | Active ui.bundle | Action is revoked on disable/uninstall; a throwing/rejecting run is caught and logged; an items() that throws or yields nothing usable falls back to the flat item, and unusable children are dropped | registry.registerTaskMenuAction({ id: "enhance", label: "Enhance", group: "primary", run: doEnhance }) |
| registerTaskFilter | { id, label, getOptions(), matches(context, selected) }; adds a client-side, multi-select filter section to the kanban board's display dropdown, alongside Workflow/Repository | Active ui.bundle | Filter is revoked on disable/uninstall; selections are ephemeral (not persisted); matches is only called for a non-empty selection, and a throw is caught, logged, and treated as non-matching | registry.registerTaskFilter({ id: "tags", label: "Tags", getOptions: listTagOptions, matches: taskHasSelectedTag }) |
| registerTaskListFacet | { id, label, getValues({ taskId, workspaceId }), subscribe? }; adds page-local Sort and Group choices on `/tasks` | Active ui.bundle | Values apply only to the loaded page, callbacks are isolated, and registrations are revoked on disable/unload | registry.registerTaskListFacet({ id: "tags", label: "Tag", getValues: taskTags }) |
| host.React / host.jsx | Shared React instance and React.createElement alias | Active ui.bundle | No cleanup; never bundle a second React/Radix runtime | const h = host.jsx |
@@ -2523,7 +2523,7 @@ Host reader because event queues are bounded and delivery is best-effort.
`registerTaskMenuAction` adds an item to native task menus. Group `"edit"` is
card-only and nests inside the kanban card's `Edit` submenu. Group `"primary"`
renders as a flat top-level item on cards and desktop/mobile task-row menus.
renders as a top-level item on cards and desktop/mobile task-row menus.
`task-card-indicators` (see the named slots table) is the matching read-only
surface, rendered beside the PR status icon on every card. `task-card-tags`
is a sibling read-only surface with the same `slotProps` shape, mounted in its
@@ -2558,6 +2558,40 @@ logged, not left to crash the card; the menu closes either way. Group
including in desktop and phone task-row menus. Do not patch first-party task
components directly.
An action of either group that declares `items(context)` renders as a submenu
instead: `label` becomes an unselectable trigger and the returned
`TaskMenuSubItemRegistration` children are its entries, in order, each called
with the same `context` as the action. Nesting stops at that one level. A
`primary` action's children also reach the command palette and the sidebar's
task commands, one command each; `edit` stays card-only, as it always was. `items()` is synchronous and is called while the host builds that
card's or row's menu entries on every render, whether or not a menu is open; a
card's dropdown and context variants are built from one evaluation, so read
cached state and memoize anything expensive. `run` stays required: it is the
flat item a host that predates `items` renders, and the fallback whenever
`items` yields nothing usable (a non-array, an empty list, a promise, or a
throw, which is caught and logged). A child needs a non-blank, unique `id`, a
non-blank `label` and a callable `run`, with optional fields of the shapes the
entry builder understands: a boolean `disabled` (or `null`, meaning enabled) and
an `icon` that is a curated name, a component, a ready-made element or `null`. A child the host cannot read
or render is dropped, and one whose `id` repeats is reported and skipped.
```js
registry.registerTaskMenuAction({
id: "add-tag",
label: "Add tag...",
group: "primary",
// Optional. Turns this action into a submenu of these children.
items: (context) =>
recentTags(context).map((tag) => ({
id: tag.id,
label: tag.name,
run: (ctx) => applyTag(ctx.taskId, tag.id),
})),
// Flat behavior, and the fallback when `items` has nothing usable.
run: (context) => openTagPicker(context.taskId),
});
```
`registerTaskFilter` adds a client-side, multi-select filter section to the
kanban board's display dropdown, next to the built-in Workflow and Repository
sections. The plugin supplies its own options (including any "untagged"-style
@@ -0,0 +1,89 @@
---
status: draft
system: plugins
created: 2026-09-23
owners:
- kandev
---
# Plugin task menu submenus Requirements
## Overview
A plugin task-menu action has always been exactly one selectable item. A plugin
whose natural contribution is a short list of frequent choices therefore has
nowhere to put them: the Tags plugin opens a modal picker so the user can add one
of the workspace's recent tags to a card, one menu open plus one modal plus one
click. Registering five sibling actions instead would flood every card's menu
with rows that are mostly noise.
`registerTaskMenuAction` gains an optional `items(context)` beside its required
`run`. Declaring it renders the action as a one-level submenu whose children the
plugin supplies at menu-build time; the children reach the card menu, the shared
desktop/mobile task-row menu, the command palette and the sidebar's task
commands. `run` stays required and stays the flat behavior, which is the whole
compatibility story: a host that predates the field ignores it, and a build whose
`items()` yields nothing usable falls back to the same flat item, so an action
can ship both a quick child list and its existing behavior.
Plugin bundles are plain JavaScript, so the registration types are a promise
rather than a guarantee. Everything here is shaped by that: the host reads plugin
values defensively at one boundary, degrades to the flat item or drops a single
child instead of failing a render, and reports what it dropped.
## Requirements
### REQ-PLUGINS-TASK-MENU-SUBMENUS-001: A task menu action can declare a one-level submenu
**Intent:** A plugin with a short list of frequent, per-card choices needs to
offer them in one click each without adding sibling rows to every card's menu.
The host renders the action as a submenu when, and only when, the plugin supplies
children it can actually render.
#### Acceptance criteria
- **AC-PLUGINS-TASK-MENU-SUBMENUS-001.1:** `TaskMenuActionRegistration.items` SHALL be optional and synchronous, returning `readonly TaskMenuSubItemRegistration[]`; `run` SHALL remain required. A host that predates `items` SHALL ignore the unknown field and render the flat item it has always rendered, reading only `label`, `icon` and `run`.
- **AC-PLUGINS-TASK-MENU-SUBMENUS-001.2:** When `items()` returns at least one usable child, the action SHALL render as a submenu: `label` becomes an unselectable trigger, each child becomes a selectable entry in the returned order, each child's `run` is invoked with the same `PluginTaskMenuContext` as the action, and a child is unselectable when either the child's own `disabled` or the host's entry-level `disabled` is set. Nesting SHALL stop at one level: a child is never itself a submenu.
- **AC-PLUGINS-TASK-MENU-SUBMENUS-001.3:** A result with nothing usable SHALL fall back to the action's flat `run` item rather than producing a trigger nothing can open. Nothing usable means: `items` absent, `null`/`undefined`, a non-array, an empty array, a thrown exception, a thenable (the contract is synchronous), or an array whose every element is unusable. A thrown `items()` SHALL be caught and reported.
- **AC-PLUGINS-TASK-MENU-SUBMENUS-001.4:** A child the host cannot render SHALL be dropped, and the drop SHALL be reported once per action and defect kind rather than once per menu build. Unusable means: `id` or `label` absent, not a string, or blank after trimming; `run` not callable; `disabled` present and neither boolean nor `null`; `icon` present and neither a curated name, a component (including React's `memo` and `forwardRef`, which are objects at run time), a ready-made element, nor `null`. A child may be read only inside a guard, so a throwing getter or a Proxy fails that child instead of the card's render.
- **AC-PLUGINS-TASK-MENU-SUBMENUS-001.5:** A repeated child `id` SHALL keep its first occurrence and report the repeat, so the host never builds two children with one key. `icon: null` and `disabled: null` SHALL both mean "absent", matching how a JavaScript bundle spells an omitted field.
- **AC-PLUGINS-TASK-MENU-SUBMENUS-001.6:** `items()` SHALL be evaluated while the host builds that card's or row's menu entries, which happens on every render and for both the dropdown and the context variant whether or not a menu is open. The API documentation SHALL say so and SHALL tell authors to read cached state and memoize anything expensive.
### REQ-PLUGINS-TASK-MENU-SUBMENUS-002: An unrenderable registration never fails a host render
**Intent:** One plugin's malformed registration must not take down a card, a
board, or a command surface. The host's own contract says unusable input degrades;
these criteria make that true for the values a real bundle can produce, including
values whose own accessors throw.
#### Acceptance criteria
- **AC-PLUGINS-TASK-MENU-SUBMENUS-002.1:** A registration whose own `label` or `id` is not a usable string SHALL contribute no entry at all, and SHALL be reported once, instead of rendering an unselectable or unkeyable entry.
- **AC-PLUGINS-TASK-MENU-SUBMENUS-002.2:** The host's defect report SHALL NOT throw while reporting: a `Symbol` id, an object without a string form, or a throwing accessor on the registration SHALL still be reported or skipped without an exception escaping the builder.
- **AC-PLUGINS-TASK-MENU-SUBMENUS-002.3:** A group `edit` registration the host cannot render SHALL leave the card's native `Edit` item flat, exactly as if no plugin action were registered, rather than wrapping it in a submenu with no plugin child under it.
- **AC-PLUGINS-TASK-MENU-SUBMENUS-002.4:** An entry key SHALL be unique for any plugin-authored `pluginId`, action `id` and child `id`, without throwing for any string a bundle can produce, including a lone surrogate (the shape `slice` and `[0]` produce from an astral character). Because the key is also a palette row's React key and cmdk value, two entries SHALL never share one.
- **AC-PLUGINS-TASK-MENU-SUBMENUS-002.5:** A plugin-provided value that is not a usable icon SHALL resolve to the surface's fallback glyph rather than reaching `createElement` as an element type. Context menus SHALL additionally render a ready-made element unchanged, which is the shape plugins registered before icon resolution existed still send; that tolerance SHALL be documented as menu-only.
### REQ-PLUGINS-TASK-MENU-SUBMENUS-003: Submenu children are reachable from the command surfaces
**Intent:** A card's plugin contribution should be usable from the same
task-action surfaces that already list card actions, rather than only inside the
card's own menu.
#### Acceptance criteria
- **AC-PLUGINS-TASK-MENU-SUBMENUS-003.1:** A `primary`-group submenu SHALL contribute one command per child to the command palette and the sidebar's task commands, each carrying the trigger's label as its `context` so a bare child label stays identifiable; the sidebar SHALL keep the task title as the default `context` for commands that do not set their own.
- **AC-PLUGINS-TASK-MENU-SUBMENUS-003.2:** Group `edit` SHALL remain card-only: its submenu children SHALL NOT appear in the command palette or the sidebar's task commands.
- **AC-PLUGINS-TASK-MENU-SUBMENUS-003.3:** A surface that reuses already-built plugin entries SHALL produce them from the same inputs it renders with, and a caller that forces the flat `Edit` form SHALL NOT receive a prebuilt set that could contain the nested `Edit` submenu.
## Exclusions
- No nested plugin submenus: a child is a selectable item, never another submenu.
- No asynchronous `items()`: a menu entry cannot await, so a thenable is treated as a contract violation and falls back.
- The palette lists submenu children as flat commands; it does not render a nested palette submenu.
- Group `edit` gains no palette or sidebar placement.
- The host does not validate or repair a plugin's ids, labels or icons at registration time; malformed input is handled where it is used.
## System design
The technical source for these requirements is
[plugin-task-menu-submenus](../system-design/plugin-task-menu-submenus.md).
@@ -0,0 +1,192 @@
---
status: draft
system: plugins
requirements:
- REQ-PLUGINS-TASK-MENU-SUBMENUS-001
- REQ-PLUGINS-TASK-MENU-SUBMENUS-002
- REQ-PLUGINS-TASK-MENU-SUBMENUS-003
created: 2026-09-23
owners:
- kandev
---
# Plugin task menu submenus System Design
## Purpose and boundaries
This design covers the host half of plugin task-menu submenus: the registration
contract, the one boundary that reads plugin values, the entry builder every card
and row surface uses, and the flattening that makes submenu children reachable
from the command palette and the sidebar's task commands.
It does not cover the plugin-facing authoring guide itself, the plugin-side
implementation that consumes the contract, or host-owned menu entries other than
plugin actions. Rendering changed no component: `KanbanCardMenuEntry` already
models `kind: "submenu"` for the built-in Move to / Priority / Link menus and both
renderers recurse, so cards, the shared desktop/mobile task-row menu, the
preview/detail menus and the palette inherit submenus from one entry model.
## Requirement mapping
| Requirement | Design section |
| --- | --- |
| `REQ-PLUGINS-TASK-MENU-SUBMENUS-001` | [Registration contract](#registration-contract), [Entry building](#entry-building), [The reading boundary](#the-reading-boundary) |
| `REQ-PLUGINS-TASK-MENU-SUBMENUS-002` | [The reading boundary](#the-reading-boundary), [Keys and identity](#keys-and-identity), [Icon resolution](#icon-resolution) |
| `REQ-PLUGINS-TASK-MENU-SUBMENUS-003` | [Command surfaces](#command-surfaces), [Shared entry reuse](#shared-entry-reuse) |
## Registration contract
`TaskMenuActionRegistration` gains optional `items?(context)`. `run` stays
required, so there is one contract for two host generations: a host predating the
field reads only `label`, `icon` and `run` and renders the flat item, and a build
whose `items()` yields nothing usable does the same. The host never awaits a menu
item, so `items()` is synchronous by contract.
`TaskMenuSubItemRegistration` is deliberately smaller than the parent: `id`,
`label`, optional `icon` and `disabled`, and `run`. It has no `group` and no
`visible`, because a child exists only inside its parent's submenu, and its own
nesting is not supported.
## Entry building
`pluginMenuEntry` produces exactly one `KanbanCardMenuEntry | null` per
registration: a `submenu` when `pluginSubItems` returns usable children, otherwise
a flat `item`, otherwise `null` for a registration the host cannot render. Both
callers — `buildPrimaryPluginEntries` (group `primary`) and `buildEditMenuEntry`
(group `edit`, nested inside the native `Edit` item) — filter that `null`.
`buildEditMenuEntry` builds and filters the plugin entries **before** choosing
between the submenu and the flat item, so a registration list that yields nothing
still leaves the native `Edit` item exactly as it was rather than wrapping it in a
submenu with no plugin child (AC-PLUGINS-TASK-MENU-SUBMENUS-002.3).
`buildCardPluginEntries` builds the two plugin-derived pieces of a card menu once
per render from the card's own inputs, and `useKanbanCardMenus` passes that result
to both the dropdown and the context variant, so a plugin's `items()` runs once per
card per render rather than twice.
## The reading boundary
`pluginSubItems` is the single place where host code reads a plugin's `items()`
result, and its whole body is one `try`. A plugin object may throw from any
property access — including the array methods a naive implementation would call
and the `then` lookup a naive thenable check would perform — so:
- the result is copied with `Array.from`, which never calls a method on the
plugin's own object;
- every child is read once, inside the guard, into a snapshot of plain values, so
a throwing getter or a Proxy fails that child and nothing is read twice;
- `id` and `label` must be non-blank strings after trimming, `run` must be
callable, `disabled` must be a boolean or `null`, and `icon` must be a curated
name, a component, a ready-made element or `null`;
- duplicate ids keep their first occurrence and the repeat is reported;
- a throw, a thenable, a non-array, an empty list or an all-unusable list return
`null`, which is AC-PLUGINS-TASK-MENU-SUBMENUS-001.3's flat fallback.
Defects are reported through one helper keyed by action identity **and kind**, so
a registration that fails every render is reported once while a later, different
defect on the same action still reports. The helper never interpolates a raw
plugin value into a message: template interpolation calls `ToString`, which throws
for a `Symbol` id or a null-prototype object, and the omission paths are exactly
where such a value arrives (AC-PLUGINS-TASK-MENU-SUBMENUS-002.2). The read of the
registration's own `label`, `id` and `icon` is guarded for the same reason, and a
registration missing any of them contributes no entry.
The registry also reads each action inside a guard when it filters by group and
copies the registration. It skips an action whose getter or Proxy throws, reports
that registration once, and keeps other actions available. It does not validate
the registration when the plugin registers it.
The set of already-reported defects is module state that is never cleared. A
defect of the *same* kind that recurs on the same action after a plugin generation
swap therefore stays silent for the life of the page; that is a deliberate
tradeoff for a diagnostic line, and it is documented at the helper rather than
implied.
## Keys and identity
A plugin entry key is `"<prefix>-<encoded pluginId>%<encoded action id>"`, and a
child's key is `"<parent key>#<encoded child id>"`. Both ids are plugin-authored
strings and the palette flattens every plugin entry into one list where a key is
both a React key and cmdk's value, so the encoding has to be injective for any
string a bundle can produce.
`encodeKeyPart` percent-encodes per UTF-16 code unit, escaping everything
`encodeURIComponent` would not leave alone. Two properties matter:
- it cannot throw. `encodeURIComponent` raises `URIError` on a lone surrogate,
which `slice` or `[0]` produce from an astral character, and that exception
leaves the builder a card calls during render with no error boundary above it.
- it is injective. `%` is itself escaped, and every escape is exactly four hex
digits, which makes each token self-delimiting so no escaped unit can run into
the literal characters that follow it — `"%0"` and `"\u0250"` must not share a
key, and neither may two different `(pluginId, actionId)` pairs whose ids
differ only in how a dash join would spell them.
A child key always contains the `#` separator and a flat key never does, because
encoded ids contain neither `#` nor a bare `%`, so a child key can never equal a
flat action's key.
## Icon resolution
`lookupPluginIcon` resolves a curated name for strings only, and passes through a
component — including React's exotic components. `memo` and `forwardRef` return
objects that are callable per their types but not at run time, and
`@tabler/icons-react`, the set the curated map itself is built from, produces
exactly that shape, so a plain `typeof` check would reject the host's own icons.
`lazy` is excluded deliberately: it cannot resolve synchronously in a menu and
would suspend the menu's content. Elements are excluded here too, because this
function's result is passed to `createElement` by every non-menu surface; the
menu's own path returns an element unchanged before reaching it, which is the
documented, menu-only element tolerance (AC-PLUGINS-TASK-MENU-SUBMENUS-002.5).
The lookup resolves own keys only, so `PLUGIN_ICONS["__proto__"]` or
`["constructor"]` cannot resolve through the prototype chain to a non-icon.
## Command surfaces
`pluginCommandChoices` walks a built entry and turns a submenu into one command
per selectable child, carrying the trigger's label as the command's `context`;
`buildSidebarTaskCommands` preserves a command's own `context` and keeps the task
title as the default for everything else. The palette feeds it group `primary`
entries only, so `edit` stays card-only
(AC-PLUGINS-TASK-MENU-SUBMENUS-003.1, AC-PLUGINS-TASK-MENU-SUBMENUS-003.2). A
command's id is the entry key, which is what makes the two properties above matter
to the palette as well.
## Shared entry reuse
`BuildKanbanCardMenuEntriesArgs` accepts prebuilt plugin entries so one card
render can share them between its variants. Because those entries carry a disabled
state, an edit handler and a menu context, `forceFlatEdit` outranks a prebuilt set
inside `buildKanbanCardMenuEntries`, and the non-card menu tiers additionally drop
any set they are handed: a surface that forces the flat `Edit` form must never
render the nested `Edit` submenu those entries could contain
(AC-PLUGINS-TASK-MENU-SUBMENUS-003.3). The field is documented as valid only for a
call whose inputs match the ones the entries were built from; it is not
runtime-guarded, because no caller passes a mismatch today and the guard would
need the inputs carried alongside for a hazard that does not exist yet.
## Compatibility and failure behavior
- A host predating `items` ignores it: the field is additive and `run` is
unchanged, so every existing plugin keeps its exact behavior.
- A registration the host cannot render contributes nothing and is reported,
rather than rendering a broken row.
- A child the host cannot render is dropped, reported once, and the remaining
children still render; if none remain the action falls back to its flat item.
- An exception anywhere in the plugin's `items()`, or in reading a registration's
own fields, degrades at the boundary instead of propagating out of a render.
## Amendment log
**Amendment 1 (2026-09-23), from the adversarial review loop on PR #3874.** The
first implementation of this design used `encodeURIComponent` for the key parts
and a variable-width escape for the fallback encoder, and reported defects with a
raw template interpolation of the action's identity. Three defects came out of
review, each with a reproduction: an id containing a lone surrogate threw
`URIError` out of the card's render; `"%0"` and `"\u0250"` collided because a
two-digit escape is not self-delimiting; and a `Symbol` or null-prototype id made
the defect report itself throw. The contract is unchanged; the implementation now
escapes per code unit at a fixed width, reads plugin values defensively on every
path that reports them, admits `memo`/`forwardRef` component objects while
excluding `lazy` and elements, treats `null` as absent for both `icon` and
`disabled`, and keeps the native `Edit` item flat when no plugin entry wraps it.