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