Files
kandev/docs/specs/tasks/system-design/threads-task-actions.md
T
2026-09-12 16:54:56 +02:00

21 KiB

status, system, updated, requirements
status system updated requirements
current tasks 2026-09-12
REQ-TASKS-THREADS-ACTIONS-001
REQ-TASKS-THREADS-ACTIONS-002
REQ-TASKS-THREADS-ACTIONS-003
REQ-TASKS-THREADS-ACTIONS-004

Threads Task Actions System Design

The task-action surfaces and pending archive exclusion below are implemented. The immediate archive removal plan records the extension's implementation and targeted verification separately from the original task-action delivery.

Ownership and dependencies

The task system owns the action target and delegates execution to the same task operations used by existing task menus. Threads supplies task IDs and presentation anchors. Its conversation selection remains local to each column. No endpoint, schema, permission, or plugin contract changes are needed.

The Threads deck and saved views own task admission, stable order, and viewport activation. The parent mobile Threads/shared-header implementation is integrated without recreating its header, picker or swiper. The plan records the pinned parent commit, merge, work orders and verification results.

Requirement mapping

Requirement Design sections
REQ-TASKS-THREADS-ACTIONS-001 Shared task operations; menu composition
REQ-TASKS-THREADS-ACTIONS-002 Target lifetime; shared task operations; failure behavior
REQ-TASKS-THREADS-ACTIONS-003 Pending archive exclusion; admission and selection recovery; state delivery
REQ-TASKS-THREADS-ACTIONS-004 Header entry points; phone composition; dismissal and focus

Existing implementation boundaries

Existing owner Reuse in this feature
components/task/task-switcher-context-menu.tsx Shared context items, movement dispatch, eligibility, and open/close behavior
components/task/task-item-menu-button.tsx Visible ellipsis, localized Task actions name, keyboard and touch entry
task-priority-context-menu.tsx, hooks/use-update-task-priority.ts Four tokens/current marker, persistence, event-based display, failure toast
task-move-context-menu.tsx, hooks/use-task-workflow-move.ts Workflow/step choices, no-step/current/auto-start states, existing move transport and errors
task-switcher-link-menu.tsx, task-session-sidebar-link-actions.ts, task-session-sidebar-task-linking.ts First-party availability, captured link target, immutable plugin context, provider icons and dialog handoff
task-switcher-action-items.tsx, task-archive-confirmation.tsx, task-delete-confirm-dialog.tsx Archive preference/classification and existing deletion consent
hooks/use-task-actions.ts, hooks/use-task-removal.ts Domain mutation APIs and shared successful-removal cleanup
app/threads/threads-page-client.tsx, lib/threads/stable-order.ts Workspace-scoped snapshots, saved-view query, stable admitted order

Paths without a prefix in this table are under apps/web/components/task/; all other source paths are relative to apps/web/.

Target lifetime

useTaskManagementFlow is mounted once above the removable column list by ThreadTaskActionsProvider. The task-owned TaskManagementSurface composes existing hooks and confirmation/link components. It does not import Threads, derive task IDs from sessions, or implement API requests. useTaskMenuActions owns the shared archive/delete lifecycle and is consumed by this surface and the existing task-session sidebar. Threads contains no mutation handlers.

The flow stores an immutable { taskId, workspaceId } and a small presentation stage; its entry adapter stores the originating trigger. resolveTaskMenuTarget reads the target's latest task record and workflow metadata by that ID from the scoped workflow snapshots, using the existing task lookup conventions. It derives the required TaskSwitcherItem fields from the real task record: priority, workflow and step IDs, archive state, repository links, primary executor type, and task-wide foreground activity. ActiveThread is a summary and lacks several of these fields; it is not a stand-in for the task record. No background transcript or per-column task fetch is introduced to populate a menu.

Opening A captures A. Re-rendering the page with B visible cannot replace the captured identity. Display values and eligibility can refresh for A. Before dispatch or confirmation, resolve A again and check current availability. Close an unsubmitted flow when its workspace changes or authoritative state removes or archives A. Mere admission/filter loss does not destroy the flow. A response already in flight retains its captured target. Provider forms live in a child keyed by workspace/task identity, while the pending mutation owner lives above that child. An old link completion cannot close or populate a newer target's form. Invalid workspace/target state closes the stage permanently, including across a later return to that workspace.

