20 KiB
status, system, requirements
| status | system | requirements | ||||
|---|---|---|---|---|---|---|
| draft | tasks |
|
Task Actions Menu on Preview and Detail Surfaces System Design
Purpose and boundaries
This design places the existing task actions menu on the desktop preview panel and the desktop task detail top bar. It owns how a surface obtains the subject task, how the shared entry list is built for that surface, and what each confirmed action does to task state and navigation.
Contracts this design uses but does not own:
- The task update, archive, delete, move, detach, and link request contracts, owned by their existing task-system requirements.
- The archive confirmation contract, owned by Archive confirmation.
- The dropdown, context-menu, popover, and dialog primitives, owned by the UI system.
- The plugin task-menu action contract, owned by the plugin system.
Requirement mapping
| Requirement | Design section |
|---|---|
REQ-TASKS-TASK-ACTIONS-MENU-001 |
Components and responsibilities |
REQ-TASKS-TASK-ACTIONS-MENU-002 |
Data and contracts |
REQ-TASKS-TASK-ACTIONS-MENU-003 (own file) |
Control flow |
REQ-TASKS-TASK-ACTIONS-MENU-004 (own file) |
Failure and recovery |
Components and responsibilities
Shared menu state (new). The Kanban card today builds its entry list and
its dialog state inside a card-scoped hook that also carries board concerns:
board-supplied edit/delete/archive/move callbacks, the multi-selection, and the
card's own step list. Parity in
AC-TASKS-TASK-ACTIONS-MENU-002.1 cannot be guaranteed by a second,
independent implementation, so the surface-independent part of that hook is
extracted into a shared hook that takes a subject task and a presentation, and
returns the entry list plus the dialog state. The card hook keeps the
board-only concerns (multi-selection move targets, board callbacks) and
composes the shared one. The extracted hook carries no pending state: each
caller supplies its own in-flight flags, which is what keeps the card's existing
board-scoped flags working untouched and what makes the surface-local rule below
fall out of the structure rather than needing enforcement.
This extraction sits on the board's hot render path, one instance per visible card. It must not change the card's rendered entries, their identity keys, or the number of store subscriptions per card. That is the constraint the implementation is held to, and the existing card menu tests are the evidence.
Preview trigger (new). Renders inside the preview header's control group, before Maximize. The preview already receives the full task object, so its subject task needs no store lookup.
Detail trigger (new). Renders inside the top bar's right control group,
after the tools cluster. The top bar receives only identifiers, so it resolves
its board row from the store (see below) and degrades per
AC-TASKS-TASK-ACTIONS-MENU-002.5 when the row is absent.
Dialog host (new, one per surface). Mounts the confirmation and link dialogs the entries open. Each surface hosts its own so a dialog outlives the menu that opened it and is not nested inside a portal that unmounts on select. The single-task Change workflow... entry opens the shared form from Change workflow; that design owns its task snapshot, draft, preview, version check, in-flight guard, and result recovery.
Data and contracts
Subject task resolution.
- Preview surface: the task object the panel already receives. When it is
nullthe trigger is not rendered. - Detail surface: the task identifier from the route, plus a board-row lookup against the workflow snapshots with the flat task list as fallback (the same lookup the sidebar and the shared archive-confirmation hook already perform from inside the detail view, so the snapshots are populated there).
Entry availability tiers. Two tiers, which is what makes
AC-TASKS-TASK-ACTIONS-MENU-002.5 implementable:
- Identifier-only: Archive, Delete. Available whenever a task identifier is known.
- Board-row: Edit, Priority, Move to, Change workflow, Link, Detach from parent. Each needs fields carried by the board row (priority, workflow membership, step, repositories, or parent task id), and is omitted while that row is unresolved.
The tiers describe availability, not precedence. An archived subject is filtered by the archived rule first, which is why Archive -- an identifier-only entry -- is still absent for it.
The tiers are evaluated live. The entry list is derived from store state on each
render rather than captured when the menu opens, so a board row that resolves
while the menu is open promotes the board-row entries in place
(AC-TASKS-TASK-ACTIONS-MENU-002.6). Nothing needs to close and reopen the menu
to converge. Two things do close it: the subject task disappearing
(AC-TASKS-TASK-ACTIONS-MENU-004.5) and the subject being replaced by a
different task (AC-TASKS-TASK-ACTIONS-MENU-004.5a). Losing the board row is
neither, and demotes entries in place.
There is no memoized-at-open tier. Plugin entries are live on exactly the
same terms as every other entry (AC-TASKS-TASK-ACTIONS-MENU-002.8), including
inside a menu that is currently open. This matches the card rather than relaxing
against it: usePluginRegistry() is a useSyncExternalStore subscription that
re-renders the card on any registry mutation, and the card's entry list is
rebuilt unmemoized on each render, so an action a plugin registers after the
card mounted already appears and one whose plugin was just disabled already
stops appearing. A single liveness rule for every tier is also the cheaper
contract to hold: a plugin-only exception would need its own snapshot point,
its own invalidation, and its own tests, to buy nothing a user can see.
Ordering. The entry order is fixed by the shared builder, so parity is structural rather than asserted per surface.
For a normal resolved row, the top-level order and dividers follow Task menu grouping. The Priority submenu uses the row's current priority and the same update hook as the card. If the card builder omits an entry because its callback is unavailable, the new surface omits that entry too.
Step ordering is ascending position, tie-broken by ascending step id. That
rule is already implemented, once, by sortWorkflowStepsByPosition
(lib/kanban/auto-hide-empty-columns.ts), and the card's Move to submenu
already renders through it: the board derives its move targets with that helper
and passes them down, and the shared move-target derivation then uses that
supplied list verbatim for the current workflow. So the tiebreak is not new
behavior being introduced here, and the card's Move to submenu does not change.
The existing card menus retain their workflow-target ordering. The shared
Change workflow form sorts destination steps from its loaded workflow snapshot
by position, then step id, as defined by its requirement. This keeps the
explicit form selection deterministic without changing the bulk workflow path.
Two exclusions travel with the target list. User-hidden steps are filtered out
for the current workflow and not for other workflows, which is what the card
does today and what AC-TASKS-TASK-ACTIONS-MENU-002.3b reproduces rather than
corrects. The orphan sentinel __kandev_orphan__ ("Needs Reassignment") is a
display-only node synthesized per view and never reaches a snapshot, so it is
absent from these surfaces' targets by construction; the criterion states it so
that a future view-specific target list cannot leak it in.
Workflow ordering is the workflow collection's own order, minus hidden workflows, unchanged.
Plugin action groups. Only the primary group crosses to these surfaces.
Group edit actions, which the card nests under its Edit entry, are documented
as card-only (docs/plans/plugins/PLUGIN-API.md), and this design does not
widen that contract from the task system's side, so the Edit entry here is
always the flat item and never the card's submenu form
(AC-TASKS-TASK-ACTIONS-MENU-002.2a). Concretely, buildEditMenuEntry returns
a flat item when no edit-group action is visible and a submenu when one is;
these surfaces take the flat form unconditionally rather than calling that
builder with the plugin context.
Archived subject. The archived case reaches only the detail surface: the
board excludes archived tasks, so the preview panel cannot hold one. That same
exclusion is why an archived subject is normally board-row-unresolvable too, so
the archived rule and the unresolved-row rule overlap by default rather than by
accident. AC-TASKS-TASK-ACTIONS-MENU-002.4a resolves the overlap in one
direction: archived wins, and Archive is not offered on a task that is already
archived.
The archived entry set is therefore the admitted plugin primary actions, a
separator, and Delete -- stated in full because the card menu has no archived
branch for it to inherit from (buildKanbanCardMenuEntries takes no archived
input). "Admitted" is the ordinary visible(context) result in registration
order; the plugin task-menu context carries no archived signal and this design
does not add one, so archived-awareness stays the plugin's own concern.
The detail surface already receives isArchived, so no new input is needed to
evaluate this branch.
Accessible names and test ids. Both triggers use the existing
More options string. Because the preview renders beside a board whose cards
carry the same accessible name, each trigger also carries a surface-specific
test id (task-preview-actions-menu, task-topbar-actions-menu) so a test can
address one surface unambiguously.
Localization. No new key. Every entry label and both trigger names already
exist in en, pt-pt, zh-cn, zh-tw, zh-hk, and pseudo.
Control flow
- The user activates a surface's trigger. The surface opens its menu. No navigation, no active-task change, no preview close: the trigger stops pointer and click propagation so the surrounding header controls and the board's card click dispatch never see the event.
- Selecting an entry closes the menu and either opens a dialog hosted by that surface or dispatches a move.
- Archive and Delete open the shared confirmation; nothing is requested until
the user confirms.
3a. Except when the archive-confirmation preference is off. That preference is
read inside the shared confirmation component, not by its callers: when it
is disabled the component renders no dialog, immediately invokes its confirm
callback with no cascade, and closes. A surface that mounts the same shared
confirmation therefore inherits the preference-honouring path for free, which
is why
AC-TASKS-TASK-ACTIONS-MENU-003.1ais parity with the card rather than an exception to it: the card mounts that same component. Neither new surface reads the preference itself, and this design adds no second bypass. - On confirmed Archive or Delete:
- Preview surface: the removal prunes the task from the board collections. The preview's existing missing-subject guard then closes the panel. No new navigation code path is introduced.
- Detail surface: the removal routes through the existing
archive-and-switch and remove-from-board flows, which pick the next
eligible task or fall back to the task overview.
4a. Escape while a preview menu is open must close only the menu
(
AC-TASKS-TASK-ACTIONS-MENU-001.11). The preview panel's own Escape handler is currently a barewindowkeydown listener that does not consultdefaultPrevented, so without a change it would close the panel on the same keypress that closes the menu. The panel's handler is the one that changes: it must ignore an Escape the open menu has already handled. The menu primitive's own Escape behavior is not modified. 4b. The preview panel re-renders with a different subject task rather than unmounting, so an open menu can outlive the task it was opened on, and its terminal entries include Delete. Both triggers therefore use the default modal dropdown behavior the card already uses, so a single outside click dismisses the menu rather than also landing on another card; and the preview additionally closes its menu when the subject's identity changes (AC-TASKS-TASK-ACTIONS-MENU-004.5a). Modality alone is not relied on: it is a presentation default a latermodal={false}could remove, whereas the identity check is behavioral. That check compares the subject's task id across renders, never the identity of the task object:useSelectedTaskmemoizes on the board's task collections and returns a freshly constructed object, so its result gets a new reference whenever any task on the board changes while the selected id stays put. Keying the close on the object would therefore close the menu on unrelated board churn and contradictAC-TASKS-TASK-ACTIONS-MENU-002.6, which requires an open menu to survive an in-place entry update. The detail route remounts on navigation and needs neither.
- On a move: the subject task alone is moved. Both surfaces stay on the task.
The detail top bar's stepper re-reads the current step from state.
5a. If the board row is lost while a Move to submenu is open, the live demotion
of
AC-TASKS-TASK-ACTIONS-MENU-002.6removes the submenu's parent entry. A Change workflow form stays open because it has its own task snapshot; if the task changes before submit, its version check refreshes the source and keeps the user's valid draft for review. The shared form contract owns this recovery. - Edit opens the existing edit dialog seeded from the subject task; save uses the existing task update contract.
Failure and recovery
In-flight state is surface-local, and that is a decision, not an oversight.
Each surface owns the pending flag for the requests it starts. There is no
task-keyed pending registry, and this design does not add one
(AC-TASKS-TASK-ACTIONS-MENU-004.1b).
The alternative -- lifting archive/delete/detach pending state into shared,
task-keyed store state so every surface showing the task disables together --
was rejected. It buys strict parity during a window that is hard to even
observe (AC-TASKS-TASK-ACTIONS-MENU-004.2 closes the menu on a terminal
activation, and archive/delete/detach are terminal, so the
disabled state is only reachable by reopening mid-flight), and it pays for that
with a new piece of client state architecture on the board's hot render path.
It would also be a divergence dressed as parity: the card has no such registry
either. isDeleting and isArchiving arrive as props from board-level state
(deletingTaskId === task.id in virtualized-column-task-list.tsx and
swimlane-graph2-content.tsx), and isDetaching comes from useDetachTask,
whose detachingTaskId is a useState inside the hook and therefore local to
each caller. Two cards for different tasks already do not share it, and nothing
outside the board can read it.
The consequence is explicit and testable: a card mid-archive and a preview panel
open on that same task will show different enabled states until the request
settles. AC-TASKS-TASK-ACTIONS-MENU-002.1 names this as its one in-flight
exception rather than leaving the parity rule to over-claim.
- In-flight archive, delete, and detach requests mark that surface's entries disabled, and the menu is already closed by the selection, so a second request cannot be started from the same menu. The disabled state is therefore observable by reopening the menu mid-flight, not by watching the menu that dispatched the request.
- Move to and Link are outside that rule by decision, not by omission
(
AC-TASKS-TASK-ACTIONS-MENU-004.1a). Link starts no task-state request on select; its dialog owns submit state. A single-task Change workflow selection opens a form with its own in-flight guard and source-version check. The bulk workflow submenu retains its existing independent move behavior. - Two surfaces acting on one task issue independent requests. The server is the arbiter: the losing request fails and raises the existing per-action failure feedback. No new error surface, no client-side lock across surfaces.
- Detach is the exception, because it is idempotent rather than one-shot
(
AC-TASKS-TASK-ACTIONS-MENU-004.3b). Two existing layers already guarantee that, and this design leans on both rather than adding a third: the client detach request layer keys in-flight requests by task id and hands a second caller the existing promise instead of issuing another request, and the detach endpoint treats a detach of an already-parentless task as success with no row update. A second detach of one task therefore produces no second request and no failure feedback. This is a REQUEST-level coalesce and does not contradictAC-TASKS-TASK-ACTIONS-MENU-004.1b, which governs the per-surface pending flag behind the disabled state, not the request layer. - When the losing request fails because the subject was archived or deleted by
another actor, the subject is gone, so the surface's missing-task behavior
wins over the keep-the-surface-open rule
(
AC-TASKS-TASK-ACTIONS-MENU-004.4a). The failure feedback is raised at application level, so it still reaches the user after the preview has closed or the detail route has switched away. - That cause is detected from store state, not from the failed response, and
that is the authoritative mechanism rather than an incidental race. The
surface never inspects the rejected request to decide whether the subject
survived; it reacts to the subject leaving the store, which is the same signal
AC-TASKS-TASK-ACTIONS-MENU-004.5already runs on. The response could not carry that decision anyway without a backend change this capability does not make: a delete of a removed task resolves through the handler's not-found branch, but the already-archived sentinel matches none of the handler's typed branches and is answered as an undifferentiated server error, so the archive path has no distinguishable losing-race status to read. Ordering between the rejection and the store update is therefore not load-bearing. If the rejection lands first, the surface stays open, shows the failure feedback, and closes when the store update arrives; if the store update lands first, the surface is already gone and the application-level feedback still reaches the user. If the store update never arrives, the surface simply stays open underAC-TASKS-TASK-ACTIONS-MENU-004.4, which is the safe default. - A failed request leaves the task at its last confirmed state and leaves the surface open on it. The archive-and-switch flow already restores the previous active task and route when its archive request rejects after an optimistic switch.
- A task removed by another client is handled by each surface's existing missing-subject behavior. The menu is closed by then, because the removal is observed through state rather than through a menu interaction.
Persistence
None. This capability adds no storage, no migration, and no restart behavior. All state is client-side and derived from the existing task store.
Security
None added. Every action reuses its existing request, so backend authorization is unchanged and no surface bypasses it.
Observability
None added. Failures surface through the existing per-action feedback, and failed archive and delete requests keep their existing console diagnostics.
Testing
- Unit: shared builder parity for a not-archived task, an archived task, and an unresolved board row; the step ordering tiebreak.
- Component: each trigger renders, opens, closes on Escape with focus returned, and does not activate its neighbouring controls.
- End-to-end: the two surfaces are user-visible, so each gets a scenario that
opens the menu by its surface test id and confirms Archive, asserting the
post-action outcome named in
REQ-TASKS-TASK-ACTIONS-MENU-003. The existing Kanban card-menu scenarios must continue to pass unchanged, as the evidence forAC-TASKS-TASK-ACTIONS-MENU-002.10.