feat(plugins): render a task menu action as a submenu (#3874)
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
@@ -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.
|
||||
@@ -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 }`.
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user