Store the pending target/operation separately from the visible stage: deletion confirmation currently closes when submitting. Clearing a dialog must not clear the identity needed by its asynchronous result. Archive closes its stage before awaiting the shared operation, so its late result cannot close a newer menu. Shared ref guards prevent duplicate pending dispatch. Never pass sidebar selectedTaskIds into the Threads entry point.

Shared task operations

Use the existing movement fallback from TaskMoveItems for both destinations: moveTasks([taskId], workflowId, stepId, destination), obtained from useTaskWorkflowMove(). That is already the shared task-menu path for consumers without the sidebar's optimistic same-workflow adapter. It preserves the server's workflow transition and WIP rules and avoids adding an optimistic Threads-only list mutation. Retain useMoveToStep for existing consumers that already use it.

Priority uses useUpdateTaskPriority; the returned Promise alone is not proof of persistence, because the hook owns its error toast. Current values follow the confirmed store/event path. Move failures already toast and rethrow; catch the rejection at the shared menu boundary without a duplicate toast.

Link choices use selectTaskLinkActions, useSidebarLinkActions, useSidebarTaskLinking, and useTaskPluginLinkActions. Reuse SidebarLinkDialogs or extract its existing provider-only composition without cloning forms. Capture the task's repository context and close the menu before launching a provider or plugin surface. Plugin registrations and visibility stay reactive. A workspace change dismisses the old flow before a new plugin context can run.

Archive uses TaskArchiveConfirmation, which already owns the user preference, subtask classification, in-flight warning, and consent. On phone, the simple confirmation occupies the task drawer body via its inline and renderInline support. A classification requiring the existing full dialog closes the drawer before opening that dialog. Classification remains in the shared confirmation adapter. If filtering removes its header while classification loads, the confirmation anchor falls back to the surviving trigger without changing the captured task. Delete closes the menu and opens TaskDeleteConfirmDialog for the captured task. Keep existing cascade/discard options and backend dirty-worktree errors.

The domain APIs remain in useTaskActions. Both menu operations use the archive/delete wrappers around useTaskRemoval and its shared removal coordinator. Its explicit stayOnListing option records pending targets without a detail/preview departure, even when global selection remembers that task. It retains duplicate suppression, cascade cleanup and one success notification, while preventing destination loads and failure-recovery navigation. Direct successful-removal cleanup also honors this option. Defaults preserve sidebar/task-detail departure protection and conditional recovery. Threads must not call useArchiveAndSwitchTask with its task-detail navigation behavior or temporarily rewrite global selection to suppress that behavior. Capture descendants before a cascade can prune the snapshots. Reuse existing task lifecycle events for recent/sidebar/session cleanup.

Menu composition

Extract a focused shared task-management item composition containing exactly the requested six groups. Reuse the existing priority, movement, link, archive, and delete items and their action callbacks. Keep the full switcher menu's default composition for its other consumers. A small explicit composition choice is sufficient; a new action registry or plugin API is unnecessary.

Desktop uses the existing ContextMenu primitives. TaskContextMenuSubContent portals and bounds shared task submenus to the viewport with an internal scroller, avoiding a transformed parent menu as their containing block. Phone uses a presentation adapter over those same option/availability sources. Where current components mix option derivation with Radix markup, extract only that derivation for both renderers. Do not duplicate workflow filtering, link eligibility, priority tokens, confirmation rules, or mutation dispatch in the phone component. Disabled current-step and no-step states remain visible.

Header entry points

Attach the fine-pointer context trigger only to the task header's title/metadata region. Exclude session controls and interactive/editor descendants. The chat body, composer, and deck scroll container are outside that trigger. Menu portal events must not reach header navigation handlers, while normal board touch scroll remains untouched. Threads has no task-row drag sensor to cancel.

Reuse TaskMenuButton with visible enabled in the existing header row next to Open task. Extend it with a controlled opener/ref and sizing hooks if required, preserving its existing default behavior for other callers. Keyboard opening anchors to the button's bounds rather than synthetic pointer coordinates 0,0. Mobile uses a dialog popup name/state; desktop uses menu semantics.

The parent MobileThreadColumnHeader retains its title picker, Open task, status, and session control. Its title remains flexible and bounded to two lines beside the two explicit icon controls. The page's compact shared topbar and ThreadsBoard.renderHeader inline pagination are unchanged.

Phone composition

Use useResponsiveBreakpoint: phone or a coarse-pointer menu entry uses the task-domain drawer adapter; fine-pointer desktop keeps compact Radix menus. This explicitly covers the 640-767px gap in the existing global menu CSS, whose bottom-sheet overrides only apply below 640px. Keep the new behavior scoped to the shared task-management surface.

The curated exemplars are components/kanban/mobile-menu-sheet.tsx for its inset card, fixed header, min-h-0 body and safe-area clearance, and components/task/mobile/mobile-picker-sheet.tsx for short task-domain choices and the parent's onCloseAutoFocus support. Reuse the Drawer primitive's inset geometry. Bound the whole surface using dvh and safe-area offsets; override legacy vh caps only on this surface. Use intrinsic height for short menus and one overflow-y-auto overscroll-contain body for long ones.

This is a temporary task decision, so an inset drawer fits the depth. Its title identifies the target task. A small page state contains root, priority, current-workflow steps, workflow list, selected workflow's steps, or link choices. Back moves up one page; selecting a workflow keeps the same drawer open for its steps. No portaled submenu mounts inside this phone adapter. Nested pages reset body scroll on entry and restore the originating row on Back. Long labels wrap or truncate within min-w-0 while keeping their full accessible name and visible state marker.

Touch triggers and navigation/choice rows have 44px minimum hit areas. Apply these via the phone/coarse-pointer variant so the fine-pointer base remains compact. The drawer owns vertical drag/scroll only while open. No new gesture listener, pointer capture, touch cancellation, or touch-action: none is added to the native horizontal swiper.

Pending archive exclusion

coordinateTaskRemovalBatch publishes taskRemoval.operationsByToken before calling an archive mutation. Each operation retains its action, workspace, target IDs and captured cascade descendants until request reconciliation finishes. stayOnListing suppresses detail navigation, but retains this operation state. Reuse that intent as the Threads presentation boundary.

ThreadsPageClient subscribes to the existing operation map and derives the union of taskIds for operations whose action is archive. Pass that set to an optional excludedTaskIds input on queryThreadView. Apply the exclusion before task scope, filters, sorting, limits, counts and temporary deep-link admission. All query candidate collections must agree on the exclusion. Workspace scoping remains in the existing snapshot/candidate path. Pending delete operations retain their current behavior in this archive-only repair.

Feed the resulting candidates to useStableThreadOrder and ThreadsBoard. Removing membership unmounts the outgoing column and its TaskChatPanel, releasing detail activity through the existing viewport owner. A CSS-hidden chat, disabled composer, or per-column placeholder does not satisfy this boundary. The board-level task-action owner remains mounted, even when the last column leaves, so its captured request and error reporting can finish. Keep the viewport observer attached across membership changes; callback refs register and unregister shells without resetting surviving visibility. Rebuild observation when an empty/nonempty transition replaces the board element. This preserves surviving editor mounts without changing activation budgets.

Pending intent is transient and is not part of the saved view or query fingerprint. Do not change snapshot task state, fabricate archive events, or reset stable ordering to hide a column. Existing WebSocket handling continues to reconcile all authoritative task/session data. Successful cleanup removes task rows before the coordinator releases its operation, so release cannot briefly readmit a successfully archived task. A rejected request releases only its own exclusion; eligible rows return through the ordinary query and stable order, while confirmed removals stay absent. Returning rows follow existing arrival ordering; surviving rows keep their positions.

A previously consumed task/session deep link must not become a fresh focus request when pending exclusion temporarily changes its resolved target to null. Keep the focus-request identity tied to the actual URL request, separate from its currently rendered resolution, in the page/board focus adapter. Retain dismissal across temporary absence. New explicit URL requests still receive normal focus behavior; do not clear unrelated URL parameters or reset order during bookkeeping. A failed archive cannot close a newer menu or scroll back to its restored column. useThreadFocusRequest tracks the visual mark separately from the viewport owner's initial detail fallback. Interaction retires the mark without unmounting the requested conversation before the first visibility callback. Once a consumed request's target leaves the deck, its fallback is cleared and readmission cannot replace the surviving chat. An unconsumed request can still activate a target that resolves after hydration. URL-driven board callers supply an explicit stable request key; the optional task-ID default retains legacy dismissal semantics for non-URL callers.

Desktop uses the remaining columns and their existing scroll-offset recovery. Phone uses the next snapped conversation, or the existing empty state, after the archive drawer/confirmation closes. The compact topbar, visible overflow entry, one transcript budget, picker, scroll owners and safe-area geometry remain the shipped composition. No new controls, copy or overlays are needed.

Admission and selection recovery

Let queryThreadView and useStableThreadOrder process confirmed shared state with the pending archive exclusion above. Changes to priority can affect existing saved-view filters; moves, archive, delete, external updates, and manual filter changes use the same membership reconciliation. Do not invent a parallel admitted list or sort.

resolveRemainingThreadId(previousOrder, nextOrder, currentTaskId) implements the requirement's successor/predecessor rule. useThreadSelectionRecovery keeps the last committed order and viewport anchor in the board. Phone scroll events read the same nearest-center geometry as the parent's mobile position. Desktop retains the interacted column while visible, otherwise the nearest visible column. This anchor supports recovery; it does not replace viewport activation or session selection.

When the anchor survives, preserve it and the local scroll offset if preceding columns disappear. When it leaves, select the deterministic survivor and position its shell through the existing board navigation path. A new user swipe/focus action before a delayed response wins if its task survives. Keep the parent's pagination and detail activation derived from resulting scroll geometry, including during a held swipe. Reconcile once per actual membership change, without render/effect loops or a live rerank.

After authoritative loading yields no tasks, render ThreadsEmptyState with a programmatic focus target. Preserve loading separately. The page and its URL resolution are unchanged: existing temporary deep-link admission remains authoritative, and a filtered-but-temporarily-admitted task is still a survivor. The existing page resolver ignores unavailable targets; no additional URL rewrite is required for this action entry point.

Dismissal and focus

Escape on a nested phone page goes Back; Escape at root closes. Capture the nested Escape before the Drawer primitive closes the entire surface. The visible Back control has the same behavior, and outside/backdrop or drawer dismissal closes the flow without dispatch. Existing provider dialogs own their own cancel and Escape behavior after handoff.

Focus return runs after the final surface closes, not during a menu-to-dialog handoff. Return to the original connected, visible trigger if it is still the reader's context. Otherwise focus the current surviving thread's overflow; if the deck is empty, focus its empty-state container. Use preventScroll so an offscreen originating column cannot pull the phone back. On breakpoint changes, resolve the surviving responsive trigger by task identity. If a newer user interaction already owns focus, leave it there.

State delivery and failure behavior

Use existing task mutation clients and workspace task events to update the single/multi-workflow snapshots and existing linked-reference stores. The menu does not add a store, subscription, polling loop, or task/session persistence. Shared removal cleanup is idempotent if a task event beats the HTTP response. Failure paths preserve confirmed values, release pending state, and display the shared localized error. Add generic error feedback at the shared action boundary only where the reused API currently propagates an error without visible UI. Pending archive recovery restores query eligibility, never task persistence or an archived workspace. An in-flight view/workspace change remains authoritative when a failed operation releases its exclusion.

Server authorization remains final. Re-check availability for the captured task before dispatch, and exercise rejection and provider disappearance in tests. No new permission grants or remote writes beyond the selected existing operation are introduced.

Verification design

The archive extension needs deferred-request regressions before any server archive event, plus early lifecycle events while the HTTP response remains held. Assert column/chat unmounting throughout that interval, query counts and limit refill, cascade targets, last-column recovery, independent operations, and rejection with a consumed deep link and newer reader interaction. Keep controls for cancelled confirmation and pending deletion. The follow-up plan owns exact commands and desktop/mobile scenario mapping.

Focused TDD covers target changes during menus/confirmations, live eligibility, shared mutation success/failure, pending-operation guards, and deterministic recovery. Browser tests use real isolated task fixtures and perform all six actions through the UI on both desktop and mobile, asserting the target result, an unaffected sibling, and persisted state after reload. Existing mocked integrations exercise link submission without production credentials.

Geometry tests cover 360px and 320px widths, long labels/lists, the 640-767px phone range, and a coarse-pointer tablet. Assert active-surface containment, one body scroller, hitboxes and actual hit testing, safe-area clearance, and no document horizontal overflow. Exercise nested Back/Escape, cancelled destructive actions, dialog handoff, removed-trigger focus, and real swiping before and after menu use. Inspect newly rendered phone screenshots during implementation. Exact test names and commands belong to the linked work orders.

These boundaries are reused. This delivery does not require a new ADR.