From a4077cf9cf36ea1c13195cfb31cedaa5d6710130 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Fri, 11 Sep 2026 15:51:57 +0100 Subject: [PATCH] feat(ui): group task and card menu actions (#3599) --- .../kanban-card-menu-grouping.test.tsx | 133 ++++ .../kanban-card-menu-items.test.tsx | 85 --- .../web/components/kanban-card-menu-items.tsx | 153 ++--- .../components/plugins/task-menu-actions.ts | 4 +- .../task/task-switcher-action-items.tsx | 4 +- ...er-context-menu-archive-selection.test.tsx | 63 ++ .../task/task-switcher-context-menu-items.tsx | 568 ++++++++++++++++++ .../task-switcher-context-menu-move-items.tsx | 89 +++ .../task/task-switcher-context-menu.test.tsx | 150 ++++- .../task/task-switcher-context-menu.tsx | 452 +------------- .../task/task-switcher-link-menu.tsx | 59 +- .../task/task-switcher-plugin-menu-items.tsx | 17 +- .../kanban/card-menu-delete-archive.spec.ts | 8 + .../tests/kanban/mobile-task-priority.spec.ts | 12 + .../kanban/task-actions-menu-detail.spec.ts | 7 + .../kanban/task-actions-menu-preview.spec.ts | 7 + .../task/mobile-sidebar-task-actions.spec.ts | 35 ++ .../e2e/tests/task/task-menu-grouping.spec.ts | 73 +++ .../web/hooks/use-kanban-card-move-targets.ts | 77 +++ .../kanban/task-actions-menu-entries.test.ts | 19 +- .../lib/kanban/task-actions-menu-entries.ts | 39 +- apps/web/lib/plugins/types.ts | 4 +- docs/plans/plugins/PLUGIN-API.md | 17 +- docs/plans/task-menu-grouping/plan.md | 220 +++++++ .../task-01-task-row-groups.md | 149 +++++ .../task-menu-grouping/task-02-card-groups.md | 153 +++++ docs/specs/tasks/README.md | 2 + .../tasks/requirements/task-actions-menu.md | 16 +- .../tasks/requirements/task-menu-grouping.md | 59 ++ .../tasks/system-design/task-actions-menu.md | 6 +- .../tasks/system-design/task-menu-grouping.md | 85 +++ 31 files changed, 2092 insertions(+), 673 deletions(-) create mode 100644 apps/web/components/kanban-card-menu-grouping.test.tsx create mode 100644 apps/web/components/task/task-switcher-context-menu-archive-selection.test.tsx create mode 100644 apps/web/components/task/task-switcher-context-menu-items.tsx create mode 100644 apps/web/components/task/task-switcher-context-menu-move-items.tsx create mode 100644 apps/web/e2e/tests/task/task-menu-grouping.spec.ts create mode 100644 apps/web/hooks/use-kanban-card-move-targets.ts create mode 100644 docs/plans/task-menu-grouping/plan.md create mode 100644 docs/plans/task-menu-grouping/task-01-task-row-groups.md create mode 100644 docs/plans/task-menu-grouping/task-02-card-groups.md create mode 100644 docs/specs/tasks/requirements/task-menu-grouping.md create mode 100644 docs/specs/tasks/system-design/task-menu-grouping.md diff --git a/apps/web/components/kanban-card-menu-grouping.test.tsx b/apps/web/components/kanban-card-menu-grouping.test.tsx new file mode 100644 index 0000000000..b2acdbf300 --- /dev/null +++ b/apps/web/components/kanban-card-menu-grouping.test.tsx @@ -0,0 +1,133 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { buildKanbanCardMenuEntries, type KanbanCardMenuEntry } from "./kanban-card-menu-items"; +import { pluginRegistry } from "@/lib/plugins/registry"; + +const PLUGIN_ID = "kandev-plugin-tags"; +const ACTION_ID = "quick-tag"; +const ACTION_LABEL = "Quick tag"; +const WORKFLOW_ONE = "wf-1"; +const WORKFLOW_TWO = "wf-2"; + +const PluginBitbucketIcon = () => null; + +afterEach(() => { + pluginRegistry.unregisterPlugin(PLUGIN_ID); +}); + +function entryKeys(entries: KanbanCardMenuEntry[]) { + return entries.map((entry) => entry.key); +} + +function registerPrimaryAction(visible?: () => boolean) { + pluginRegistry.forPlugin(PLUGIN_ID).registerTaskMenuAction({ + id: ACTION_ID, + label: ACTION_LABEL, + icon: PluginBitbucketIcon, + group: "primary", + visible, + run: vi.fn(), + }); +} + +function movementArgs() { + return { + currentWorkflowId: WORKFLOW_ONE, + workflows: [ + { id: WORKFLOW_ONE, name: "Workflow 1" }, + { id: WORKFLOW_TWO, name: "Workflow 2" }, + ], + stepsByWorkflowId: { + [WORKFLOW_ONE]: [ + { id: "s1", title: "Step 1" }, + { id: "s2", title: "Step 2" }, + ], + [WORKFLOW_TWO]: [{ id: "s3", title: "Step 3" }], + }, + }; +} + +describe("buildKanbanCardMenuEntries — 'primary' group plugin actions", () => { + it("renders a 'primary' group action after movement and before removal", () => { + registerPrimaryAction(); + + const entries = buildKanbanCardMenuEntries({ + ...movementArgs(), + onSendToWorkflow: vi.fn(), + onLinkPullRequest: vi.fn(), + }); + + const keys = entryKeys(entries); + const sendToIndex = keys.indexOf("send-to-workflow"); + const primaryIndex = keys.indexOf(`plugin-primary-${PLUGIN_ID}-${ACTION_ID}`); + const linkIndex = keys.indexOf("link"); + + expect(sendToIndex).toBeGreaterThanOrEqual(0); + expect(primaryIndex).toBeGreaterThanOrEqual(0); + expect(linkIndex).toBeGreaterThanOrEqual(0); + expect(linkIndex).toBeLessThan(sendToIndex); + expect(sendToIndex).toBeLessThan(primaryIndex); + + const archiveIndex = keys.indexOf("archive"); + expect(primaryIndex).toBeLessThan(archiveIndex); + + const primaryEntry = entries[primaryIndex]; + expect(primaryEntry.kind).toBe("item"); + if (primaryEntry.kind === "item") { + expect(primaryEntry.label).toBe(ACTION_LABEL); + expect((primaryEntry.icon as { type?: unknown })?.type).toBe(PluginBitbucketIcon); + } + }); + + it("keeps exactly one separator between each nonempty card group", () => { + registerPrimaryAction(); + + const entries = buildKanbanCardMenuEntries({ + ...movementArgs(), + currentStepId: "s1", + currentPriority: "high", + onSelectPriority: vi.fn(), + onEdit: vi.fn(), + onLinkPullRequest: vi.fn(), + parentTaskId: "parent-1", + onDetach: vi.fn(), + onMoveToStep: vi.fn(), + onSendToWorkflow: vi.fn(), + onArchive: vi.fn(), + onDelete: vi.fn(), + }); + + expect(entryKeys(entries)).toEqual([ + "priority", + "edit-separator", + "edit", + "relationships-separator", + "link", + "detach", + "move-separator", + "move-to", + "send-to-workflow", + "plugins-separator", + `plugin-primary-${PLUGIN_ID}-${ACTION_ID}`, + "remove-separator", + "archive", + "delete", + ]); + }); + + it("does not add a 'primary' entry when visible(context) returns false", () => { + registerPrimaryAction(() => false); + + const entries = buildKanbanCardMenuEntries({ workflows: [], stepsByWorkflowId: {} }); + + expect(entryKeys(entries)).not.toContain(`plugin-primary-${PLUGIN_ID}-${ACTION_ID}`); + }); + + it("leaves the 'edit' group submenu unaffected by 'primary' group registrations", () => { + registerPrimaryAction(); + + const entries = buildKanbanCardMenuEntries({ workflows: [], stepsByWorkflowId: {} }); + const editMenu = entries.find((entry) => entry.key === "edit"); + + expect(editMenu?.kind).toBe("item"); + }); +}); diff --git a/apps/web/components/kanban-card-menu-items.test.tsx b/apps/web/components/kanban-card-menu-items.test.tsx index 9b3e0fde5f..3dbc3e3964 100644 --- a/apps/web/components/kanban-card-menu-items.test.tsx +++ b/apps/web/components/kanban-card-menu-items.test.tsx @@ -212,91 +212,6 @@ describe("buildKanbanCardMenuEntries — !onEdit does not disable plugin edit ac }); }); -describe("buildKanbanCardMenuEntries — 'primary' group plugin actions", () => { - const PLUGIN_ID = "kandev-plugin-tags"; - - afterEach(() => { - pluginRegistry.unregisterPlugin(PLUGIN_ID); - }); - - function entryKeys(entries: KanbanCardMenuEntry[]) { - return entries.map((entry) => entry.key); - } - - it("renders a 'primary' group action as a flat item between Send to workflow and Link", () => { - pluginRegistry.forPlugin(PLUGIN_ID).registerTaskMenuAction({ - id: "quick-tag", - label: "Quick tag", - icon: PluginBitbucketIcon, - group: "primary", - run: vi.fn(), - }); - - const entries = buildKanbanCardMenuEntries({ - currentWorkflowId: "wf-1", - workflows: [ - { id: "wf-1", name: "Workflow 1" }, - { id: "wf-2", name: "Workflow 2" }, - ], - stepsByWorkflowId: { - "wf-1": [ - { id: "s1", title: "Step 1" }, - { id: "s2", title: "Step 2" }, - ], - "wf-2": [{ id: "s3", title: "Step 3" }], - }, - onSendToWorkflow: vi.fn(), - onLinkPullRequest: vi.fn(), - }); - - const keys = entryKeys(entries); - const sendToIndex = keys.indexOf("send-to-workflow"); - const primaryIndex = keys.indexOf(`plugin-primary-${PLUGIN_ID}-quick-tag`); - const linkIndex = keys.indexOf("link"); - - expect(sendToIndex).toBeGreaterThanOrEqual(0); - expect(primaryIndex).toBeGreaterThanOrEqual(0); - expect(linkIndex).toBeGreaterThanOrEqual(0); - expect(sendToIndex).toBeLessThan(primaryIndex); - expect(primaryIndex).toBeLessThan(linkIndex); - - const primaryEntry = entries[primaryIndex]; - expect(primaryEntry.kind).toBe("item"); - if (primaryEntry.kind === "item") { - expect(primaryEntry.label).toBe("Quick tag"); - expect((primaryEntry.icon as { type?: unknown })?.type).toBe(PluginBitbucketIcon); - } - }); - - it("does not add a 'primary' entry when visible(context) returns false", () => { - pluginRegistry.forPlugin(PLUGIN_ID).registerTaskMenuAction({ - id: "quick-tag", - label: "Quick tag", - group: "primary", - visible: () => false, - run: vi.fn(), - }); - - const entries = buildKanbanCardMenuEntries({ workflows: [], stepsByWorkflowId: {} }); - - expect(entryKeys(entries)).not.toContain(`plugin-primary-${PLUGIN_ID}-quick-tag`); - }); - - it("leaves the 'edit' group submenu unaffected by 'primary' group registrations", () => { - pluginRegistry.forPlugin(PLUGIN_ID).registerTaskMenuAction({ - id: "quick-tag", - label: "Quick tag", - group: "primary", - run: vi.fn(), - }); - - const entries = buildKanbanCardMenuEntries({ workflows: [], stepsByWorkflowId: {} }); - const editMenu = entries.find((entry) => entry.key === "edit"); - - expect(editMenu?.kind).toBe("item"); - }); -}); - describe("buildKanbanCardMenuEntries — detach", () => { const baseArgs = { workflows: [], diff --git a/apps/web/components/kanban-card-menu-items.tsx b/apps/web/components/kanban-card-menu-items.tsx index a6a6f60d3d..35cd61acff 100644 --- a/apps/web/components/kanban-card-menu-items.tsx +++ b/apps/web/components/kanban-card-menu-items.tsx @@ -1,7 +1,6 @@ "use client"; -import { useMemo, type ReactNode } from "react"; -import { useShallow } from "zustand/react/shallow"; +import { type ReactNode } from "react"; import { IconArchive, IconArrowRight, @@ -26,8 +25,6 @@ import { DropdownMenuSubContent, DropdownMenuSubTrigger, } from "@kandev/ui/dropdown-menu"; -import { useAppStore } from "@/components/state-provider"; -import type { WorkflowStep } from "@/components/kanban-card"; import { stepHasAutoStart, type TaskMoveStep, @@ -40,14 +37,13 @@ import { } from "@/lib/tasks/task-priority"; import type { TaskPriority } from "@/lib/types/http"; import { cn } from "@/lib/utils"; -import { sortWorkflowStepsByPosition } from "@/lib/kanban/workflow-step-order"; import { buildLinkSubmenu } from "./kanban-card-link-submenu"; import type { PluginIcon, PluginTaskMenuContext } from "@/lib/plugins/types"; import { buildEditMenuEntry } from "./kanban-card-edit-submenu"; import { buildPrimaryPluginEntries } from "./plugins/task-menu-actions"; import { useTranslation } from "react-i18next"; import { t } from "@/lib/i18n"; -import type { WorkflowSnapshotData } from "@/lib/state/slices/kanban/types"; +export { useKanbanCardMoveTargets } from "@/hooks/use-kanban-card-move-targets"; type ItemEntry = { kind: "item"; @@ -77,6 +73,26 @@ type SubmenuEntry = { export type KanbanCardMenuEntry = ItemEntry | SeparatorEntry | SubmenuEntry; +export type KanbanCardMenuEntryGroup = { + key: string; + entries: readonly KanbanCardMenuEntry[]; +}; + +/** Inserts one separator between each nonempty top-level menu group. */ +export function buildGroupedMenuEntries( + groups: readonly KanbanCardMenuEntryGroup[], +): KanbanCardMenuEntry[] { + return groups + .filter((group) => group.entries.length > 0) + .flatMap((group, index) => { + const separator: SeparatorEntry = { + kind: "separator", + key: `${group.key}-separator`, + }; + return index === 0 ? [...group.entries] : [separator, ...group.entries]; + }); +} + export type KanbanCardMoveTargets = { currentWorkflowId: string | null; workflowItems: TaskMoveWorkflow[]; @@ -357,21 +373,12 @@ export function buildKanbanCardMenuEntries({ const visibleWorkflows = workflows.filter((workflow) => !workflow.hidden); const currentSteps = currentWorkflowId ? (stepsByWorkflowId[currentWorkflowId] ?? []) : []; const isProcessing = Boolean(disabled || isDeleting || isArchiving || isDetaching); - const entries: KanbanCardMenuEntry[] = [ - buildEditMenuEntry({ - onEdit, - disabled: isProcessing, - context: resolvePluginMenuContext(pluginMenuContext), - forceFlat: forceFlatEdit, - }), - ]; const priorityEntry = buildPriorityMenuEntry({ currentPriority, disabled: isProcessing, onSelectPriority, }); - if (priorityEntry) entries.push(priorityEntry); const moveToEntry = buildMoveToCurrentWorkflowSubmenu({ steps: currentSteps, @@ -379,7 +386,6 @@ export function buildKanbanCardMenuEntries({ disabled: isProcessing, onMoveToStep, }); - if (moveToEntry) entries.push(moveToEntry); const sendToEntry = buildSendToWorkflowSubmenu({ currentWorkflowId, @@ -388,14 +394,11 @@ export function buildKanbanCardMenuEntries({ disabled: isProcessing, onSendToWorkflow, }); - if (sendToEntry) entries.push(sendToEntry); - entries.push( - ...buildPrimaryPluginEntries({ - disabled: isProcessing, - context: resolvePluginMenuContext(pluginMenuContext), - }), - ); + const pluginEntries = buildPrimaryPluginEntries({ + disabled: isProcessing, + context: resolvePluginMenuContext(pluginMenuContext), + }); const linkEntry = buildLinkSubmenu({ disabled: isProcessing, @@ -407,17 +410,43 @@ export function buildKanbanCardMenuEntries({ onLinkSentryIssue, pluginLinkActions, }); - if (linkEntry) entries.push(linkEntry); - - entries.push(buildArchiveEntry({ isArchiving, isProcessing, onArchive })); const detachEntry = buildDetachEntry({ parentTaskId, onDetach, isDetaching, isProcessing }); - if (detachEntry) entries.push(detachEntry); - entries.push({ kind: "separator", key: "delete-separator" }); - entries.push(buildDeleteEntry({ isDeleting, isProcessing, onDelete })); - - return entries; + return buildGroupedMenuEntries([ + { key: "priority", entries: priorityEntry ? [priorityEntry] : [] }, + { + key: "edit", + entries: [ + buildEditMenuEntry({ + onEdit, + disabled: isProcessing, + context: resolvePluginMenuContext(pluginMenuContext), + forceFlat: forceFlatEdit, + }), + ], + }, + { + key: "relationships", + entries: [linkEntry, detachEntry].filter( + (entry): entry is KanbanCardMenuEntry => entry !== null, + ), + }, + { + key: "move", + entries: [moveToEntry, sendToEntry].filter( + (entry): entry is KanbanCardMenuEntry => entry !== null, + ), + }, + { key: "plugins", entries: pluginEntries }, + { + key: "remove", + entries: [ + buildArchiveEntry({ isArchiving, isProcessing, onArchive }), + buildDeleteEntry({ isDeleting, isProcessing, onDelete }), + ], + }, + ]); } export function buildArchiveEntry({ @@ -491,68 +520,6 @@ function buildDetachEntry({ }; } -/** - * The card's hot render path: a caller with column context already passes a - * `steps` list pre-filtered for this workflow, and its `currentWorkflowId` - * always resolves from `kanbanMulti.snapshots`. Kept to exactly these two - * subscriptions (system design: no `kanban.tasks`/`hiddenWorkflowStepIds` - * reads here) so unrelated writes to either slice never re-render every - * visible card. Callers without column context (preview panel, detail top - * bar) use `useTaskActionsMenuMoveTargets` instead, which layers the - * flat-list and hidden-step fallbacks this hook intentionally omits. - */ -export function useKanbanCardMoveTargets( - taskId: string, - steps?: WorkflowStep[], -): KanbanCardMoveTargets { - const workflows = useAppStore((state) => state.workflows.items); - const currentWorkflowId = useAppStore((state) => { - for (const [workflowId, snapshot] of Object.entries(state.kanbanMulti.snapshots)) { - if (snapshot.tasks.some((task) => task.id === taskId)) return workflowId; - } - return null; - }); - const snapshotStepsByWorkflowId = useAppStore( - useShallow((state): Record => { - const result: Record = {}; - for (const [workflowId, snapshot] of Object.entries(state.kanbanMulti.snapshots)) { - result[workflowId] = snapshot.steps; - } - return result; - }), - ); - - const workflowItems = useMemo(() => { - const current = workflows.find((workflow) => workflow.id === currentWorkflowId); - return workflows - .filter((workflow) => workflow.workspaceId === current?.workspaceId && !workflow.hidden) - .map((workflow) => ({ id: workflow.id, name: workflow.name, hidden: workflow.hidden })); - }, [workflows, currentWorkflowId]); - - const stepsByWorkflowId = useMemo>(() => { - const result: Record = {}; - for (const [workflowId, snapshotSteps] of Object.entries(snapshotStepsByWorkflowId)) { - result[workflowId] = sortWorkflowStepsByPosition(snapshotSteps).map((step) => ({ - id: step.id, - title: step.title, - color: step.color, - events: step.events, - })); - } - if (currentWorkflowId && steps) { - result[currentWorkflowId] = steps.map((step) => ({ - id: step.id, - title: step.title, - color: step.color, - events: step.events, - })); - } - return result; - }, [snapshotStepsByWorkflowId, currentWorkflowId, steps]); - - return { currentWorkflowId, workflowItems, stepsByWorkflowId }; -} - function ContextEntry({ entry }: { entry: KanbanCardMenuEntry }) { if (entry.kind === "separator") return ; if (entry.kind === "submenu") { diff --git a/apps/web/components/plugins/task-menu-actions.ts b/apps/web/components/plugins/task-menu-actions.ts index 7e6db8dd19..23da951d4c 100644 --- a/apps/web/components/plugins/task-menu-actions.ts +++ b/apps/web/components/plugins/task-menu-actions.ts @@ -70,8 +70,8 @@ export function runnablePluginMenuEntry( /** * Builds flat, top-level menu entries for group "primary" task menu * actions, in registration order. Unlike group "edit" (nested in the Edit - * submenu), these render directly in the card menu — positioned between - * "Move to"/"Send to workflow" and "Link". + * submenu), these render directly in the card menu — after the movement + * group and before the Archive/Delete removal group. */ export function buildPrimaryPluginEntries({ disabled, diff --git a/apps/web/components/task/task-switcher-action-items.tsx b/apps/web/components/task/task-switcher-action-items.tsx index e517ae5744..522e975314 100644 --- a/apps/web/components/task/task-switcher-action-items.tsx +++ b/apps/web/components/task/task-switcher-action-items.tsx @@ -88,16 +88,18 @@ export function TaskDeleteItem({ taskId, isDeleting, onDeleteTask, + showSeparator = true, }: { taskId: string; isDeleting?: boolean; onDeleteTask?: (taskId: string) => void; + showSeparator?: boolean; }) { const { t } = useTranslation(); if (!onDeleteTask) return null; return ( <> - + {showSeparator ? : null} = {}): TaskSwitcherItem { + return { id: "task-1", title: "Task 1", state: "IN_PROGRESS", ...overrides }; +} + +function ArchiveAwareRow() { + return
Task 1
; +} + +describe("TaskItemWithContextMenu — single-selection bulk archive", () => { + it("keeps Archive for a one-row selection with only the bulk archive handler", async () => { + const onBulkArchive = vi.fn(); + render( + + + + + + + , + ); + + fireEvent.contextMenu(screen.getByTestId("task-row")); + const archive = await screen.findByRole("menuitem", { name: "Archive" }); + + fireEvent.click(archive); + + expect(onBulkArchive).toHaveBeenCalledWith(["task-1"]); + }); + + it("does not show a removal group for a non-selected row with only the bulk archive handler", async () => { + render( + + + + + + + , + ); + + fireEvent.contextMenu(screen.getByTestId("task-row")); + const menu = await screen.findByRole("menu"); + + expect(within(menu).queryByRole("menuitem", { name: /archive/i })).toBeNull(); + }); +}); diff --git a/apps/web/components/task/task-switcher-context-menu-items.tsx b/apps/web/components/task/task-switcher-context-menu-items.tsx new file mode 100644 index 0000000000..ac1068094f --- /dev/null +++ b/apps/web/components/task/task-switcher-context-menu-items.tsx @@ -0,0 +1,568 @@ +"use client"; + +import { useTranslation } from "react-i18next"; +import { Fragment, type ReactNode } from "react"; +import { + IconCopy, + IconEdit, + IconPencil, + IconPin, + IconPinFilled, + IconTrash, +} from "@tabler/icons-react"; +import { ContextMenuItem, ContextMenuSeparator } from "@kandev/ui/context-menu"; +import type { TaskMoveWorkflow } from "@/components/task/task-move-context-menu"; +import { + KanbanCardContextMenuItems, + type KanbanCardMenuEntry, +} from "@/components/kanban-card-menu-items"; +import { TaskNestContextMenuItems } from "@/components/task/task-nest-context-menu"; +import { useTaskWorkflowMove } from "@/hooks/use-task-workflow-move"; +import { useUpdateTaskPriority } from "@/hooks/use-update-task-priority"; +import { TaskColorMenu } from "./task-switcher-color-menu"; +import { TaskPluginLinkMenu, selectTaskLinkActions } from "./task-switcher-link-menu"; +import { + TaskArchiveItem, + TaskCreateSubtaskItem, + TaskDeleteItem, + TaskDetachItem, +} from "./task-switcher-action-items"; +import { + useTaskPluginLinkActions, + type PluginLinkMenuAction, +} from "./task-session-sidebar-link-actions"; +import type { StepDef, TaskSwitcherItem } from "./task-switcher-types"; +import { useTaskPluginPrimaryMenuEntries } from "./task-switcher-plugin-menu-items"; +import { TaskPriorityContextMenu } from "./task-priority-context-menu"; +import type { TaskContextMenuItemsProps } from "./task-switcher-context-menu"; +import { TaskMoveItems } from "./task-switcher-context-menu-move-items"; + +type SingleSelectionMenuProps = TaskContextMenuItemsProps & { + actingIds: string[]; + actingOnSelection: boolean; +}; + +type LinkActions = ReturnType; + +type SingleGroupProps = Pick< + SingleSelectionMenuProps, + | "task" + | "isDeleting" + | "isArchiving" + | "isPinned" + | "onTogglePin" + | "onEditTask" + | "onRenameTask" + | "onCreateSubtask" + | "onDetachTask" + | "onArchiveTask" + | "onDeleteTask" + | "closeMenu" + | "actingIds" + | "actingOnSelection" + | "onBulkArchive" + | "onClearSelection" + | "onBulkMove" + | "workflows" + | "stepsByWorkflowId" + | "steps" + | "onMoveToStep" + | "isMixedWorkflowSelection" + | "moveTasks" +> & { + linkActions?: LinkActions; + pluginLinkActions?: PluginLinkMenuAction[]; + hasLinkGroup?: boolean; +}; + +type TaskContextMenuGroup = { + key: string; + visible: boolean; + content: ReactNode; +}; + +function TaskContextMenuGroups({ groups }: { groups: readonly TaskContextMenuGroup[] }) { + const visibleGroups = groups.filter((group) => group.visible); + return ( + <> + {visibleGroups.map((group, index) => ( + + {index > 0 && } + {group.content} + + ))} + + ); +} + +type SingleMenuState = { + pluginPrimaryEntries: KanbanCardMenuEntry[]; + pluginLinkActions: PluginLinkMenuAction[]; + linkActions: LinkActions; + hasMoveGroup: boolean; + hasLinkGroup: boolean; + hasRelationshipsGroup: boolean; + onDelete: ((id: string) => void) | undefined; + onDetach: ((id: string) => void) | undefined; +}; + +function useSingleMenuState(props: SingleSelectionMenuProps): SingleMenuState { + const { task, closeMenu, stepsByWorkflowId, steps, workflows, actingOnSelection } = props; + const pluginPrimaryEntries = useTaskPluginPrimaryMenuEntries(task, props.isDeleting); + const pluginLinkActions = useTaskPluginLinkActions(task.id, task.repositoryLinks ?? []); + const linkActions = selectTaskLinkActions(task, closeMenu, props); + const workflowId = task.workflowId; + const moveStepsByWorkflowId = + stepsByWorkflowId ?? (workflowId && steps ? { [workflowId]: steps } : undefined); + const currentMoveSteps = workflowId ? (moveStepsByWorkflowId?.[workflowId] ?? []) : []; + const hasMoveGroup = hasSingleMoveGroup({ + task, + workflows, + workflowId, + currentMoveSteps, + actingOnSelection, + isMixedWorkflowSelection: props.isMixedWorkflowSelection, + }); + const hasLinkGroup = Object.values(linkActions).some(Boolean) || pluginLinkActions.length > 0; + const hasRelationshipsGroup = Boolean( + props.onCreateSubtask || + (!task.isArchived && task.workflowId) || + hasLinkGroup || + (task.parentTaskId && props.onDetachTask), + ); + return { + pluginPrimaryEntries, + pluginLinkActions, + linkActions, + hasMoveGroup, + hasLinkGroup, + hasRelationshipsGroup, + onDelete: withSelectionClear(actingOnSelection, props.onClearSelection, props.onDeleteTask), + onDetach: withSelectionClear(actingOnSelection, props.onClearSelection, props.onDetachTask), + }; +} + +function SingleSelectionMenuItems(props: SingleSelectionMenuProps) { + return ; +} + +function SingleSelectionMenuGroups(props: SingleSelectionMenuProps & SingleMenuState) { + const { task } = props; + return ( + , + }, + { + key: "edit", + visible: Boolean(!task.isArchived || props.onRenameTask), + content: , + }, + { + key: "relationships", + visible: props.hasRelationshipsGroup, + content: , + }, + { + key: "move", + visible: props.hasMoveGroup, + content: , + }, + { + key: "plugins", + visible: props.pluginPrimaryEntries.length > 0, + content: , + }, + { + key: "remove", + visible: hasSingleRemoveGroup(props), + content: , + }, + ]} + /> + ); +} + +function hasSingleMoveGroup({ + task, + workflows, + workflowId, + currentMoveSteps, + actingOnSelection, + isMixedWorkflowSelection, +}: Pick< + SingleSelectionMenuProps, + "task" | "workflows" | "actingOnSelection" | "isMixedWorkflowSelection" +> & { + workflowId: string | undefined; + currentMoveSteps: StepDef[]; +}) { + if (task.isArchived || !workflowId) return false; + const hasSameWorkflowMove = + currentMoveSteps.length > 1 && (!actingOnSelection || !isMixedWorkflowSelection); + const hasCrossWorkflowMove = (workflows ?? []).some( + (workflow) => !workflow.hidden && workflow.id !== workflowId, + ); + return hasSameWorkflowMove || hasCrossWorkflowMove; +} + +function hasSingleRemoveGroup({ + actingOnSelection, + onArchiveTask, + onBulkArchive, + onDeleteTask, +}: Pick< + SingleSelectionMenuProps, + "actingOnSelection" | "onArchiveTask" | "onBulkArchive" | "onDeleteTask" +>) { + return Boolean(onArchiveTask || onDeleteTask || (actingOnSelection && onBulkArchive)); +} + +function SingleMarkGroup({ + task, + isPinned, + isDeleting, + actingOnSelection, + onClearSelection, + onTogglePin, +}: Pick< + SingleGroupProps, + "task" | "isPinned" | "isDeleting" | "actingOnSelection" | "onClearSelection" | "onTogglePin" +>) { + const updateTaskPriority = useUpdateTaskPriority(); + return ( + <> + + {!task.isArchived && ( + + )} + {!task.isArchived && ( + void updateTaskPriority(task.id, priority)} + /> + )} + + ); +} + +function SingleEditGroup({ + task, + isDeleting, + onEditTask, + onRenameTask, +}: Pick) { + const { t } = useTranslation(); + return ( + <> + + + {!task.isArchived && ( + + + {t("settings:duplicate")} + + )} + + ); +} + +function SingleRelationshipsGroup({ + task, + isDeleting, + onCreateSubtask, + onDetachTask, + closeMenu, + linkActions, + pluginLinkActions, + hasLinkGroup, +}: Pick< + SingleGroupProps, + | "task" + | "isDeleting" + | "onCreateSubtask" + | "onDetachTask" + | "closeMenu" + | "linkActions" + | "pluginLinkActions" + | "hasLinkGroup" +>) { + return ( + <> + + {!task.isArchived && } + {hasLinkGroup && linkActions && pluginLinkActions && ( + + )} + + + ); +} + +function SingleRemoveGroup({ + task, + isDeleting, + isArchiving, + onArchiveTask, + onDeleteTask, + actingIds, + actingOnSelection, + onBulkArchive, +}: Pick< + SingleGroupProps, + | "task" + | "isDeleting" + | "isArchiving" + | "onArchiveTask" + | "onDeleteTask" + | "actingIds" + | "actingOnSelection" + | "onBulkArchive" +>) { + return ( + <> + + + + ); +} + +function withSelectionClear( + actingOnSelection: boolean, + onClearSelection: (() => void) | undefined, + handler: ((id: string) => void) | undefined, +) { + if (!actingOnSelection || !onClearSelection || !handler) return handler; + return (id: string) => { + onClearSelection(); + handler(id); + }; +} + +type BulkSelectionMenuProps = { + task: TaskSwitcherItem; + actingIds: string[]; + workflows?: TaskMoveWorkflow[]; + stepsByWorkflowId?: Record; + steps?: StepDef[]; + isMixedWorkflowSelection?: boolean; + pinnedTaskIds?: string[]; + onBulkPin?: (taskIds: string[]) => void; + onBulkArchive?: (taskIds: string[]) => void; + onBulkDelete?: (taskIds: string[]) => void; + onBulkMove?: (taskIds: string[], targetWorkflowId: string, targetStepId: string) => void; + closeMenu: () => void; + moveTasks: ReturnType; +}; + +function BulkSelectionMenuItems(props: BulkSelectionMenuProps) { + const { task, workflows, stepsByWorkflowId, steps, isMixedWorkflowSelection } = props; + const hasMoveGroup = hasBulkMoveGroup({ + task, + workflows, + stepsByWorkflowId, + steps, + isMixedWorkflowSelection, + }); + return ( + , + }, + { + key: "move", + visible: hasMoveGroup, + content: , + }, + { + key: "remove", + visible: Boolean(props.onBulkArchive || props.onBulkDelete), + content: , + }, + ]} + /> + ); +} + +function hasBulkMoveGroup({ + task, + workflows, + stepsByWorkflowId, + steps, + isMixedWorkflowSelection, +}: Pick< + BulkSelectionMenuProps, + "task" | "workflows" | "stepsByWorkflowId" | "steps" | "isMixedWorkflowSelection" +>) { + const workflowId = task.workflowId; + const moveStepsByWorkflowId = + stepsByWorkflowId ?? (workflowId && steps ? { [workflowId]: steps } : undefined); + const currentMoveSteps = workflowId ? (moveStepsByWorkflowId?.[workflowId] ?? []) : []; + return Boolean( + workflowId && + ((!isMixedWorkflowSelection && currentMoveSteps.length > 1) || + (workflows ?? []).some((workflow) => !workflow.hidden && workflow.id !== workflowId)), + ); +} + +function BulkPinMenuItem({ + actingIds, + pinnedTaskIds, + onBulkPin, +}: Pick) { + const { t } = useTranslation(); + if (!onBulkPin) return null; + const allPinned = + actingIds.length > 0 && actingIds.every((id) => pinnedTaskIds?.includes(id) ?? false); + const label = allPinned + ? t("task:unpinTasksCount", { count: actingIds.length }) + : t("task:pinTasksCount", { count: actingIds.length }); + return ( + onBulkPin(actingIds)}> + {allPinned ? ( + + ) : ( + + )} + {label} + + ); +} + +function BulkMoveGroup(props: BulkSelectionMenuProps) { + return ( + + ); +} + +function BulkRemoveGroup({ + task, + actingIds, + onBulkArchive, + onBulkDelete, +}: Pick) { + const { t } = useTranslation(); + return ( + <> + + {onBulkDelete && ( + onBulkDelete(actingIds)}> + + {t("task:deleteTasksCount", { count: actingIds.length })} + + )} + + ); +} + +function TaskPinItem({ + taskId, + isPinned, + disabled, + onTogglePin, +}: { + taskId: string; + isPinned?: boolean; + disabled?: boolean; + onTogglePin?: (taskId: string) => void; +}) { + const { t } = useTranslation(); + if (!onTogglePin) return null; + return ( + onTogglePin(taskId)}> + {isPinned ? : } + {isPinned ? t("task:unpin") : t("task:annotationPin")} + + ); +} + +function TaskRenameItem({ + task, + disabled, + onRenameTask, +}: { + task: TaskSwitcherItem; + disabled?: boolean; + onRenameTask?: (taskId: string, currentTitle: string) => void; +}) { + const { t } = useTranslation(); + if (!onRenameTask) return null; + return ( + onRenameTask(task.id, task.title)}> + + {t("task:rename")} + + ); +} + +function TaskEditItem({ + task, + disabled, + onEditTask, +}: { + task: TaskSwitcherItem; + disabled?: boolean; + onEditTask?: (task: TaskSwitcherItem) => void; +}) { + const { t } = useTranslation(); + if (!onEditTask || task.isArchived || !task.workflowId || !task.workflowStepId) return null; + return ( + onEditTask(task)}> + + {t("common:edit")} + + ); +} + +export { BulkSelectionMenuItems, SingleSelectionMenuItems }; diff --git a/apps/web/components/task/task-switcher-context-menu-move-items.tsx b/apps/web/components/task/task-switcher-context-menu-move-items.tsx new file mode 100644 index 0000000000..2f51072d5b --- /dev/null +++ b/apps/web/components/task/task-switcher-context-menu-move-items.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { TaskMoveContextMenuItems } from "@/components/task/task-move-context-menu"; +import type { TaskContextMenuItemsProps } from "./task-switcher-context-menu"; + +export function TaskMoveItems({ + task, + workflows, + stepsByWorkflowId, + steps, + isDeleting, + onMoveToStep, + onMoveToStepWithOptions, + onSubmitWithOptions, + moveOptionsBusy, + actingIds, + actingOnSelection, + onBulkMove, + isMixedWorkflowSelection, + closeMenu, + moveTasks, +}: Omit & { + actingIds: string[]; + actingOnSelection: boolean; +}) { + if (!task.workflowId) return null; + const workflowId = task.workflowId; + const runSelectionMove = ( + targetWorkflowId: string, + stepId: string, + destination: "step" | "workflow", + ) => { + closeMenu(); + if (onBulkMove) { + onBulkMove(actingIds, targetWorkflowId, stepId); + return; + } + void moveTasks(actingIds, targetWorkflowId, stepId, destination).catch(() => { + // useTaskWorkflowMove already shows the failure toast. + }); + }; + + let moveToStep: ((stepId: string) => void) | undefined; + if (actingOnSelection) { + moveToStep = isMixedWorkflowSelection + ? undefined + : (stepId) => runSelectionMove(workflowId, stepId, "step"); + } else { + moveToStep = (stepId) => { + closeMenu(); + if (onMoveToStep) { + onMoveToStep(task.id, workflowId, stepId); + return; + } + void moveTasks([task.id], workflowId, stepId, "step").catch(() => { + // useTaskWorkflowMove already shows the failure toast. + }); + }; + } + + return ( + { + if (actingOnSelection) { + runSelectionMove(targetWorkflowId, stepId, "workflow"); + return; + } + closeMenu(); + void moveTasks([task.id], targetWorkflowId, stepId, "workflow").catch(() => { + // useTaskWorkflowMove already shows the failure toast. + }); + }} + /> + ); +} diff --git a/apps/web/components/task/task-switcher-context-menu.test.tsx b/apps/web/components/task/task-switcher-context-menu.test.tsx index 110b0e3977..538abf62d6 100644 --- a/apps/web/components/task/task-switcher-context-menu.test.tsx +++ b/apps/web/components/task/task-switcher-context-menu.test.tsx @@ -12,6 +12,9 @@ import type { TaskSwitcherItem } from "./task-switcher-types"; const PLUGIN_ID = "example-task-actions"; const PLUGIN_ACTION_LABEL = "Inspect task"; const WORKFLOW_1_ID = "workflow-1"; +const WORKFLOW_ID = WORKFLOW_1_ID; +const STEP_ID = "step-1"; +const SEPARATOR_SLOT = "context-menu-separator"; const MOVE_TO_TEST_ID = "task-context-move-to"; const STEP_2_TEST_ID = "task-context-step-step-2"; const INSTRUCTIONS_TEST_ID = "workflow-move-instructions"; @@ -95,7 +98,7 @@ function renderPluginMenu(run: (context: PluginTaskMenuContext) => void = vi.fn( render( - +
Task 1
@@ -116,7 +119,7 @@ describe("TaskItemWithContextMenu — plugin primary actions", () => { workspaceId: "workspace-1", taskId: "task-1", taskTitle: "Task 1", - workflowStepId: "step-1", + workflowStepId: STEP_ID, presentation: "desktop", }); }); @@ -133,6 +136,144 @@ describe("TaskItemWithContextMenu — plugin primary actions", () => { }); }); +describe("TaskItemWithContextMenu — grouped single-task actions", () => { + it("renders actions in group order with one divider between nonempty groups", async () => { + pluginRegistry.forPlugin(PLUGIN_ID).registerTaskMenuAction({ + id: "inspect-task", + label: PLUGIN_ACTION_LABEL, + group: "primary", + run: vi.fn(), + }); + + render( + + + + + + + , + ); + + fireEvent.contextMenu(screen.getByTestId("task-row")); + const menu = await screen.findByRole("menu"); + const labels = within(menu) + .getAllByRole("menuitem") + .map((item) => item.textContent?.replace(/\s+/g, " ").trim()); + + expect(labels).toEqual([ + "Pin", + "Color", + "Priority", + "Edit", + "Rename", + "Duplicate", + "Create Subtask", + "Nest under", + "Link", + "Detach from parent", + "Move to", + "Send to workflow", + PLUGIN_ACTION_LABEL, + "Archive", + "Delete", + ]); + + const directChildren = Array.from(menu.children); + const separators = directChildren.filter( + (child) => child.getAttribute("data-slot") === SEPARATOR_SLOT, + ); + expect(separators).toHaveLength(5); + expect(directChildren[0]?.getAttribute("data-slot")).not.toBe(SEPARATOR_SLOT); + expect(directChildren.at(-1)?.getAttribute("data-slot")).not.toBe(SEPARATOR_SLOT); + expect( + directChildren.some( + (child, index) => + child.getAttribute("data-slot") === SEPARATOR_SLOT && + directChildren[index - 1]?.getAttribute("data-slot") === SEPARATOR_SLOT, + ), + ).toBe(false); + }); +}); + +describe("TaskItemWithContextMenu — grouped bulk actions", () => { + it("keeps bulk actions in mark, movement, and removal groups", async () => { + render( + + + + + + + , + ); + + fireEvent.contextMenu(screen.getByTestId("task-row")); + const menu = await screen.findByRole("menu"); + const labels = within(menu) + .getAllByRole("menuitem") + .map((item) => item.textContent?.replace(/\s+/g, " ").trim()); + + expect(labels).toEqual([ + "Pin 2 tasks", + "Move to", + "Send to workflow", + "Archive 2 tasks", + "Delete 2 tasks", + ]); + + const directChildren = Array.from(menu.children); + const separators = directChildren.filter( + (child) => child.getAttribute("data-slot") === SEPARATOR_SLOT, + ); + expect(separators).toHaveLength(2); + expect(directChildren[0]?.getAttribute("data-slot")).not.toBe(SEPARATOR_SLOT); + expect(directChildren.at(-1)?.getAttribute("data-slot")).not.toBe(SEPARATOR_SLOT); + }); +}); + function renderWorkflowMoveMenu({ selectedTaskIds, onMoveToStep, @@ -172,9 +313,6 @@ function renderWorkflowMoveMenu({ return { bulkMove }; } -// Open the "Move to" submenu and hover the target step so its nested content -// is revealed. On fine pointers that content is the inline options form; on -// coarse/touch pointers it is the "Move with options" item that opens a Drawer. async function openMoveSubmenu() { fireEvent.contextMenu(screen.getByTestId("task-row")); await screen.findByTestId(MOVE_TO_TEST_ID); @@ -353,8 +491,6 @@ describe("TaskItemWithContextMenu — single-task move options", () => { fireEvent.pointerMove(await screen.findByTestId(STEP_2_TEST_ID), { pointerType: "mouse", }); - // Fine pointers reveal the options form inline in the step's submenu, not a - // separate "Move with options" item that opens a dialog. expect(await screen.findByTestId("workflow-move-skip-step-prompt")).toBeTruthy(); expect(screen.getByTestId(INSTRUCTIONS_TEST_ID)).toBeTruthy(); expect(screen.getByTestId("workflow-move-submit")).toBeTruthy(); diff --git a/apps/web/components/task/task-switcher-context-menu.tsx b/apps/web/components/task/task-switcher-context-menu.tsx index b807441782..c2416834e8 100644 --- a/apps/web/components/task/task-switcher-context-menu.tsx +++ b/apps/web/components/task/task-switcher-context-menu.tsx @@ -1,52 +1,26 @@ "use client"; import { cloneElement, isValidElement, useState, type ReactNode } from "react"; -import { useTranslation } from "react-i18next"; +import { ContextMenu, ContextMenuContent, ContextMenuTrigger } from "@kandev/ui/context-menu"; import { - IconCopy, - IconEdit, - IconPencil, - IconPin, - IconPinFilled, - IconTrash, -} from "@tabler/icons-react"; -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuSeparator, - ContextMenuTrigger, -} from "@kandev/ui/context-menu"; -import { - TaskMoveContextMenuItems, TaskMoveOptionsSurface, type TaskMoveWorkflow, useTaskMoveOptions, } from "@/components/task/task-move-context-menu"; -import { TaskNestContextMenuItems } from "@/components/task/task-nest-context-menu"; import { useTaskWorkflowMove } from "@/hooks/use-task-workflow-move"; import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; import { useTouchDrawer } from "@/hooks/use-compact-task-chrome"; import { isWorkflowMoveOptionsTarget } from "@/components/task/workflow-move-surface"; import type { WorkflowMoveEntryOptions } from "@/lib/api/domains/kanban-api"; -import { useUpdateTaskPriority } from "@/hooks/use-update-task-priority"; -import { TaskColorMenu } from "./task-switcher-color-menu"; -import { - TaskPluginLinkMenu, - selectTaskLinkActions, - type TaskLinkHandlers, -} from "./task-switcher-link-menu"; -import { - TaskArchiveItem, - TaskCreateSubtaskItem, - TaskDeleteItem, - TaskDetachItem, -} from "./task-switcher-action-items"; +import type { TaskLinkHandlers } from "./task-switcher-link-menu"; import type { StepDef, TaskSwitcherItem } from "./task-switcher-types"; -import { TaskPluginPrimaryMenuItems } from "./task-switcher-plugin-menu-items"; import { useTaskSwitcherArchiveConfirmation } from "./task-switcher-archive-confirmation"; -import { TaskPriorityContextMenu } from "./task-priority-context-menu"; +import { + BulkSelectionMenuItems, + SingleSelectionMenuItems, +} from "./task-switcher-context-menu-items"; import { useMenuTouchDragCancel } from "./task-switcher-touch-drag-cancel"; + export type { StepDef } from "./task-switcher-types"; export { createTaskLinkSelectAction } from "./task-switcher-link-menu"; @@ -158,10 +132,10 @@ export function TaskItemWithContextMenu(props: ContextMenuProps) { // The menu renders in a portal whose fiber ancestors include the // dnd-kit drag handle that wraps the row. React synthetic events // bubble through the fiber tree, not the DOM, so without these guards - // a mousedown/pointerdown/touchstart on any menu item (e.g. the Color - // submenu trigger or a swatch) reaches the handle's sensor listeners - // and starts a row drag, and a click activates the row. Bubble-phase - // guards run after the item's own handlers, so menu actions still work. + // a mousedown/pointerdown/touchstart on any menu item reaches the + // handle's sensor listeners and starts a row drag, and a click + // activates the row. Bubble-phase guards run after the item's own + // handlers, so menu actions still work. onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} onTouchStart={(event) => event.stopPropagation()} @@ -180,7 +154,7 @@ export function TaskItemWithContextMenu(props: ContextMenuProps) { ); } -type TaskContextMenuItemsProps = Omit< +export type TaskContextMenuItemsProps = Omit< ContextMenuProps, "children" | "onBeforeMoveOptionsOpen" | "onRequestMoveOptions" > & { @@ -225,15 +199,14 @@ function buildTaskContextMenuItemsProps( function TaskContextMenuItems(props: TaskContextMenuItemsProps) { const { task, selectedTaskIds } = props; - // Right-clicking any row that's part of the active selection acts on the whole - // selection (even a one-row selection, so the action clears it); right-clicking - // a non-selected row acts on just that task and leaves the selection intact. + // Right-clicking any row that's part of the active selection acts on the + // whole selection, even a one-row selection. Right-clicking a non-selected + // row acts on just that task and leaves the selection intact. const actingOnSelection = !!selectedTaskIds?.has(task.id); const actingIds = actingOnSelection ? [...selectedTaskIds!] : [task.id]; // With several tasks selected, only actions that make sense for all of them - // are offered (Pin / Move / Archive / Delete) — the single-task actions - // (Rename, Color, Link, Duplicate) are hidden. + // are offered. Single-task actions are hidden for that reduced menu. if (actingOnSelection && actingIds.length > 1) { return ; } @@ -246,247 +219,6 @@ function TaskContextMenuItems(props: TaskContextMenuItemsProps) { ); } -type TaskHeaderMenuItemsProps = Pick< - TaskContextMenuItemsProps, - | "task" - | "isDeleting" - | "isPinned" - | "onClearSelection" - | "onTogglePin" - | "onEditTask" - | "onRenameTask" - | "onCreateSubtask" -> & { actingOnSelection: boolean }; - -function TaskHeaderMenuItems({ - task, - isDeleting, - isPinned, - actingOnSelection, - onClearSelection, - onTogglePin, - onEditTask, - onRenameTask, - onCreateSubtask, -}: TaskHeaderMenuItemsProps) { - const { t } = useTranslation(); - const updateTaskPriority = useUpdateTaskPriority(); - return ( - <> - - - {!task.isArchived && ( - void updateTaskPriority(task.id, priority)} - /> - )} - - - {!task.isArchived && ( - - - {t("settings:duplicate")} - - )} - - ); -} - -function SingleSelectionMenuItems({ - task, - workflows, - stepsByWorkflowId, - steps, - onEditTask, - onRenameTask, - onArchiveTask, - onCreateSubtask, - onDeleteTask, - onDetachTask, - onMoveToStep, - onMoveToStepWithOptions, - onSubmitWithOptions, - moveOptionsBusy, - onTogglePin, - isPinned, - isDeleting, - isArchiving, - onBulkArchive, - onBulkMove, - onClearSelection, - isMixedWorkflowSelection, - closeMenu, - moveTasks, - actingIds, - actingOnSelection, - ...linkHandlers -}: TaskContextMenuItemsProps & { actingIds: string[]; actingOnSelection: boolean }) { - // Acting on a lone selected row (Pin / Delete) must drop it from the selection - // so later plain clicks navigate instead of toggling. - const onDelete = withSelectionClear(actingOnSelection, onClearSelection, onDeleteTask); - const onDetach = withSelectionClear(actingOnSelection, onClearSelection, onDetachTask); - return ( - <> - - - {!task.isArchived && ( - - )} - - - - {!task.isArchived && ( - - )} - - - - ); -} - -function withSelectionClear( - actingOnSelection: boolean, - onClearSelection: (() => void) | undefined, - handler: ((id: string) => void) | undefined, -) { - if (!actingOnSelection || !onClearSelection || !handler) return handler; - return (id: string) => { - onClearSelection(); - handler(id); - }; -} - -/** Reduced menu shown when 2+ tasks are selected — only bulk-valid actions. */ -function BulkSelectionMenuItems({ - task, - actingIds, - workflows, - stepsByWorkflowId, - steps, - isMixedWorkflowSelection, - pinnedTaskIds, - onBulkPin, - onBulkArchive, - onBulkDelete, - onBulkMove, - closeMenu, - moveTasks, -}: { - task: TaskSwitcherItem; - actingIds: string[]; - workflows?: TaskMoveWorkflow[]; - stepsByWorkflowId?: Record; - steps?: StepDef[]; - isMixedWorkflowSelection?: boolean; - pinnedTaskIds?: string[]; - onBulkPin?: (taskIds: string[]) => void; - onBulkArchive?: (taskIds: string[]) => void; - onBulkDelete?: (taskIds: string[]) => void; - onBulkMove?: (taskIds: string[], targetWorkflowId: string, targetStepId: string) => void; - closeMenu: () => void; - moveTasks: ReturnType; -}) { - const { t } = useTranslation(); - const n = actingIds.length; - const allPinned = - actingIds.length > 0 && actingIds.every((id) => pinnedTaskIds?.includes(id) ?? false); - const pinLabel = allPinned - ? t("task:unpinTasksCount", { count: n }) - : t("task:pinTasksCount", { count: n }); - return ( - <> - {onBulkPin && ( - onBulkPin(actingIds)}> - {allPinned ? ( - - ) : ( - - )} - {pinLabel} - - )} - - - {onBulkDelete && ( - <> - - onBulkDelete(actingIds)}> - - {t("task:deleteTasksCount", { count: n })} - - - )} - - ); -} - function cloneWithMenuOpen( children: React.ReactElement<{ menuOpen?: boolean; archiveConfirmation?: ReactNode }>, menuOpen: boolean, @@ -495,155 +227,3 @@ function cloneWithMenuOpen( if (isValidElement(children)) return cloneElement(children, { menuOpen, archiveConfirmation }); return children; } - -function TaskPinItem({ - taskId, - isPinned, - disabled, - onTogglePin, -}: { - taskId: string; - isPinned?: boolean; - disabled?: boolean; - onTogglePin?: (taskId: string) => void; -}) { - const { t } = useTranslation(); - if (!onTogglePin) return null; - return ( - onTogglePin(taskId)}> - {isPinned ? : } - {isPinned ? t("task:unpin") : t("task:annotationPin")} - - ); -} - -function TaskRenameItem({ - task, - disabled, - onRenameTask, -}: { - task: TaskSwitcherItem; - disabled?: boolean; - onRenameTask?: (taskId: string, currentTitle: string) => void; -}) { - const { t } = useTranslation(); - if (!onRenameTask) return null; - return ( - onRenameTask(task.id, task.title)}> - - {t("task:rename")} - - ); -} - -function TaskEditItem({ - task, - disabled, - onEditTask, -}: { - task: TaskSwitcherItem; - disabled?: boolean; - onEditTask?: (task: TaskSwitcherItem) => void; -}) { - const { t } = useTranslation(); - if (!onEditTask || task.isArchived || !task.workflowId || !task.workflowStepId) return null; - return ( - onEditTask(task)}> - - {t("common:edit")} - - ); -} - -function TaskMoveItems({ - task, - workflows, - stepsByWorkflowId, - steps, - isDeleting, - onMoveToStep, - onMoveToStepWithOptions, - onSubmitWithOptions, - moveOptionsBusy, - actingIds, - actingOnSelection, - onBulkMove, - isMixedWorkflowSelection, - closeMenu, - moveTasks, -}: Omit & { - actingIds: string[]; - actingOnSelection: boolean; -}) { - if (!task.workflowId) return null; - const workflowId = task.workflowId; - // Moving a selection routes through the sidebar hook's bulkMove, which clears - // the selection afterwards. Fall back to a raw move when no bulk handler is - // wired (e.g. the kanban-less callers that don't manage a selection). - const runSelectionMove = ( - targetWorkflowId: string, - stepId: string, - destination: "step" | "workflow", - ) => { - closeMenu(); - if (onBulkMove) { - onBulkMove(actingIds, targetWorkflowId, stepId); - return; - } - void moveTasks(actingIds, targetWorkflowId, stepId, destination).catch(() => { - // useTaskWorkflowMove already shows the failure toast. - }); - }; - - // Single-task right-click keeps the optimistic same-workflow move. A selection - // spanning workflows makes "Move to step" of one workflow ambiguous, so disable - // it there (Send to workflow remains the explicit path). - let moveToStep: ((stepId: string) => void) | undefined; - if (actingOnSelection) { - moveToStep = isMixedWorkflowSelection - ? undefined - : (stepId) => runSelectionMove(workflowId, stepId, "step"); - } else { - moveToStep = (stepId) => { - closeMenu(); - if (onMoveToStep) { - onMoveToStep(task.id, workflowId, stepId); - return; - } - void moveTasks([task.id], workflowId, stepId, "step").catch(() => { - // useTaskWorkflowMove already shows the failure toast. - }); - }; - } - - return ( - { - if (actingOnSelection) { - runSelectionMove(targetWorkflowId, stepId, "workflow"); - return; - } - closeMenu(); - void moveTasks([task.id], targetWorkflowId, stepId, "workflow").catch(() => { - // useTaskWorkflowMove already shows the failure toast. - }); - }} - /> - ); -} diff --git a/apps/web/components/task/task-switcher-link-menu.tsx b/apps/web/components/task/task-switcher-link-menu.tsx index 22e51d0bda..634f60c032 100644 --- a/apps/web/components/task/task-switcher-link-menu.tsx +++ b/apps/web/components/task/task-switcher-link-menu.tsx @@ -13,7 +13,7 @@ import { import { ContextMenuItem, ContextMenuSub, ContextMenuSubTrigger } from "@kandev/ui/context-menu"; import { TaskContextMenuSubContent as ContextMenuSubContent } from "./task-context-menu-sub-content"; import { resolvePluginIcon } from "@/lib/plugins/icons"; -import type { PluginIcon } from "@/lib/plugins/types"; +import type { PluginLinkMenuAction } from "./task-session-sidebar-link-actions"; import { useTaskPluginLinkActions } from "./task-session-sidebar-link-actions"; import type { TaskSwitcherItem } from "./task-switcher-types"; @@ -99,6 +99,40 @@ export function TaskPluginLinkMenu({ disabled, closeMenu, linkActions, + pluginLinkActions: suppliedPluginLinkActions, +}: { + task?: TaskSwitcherItem; + disabled?: boolean; + closeMenu: () => void; + linkActions: Partial>; + pluginLinkActions?: PluginLinkMenuAction[]; +}) { + if (suppliedPluginLinkActions) { + return ( + + ); + } + if (!task) return null; + return ( + + ); +} + +function TaskLinkMenuWithTask({ + task, + disabled, + closeMenu, + linkActions, }: { task: TaskSwitcherItem; disabled?: boolean; @@ -106,6 +140,27 @@ export function TaskPluginLinkMenu({ linkActions: Partial>; }) { const pluginLinkActions = useTaskPluginLinkActions(task.id, task.repositoryLinks ?? []); + return ( + + ); +} + +function TaskLinkMenuWithActions({ + disabled, + closeMenu, + linkActions, + pluginLinkActions, +}: { + disabled?: boolean; + closeMenu: () => void; + linkActions: Partial>; + pluginLinkActions: PluginLinkMenuAction[]; +}) { return ( void; onLinkLinearIssue?: () => void; onLinkSentryIssue?: () => void; - pluginLinkActions?: { id: string; label: string; icon?: PluginIcon; onSelect: () => void }[]; + pluginLinkActions?: PluginLinkMenuAction[]; }) { const { t } = useTranslation(); if ( diff --git a/apps/web/components/task/task-switcher-plugin-menu-items.tsx b/apps/web/components/task/task-switcher-plugin-menu-items.tsx index ce8de586c8..a7a6390370 100644 --- a/apps/web/components/task/task-switcher-plugin-menu-items.tsx +++ b/apps/web/components/task/task-switcher-plugin-menu-items.tsx @@ -1,6 +1,9 @@ "use client"; -import { KanbanCardContextMenuItems } from "@/components/kanban-card-menu-items"; +import { + KanbanCardContextMenuItems, + type KanbanCardMenuEntry, +} from "@/components/kanban-card-menu-items"; import { buildPrimaryPluginEntries } from "@/components/plugins/task-menu-actions"; import { useAppStore } from "@/components/state-provider"; import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; @@ -15,10 +18,18 @@ export function TaskPluginPrimaryMenuItems({ task: TaskSwitcherItem; disabled?: boolean; }) { + const entries = useTaskPluginPrimaryMenuEntries(task, disabled); + return ; +} + +export function useTaskPluginPrimaryMenuEntries( + task: TaskSwitcherItem, + disabled?: boolean, +): KanbanCardMenuEntry[] { usePluginRegistry(); const workspaceId = useAppStore((state) => state.workspaces.activeId); const { isMobile } = useResponsiveBreakpoint(); - if (!workspaceId) return null; + if (!workspaceId) return []; const context: PluginTaskMenuContext = { workspaceId, @@ -27,5 +38,5 @@ export function TaskPluginPrimaryMenuItems({ workflowStepId: task.workflowStepId ?? null, presentation: isMobile ? "mobile" : "desktop", }; - return ; + return buildPrimaryPluginEntries({ disabled, context }); } diff --git a/apps/web/e2e/tests/kanban/card-menu-delete-archive.spec.ts b/apps/web/e2e/tests/kanban/card-menu-delete-archive.spec.ts index 02ef3792a7..0f234f59a8 100644 --- a/apps/web/e2e/tests/kanban/card-menu-delete-archive.spec.ts +++ b/apps/web/e2e/tests/kanban/card-menu-delete-archive.spec.ts @@ -13,6 +13,7 @@ test.describe("Kanban card actions menu — delete/archive does not navigate", ( const task = await apiClient.createTask(seedData.workspaceId, "Card Menu Delete Task", { workflow_id: seedData.workflowId, workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], }); const kanban = new KanbanPage(testPage); await kanban.goto(); @@ -21,6 +22,13 @@ test.describe("Kanban card actions menu — delete/archive does not navigate", ( await kanban.openTaskActionsMenu(task.id); + const menu = testPage.locator('[data-slot="dropdown-menu-content"][data-state="open"]').last(); + const menuLabels = (await menu.locator(":scope > [role='menuitem']").allTextContents()).map( + (text) => text.replace(/\s+/g, " ").trim(), + ); + expect(menuLabels).toEqual(["Priority", "Edit", "Link", "Move to", "Archive", "Delete"]); + await expect(menu.locator(":scope > [data-slot='dropdown-menu-separator']")).toHaveCount(4); + const deleteItem = testPage.getByRole("menuitem", { name: "Delete" }); await expect(deleteItem).toBeVisible(); await deleteItem.click(); diff --git a/apps/web/e2e/tests/kanban/mobile-task-priority.spec.ts b/apps/web/e2e/tests/kanban/mobile-task-priority.spec.ts index cbbcca1e48..c9f0ae1fde 100644 --- a/apps/web/e2e/tests/kanban/mobile-task-priority.spec.ts +++ b/apps/web/e2e/tests/kanban/mobile-task-priority.spec.ts @@ -91,6 +91,18 @@ test.describe("Mobile kanban — task priority", () => { where: (payload) => payload.task_id === task.id && payload.priority === "low", }); await kanban.openTaskActionsMenu(task.id); + const menu = testPage.locator('[data-slot="dropdown-menu-content"]:visible').last(); + const menuLabels = (await menu.locator(":scope > [role='menuitem']").allTextContents()).map( + (text) => text.replace(/\s+/g, " ").trim(), + ); + expect(menuLabels).toEqual(["Priority", "Edit", "Link", "Move to", "Archive", "Delete"]); + await expect(menu.locator(":scope > [data-slot='dropdown-menu-separator']")).toHaveCount(4); + const deleteItem = menu.getByRole("menuitem", { name: "Delete", exact: true }); + await deleteItem.scrollIntoViewIfNeeded(); + const deleteBox = await deleteItem.boundingBox(); + expect(deleteBox).not.toBeNull(); + expect(Math.round(deleteBox!.height)).toBeGreaterThanOrEqual(44); + await expect(deleteItem).toBeInViewport(); await expect(kanban.contextPriority()).toBeVisible(); await kanban.openPrioritySubmenu(); await expect(kanban.contextPriorityCurrent("critical")).toBeVisible(); diff --git a/apps/web/e2e/tests/kanban/task-actions-menu-detail.spec.ts b/apps/web/e2e/tests/kanban/task-actions-menu-detail.spec.ts index c870e5ab61..9b8c4fd7b0 100644 --- a/apps/web/e2e/tests/kanban/task-actions-menu-detail.spec.ts +++ b/apps/web/e2e/tests/kanban/task-actions-menu-detail.spec.ts @@ -33,6 +33,13 @@ test.describe("Task detail top bar actions menu", () => { await expect(trigger).toHaveAttribute("aria-label", "More options"); await trigger.click(); + const menu = testPage.locator('[data-slot="dropdown-menu-content"][data-state="open"]').last(); + const menuLabels = (await menu.locator(":scope > [role='menuitem']").allTextContents()).map( + (text) => text.replace(/\s+/g, " ").trim(), + ); + expect(menuLabels).toEqual(["Priority", "Edit", "Link", "Move to", "Archive", "Delete"]); + await expect(menu.locator(":scope > [data-slot='dropdown-menu-separator']")).toHaveCount(4); + await expect(testPage.getByRole("menuitem", { name: "Edit" })).toBeVisible(); await expect(testPage.getByRole("menuitem", { name: "Delete" })).toBeVisible(); const archiveItem = testPage.getByRole("menuitem", { name: "Archive" }); diff --git a/apps/web/e2e/tests/kanban/task-actions-menu-preview.spec.ts b/apps/web/e2e/tests/kanban/task-actions-menu-preview.spec.ts index e9e83f2364..d998e66ddb 100644 --- a/apps/web/e2e/tests/kanban/task-actions-menu-preview.spec.ts +++ b/apps/web/e2e/tests/kanban/task-actions-menu-preview.spec.ts @@ -33,6 +33,13 @@ test.describe("Task preview panel actions menu", () => { await expect(trigger).toHaveAttribute("aria-label", "More options"); await trigger.click(); + const menu = testPage.locator('[data-slot="dropdown-menu-content"][data-state="open"]').last(); + const menuLabels = (await menu.locator(":scope > [role='menuitem']").allTextContents()).map( + (text) => text.replace(/\s+/g, " ").trim(), + ); + expect(menuLabels).toEqual(["Priority", "Edit", "Link", "Move to", "Archive", "Delete"]); + await expect(menu.locator(":scope > [data-slot='dropdown-menu-separator']")).toHaveCount(4); + const archiveItem = testPage.getByRole("menuitem", { name: "Archive" }); await expect(archiveItem).toBeVisible(); await archiveItem.click(); diff --git a/apps/web/e2e/tests/task/mobile-sidebar-task-actions.spec.ts b/apps/web/e2e/tests/task/mobile-sidebar-task-actions.spec.ts index 2443c5ecbd..103135199c 100644 --- a/apps/web/e2e/tests/task/mobile-sidebar-task-actions.spec.ts +++ b/apps/web/e2e/tests/task/mobile-sidebar-task-actions.spec.ts @@ -485,6 +485,7 @@ test.describe("Mobile sidebar task actions", () => { testPage, apiClient, seedData, + prCapture, }) => { const taskTitle = "Mobile task with diff stats"; const task = await apiClient.createTaskWithAgent( @@ -547,6 +548,9 @@ test.describe("Mobile sidebar task actions", () => { .map((animation) => animation.finished.catch(() => undefined)), ), ); + await prCapture.screenshot("mobile-task-row-menu-grouping", { + caption: "Phone task-row actions grouped inside the scrolling menu", + }); const [menuBox, itemBox] = await Promise.all([menu.boundingBox(), archiveItem.boundingBox()]); const viewport = testPage.viewportSize(); if (!menuBox || !itemBox || !viewport) throw new Error("mobile action sheet has no layout box"); @@ -564,6 +568,37 @@ test.describe("Mobile sidebar task actions", () => { scrollHeight: element.scrollHeight, })); expect(menuOverflow.scrollHeight).toBeGreaterThan(menuOverflow.clientHeight); + const menuLabels = (await menu.locator(":scope > [role='menuitem']").allTextContents()).map( + (text) => text.replace(/\s+/g, " ").trim(), + ); + const menuIndex = (label: string) => menuLabels.indexOf(label); + for (const label of [ + "Pin", + "Color", + "Priority", + "Edit", + "Rename", + "Duplicate", + "Create Subtask", + "Nest under", + "Link", + "Move to", + "Archive", + "Delete", + ]) { + expect(menuIndex(label), `${label} should be in the task action menu`).toBeGreaterThanOrEqual( + 0, + ); + } + expect(menuIndex("Pin")).toBeLessThan(menuIndex("Color")); + expect(menuIndex("Color")).toBeLessThan(menuIndex("Priority")); + expect(menuIndex("Priority")).toBeLessThan(menuIndex("Edit")); + expect(menuIndex("Edit")).toBeLessThan(menuIndex("Create Subtask")); + expect(menuIndex("Create Subtask")).toBeLessThan(menuIndex("Link")); + expect(menuIndex("Link")).toBeLessThan(menuIndex("Move to")); + expect(menuIndex("Move to")).toBeLessThan(menuIndex("Archive")); + expect(menuIndex("Archive")).toBeLessThan(menuIndex("Delete")); + await expect(menu.locator(":scope > [data-slot='context-menu-separator']")).toHaveCount(4); for (const actionName of [ "Pin", "Edit", diff --git a/apps/web/e2e/tests/task/task-menu-grouping.spec.ts b/apps/web/e2e/tests/task/task-menu-grouping.spec.ts new file mode 100644 index 0000000000..d50cb59c67 --- /dev/null +++ b/apps/web/e2e/tests/task/task-menu-grouping.spec.ts @@ -0,0 +1,73 @@ +import { test, expect } from "../../fixtures/test-base"; +import { SessionPage } from "../../pages/session-page"; + +test.describe("Desktop task-row menu grouping", () => { + test("orders the groups and persists a priority choice", async ({ + testPage, + apiClient, + seedData, + prCapture, + }) => { + const sameWorkflowStep = + seedData.steps.find((step) => step.id !== seedData.startStepId) ?? + (await apiClient.createWorkflowStep(seedData.workflowId, "Menu grouping second step", 1)).id; + const targetWorkflow = await apiClient.createWorkflow( + seedData.workspaceId, + "Menu grouping target workflow", + ); + const targetStep = await apiClient.createWorkflowStep(targetWorkflow.id, "Incoming", 0); + const task = await apiClient.createTask(seedData.workspaceId, "Menu grouping root task", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + priority: "high", + }); + + await testPage.goto(`/t/${task.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + + const row = session.sidebarTaskItem(task.title).first(); + await expect(row).toBeVisible(); + await row.click({ button: "right" }); + + const menu = testPage.locator('[data-slot="context-menu-content"][data-state="open"]').last(); + await expect(menu).toBeVisible(); + const labels = (await menu.locator(":scope > [role='menuitem']").allTextContents()).map( + (text) => text.replace(/\s+/g, " ").trim(), + ); + expect(labels).toEqual([ + "Pin", + "Color", + "Priority", + "Edit", + "Rename", + "Duplicate", + "Create Subtask", + "Nest under", + "Link", + "Move to", + "Send to workflow", + "Archive", + "Delete", + ]); + + const separators = menu.locator(":scope > [data-slot='context-menu-separator']"); + await expect(separators).toHaveCount(4); + await expect(menu.getByTestId("task-context-send-to-workflow")).toBeVisible(); + await prCapture.screenshot("desktop-task-row-menu-grouping", { + caption: "Desktop task-row actions grouped by purpose", + }); + + await menu.getByTestId("task-context-priority").hover(); + const lowOption = testPage.getByTestId("task-context-priority-low"); + await expect(lowOption).toBeVisible(); + await lowOption.click(); + await expect.poll(async () => (await apiClient.getTask(task.id)).priority).toBe("low"); + + // Keep the move fixtures live in the test so the grouping assertion proves + // both movement entries are admitted, not just the same-workflow branch. + expect(sameWorkflowStep).toBeTruthy(); + expect(targetStep.id).toBeTruthy(); + }); +}); diff --git a/apps/web/hooks/use-kanban-card-move-targets.ts b/apps/web/hooks/use-kanban-card-move-targets.ts new file mode 100644 index 0000000000..80af12d445 --- /dev/null +++ b/apps/web/hooks/use-kanban-card-move-targets.ts @@ -0,0 +1,77 @@ +"use client"; + +import { useMemo } from "react"; +import { useShallow } from "zustand/react/shallow"; +import { useAppStore } from "@/components/state-provider"; +import type { WorkflowStep } from "@/components/kanban-card"; +import type { TaskMoveStep, TaskMoveWorkflow } from "@/components/task/task-move-context-menu"; +import { sortWorkflowStepsByPosition } from "@/lib/kanban/workflow-step-order"; +import type { WorkflowSnapshotData } from "@/lib/state/slices/kanban/types"; + +export type KanbanCardMoveTargets = { + currentWorkflowId: string | null; + workflowItems: TaskMoveWorkflow[]; + stepsByWorkflowId: Record; +}; + +/** + * The card's hot render path: a caller with column context already passes a + * `steps` list pre-filtered for this workflow, and its `currentWorkflowId` + * always resolves from `kanbanMulti.snapshots`. Kept to exactly these two + * subscriptions (system design: no `kanban.tasks`/`hiddenWorkflowStepIds` + * reads here) so unrelated writes to either slice never re-render every + * visible card. Callers without column context (preview panel, detail top + * bar) use `useTaskActionsMenuMoveTargets` instead, which layers the + * flat-list and hidden-step fallbacks this hook intentionally omits. + */ +export function useKanbanCardMoveTargets( + taskId: string, + steps?: WorkflowStep[], +): KanbanCardMoveTargets { + const workflows = useAppStore((state) => state.workflows.items); + const currentWorkflowId = useAppStore((state) => { + for (const [workflowId, snapshot] of Object.entries(state.kanbanMulti.snapshots)) { + if (snapshot.tasks.some((task) => task.id === taskId)) return workflowId; + } + return null; + }); + const snapshotStepsByWorkflowId = useAppStore( + useShallow((state): Record => { + const result: Record = {}; + for (const [workflowId, snapshot] of Object.entries(state.kanbanMulti.snapshots)) { + result[workflowId] = snapshot.steps; + } + return result; + }), + ); + + const workflowItems = useMemo(() => { + const current = workflows.find((workflow) => workflow.id === currentWorkflowId); + return workflows + .filter((workflow) => workflow.workspaceId === current?.workspaceId && !workflow.hidden) + .map((workflow) => ({ id: workflow.id, name: workflow.name, hidden: workflow.hidden })); + }, [workflows, currentWorkflowId]); + + const stepsByWorkflowId = useMemo>(() => { + const result: Record = {}; + for (const [workflowId, snapshotSteps] of Object.entries(snapshotStepsByWorkflowId)) { + result[workflowId] = sortWorkflowStepsByPosition(snapshotSteps).map((step) => ({ + id: step.id, + title: step.title, + color: step.color, + events: step.events, + })); + } + if (currentWorkflowId && steps) { + result[currentWorkflowId] = steps.map((step) => ({ + id: step.id, + title: step.title, + color: step.color, + events: step.events, + })); + } + return result; + }, [snapshotStepsByWorkflowId, currentWorkflowId, steps]); + + return { currentWorkflowId, workflowItems, stepsByWorkflowId }; +} diff --git a/apps/web/lib/kanban/task-actions-menu-entries.test.ts b/apps/web/lib/kanban/task-actions-menu-entries.test.ts index d5bfe688f0..2cbe571be7 100644 --- a/apps/web/lib/kanban/task-actions-menu-entries.test.ts +++ b/apps/web/lib/kanban/task-actions-menu-entries.test.ts @@ -4,7 +4,7 @@ import { buildTaskActionsMenuEntries } from "./task-actions-menu-entries"; import type { KanbanCardMenuEntry } from "@/components/kanban-card-menu-items"; const PLUGIN_ID = "test-task-actions-menu-plugin"; -const DELETE_SEPARATOR = "delete-separator"; +const REMOVE_SEPARATOR = "remove-separator"; function itemKeys(entries: KanbanCardMenuEntry[]): string[] { return entries.map((entry) => entry.key); @@ -59,14 +59,17 @@ describe("buildTaskActionsMenuEntries — normal tier", () => { it("presents the full card order for a resolved, unarchived subject", () => { const entries = buildTaskActionsMenuEntries("normal", baseArgs); expect(itemKeys(entries)).toEqual([ - "edit", "priority", + "edit-separator", + "edit", + "relationships-separator", + "link", + "detach", + "move-separator", "move-to", "send-to-workflow", - "link", + REMOVE_SEPARATOR, "archive", - "detach", - DELETE_SEPARATOR, "delete", ]); }); @@ -129,7 +132,7 @@ describe("buildTaskActionsMenuEntries — archived tier", () => { const entries = buildTaskActionsMenuEntries("archived", baseArgs); expect(itemKeys(entries)).toEqual([ `plugin-primary-${PLUGIN_ID}-primary-action`, - DELETE_SEPARATOR, + REMOVE_SEPARATOR, "delete", ]); }); @@ -148,7 +151,7 @@ describe("buildTaskActionsMenuEntries — unresolved board row tier", () => { it("presents only Archive and Delete when no plugin action is admitted", () => { const entries = buildTaskActionsMenuEntries("unresolved-row", baseArgs); - expect(itemKeys(entries)).toEqual(["archive", DELETE_SEPARATOR, "delete"]); + expect(itemKeys(entries)).toEqual(["archive", "delete"]); }); it("orders admitted plugin primary actions before Archive", () => { @@ -162,8 +165,8 @@ describe("buildTaskActionsMenuEntries — unresolved board row tier", () => { const entries = buildTaskActionsMenuEntries("unresolved-row", baseArgs); expect(itemKeys(entries)).toEqual([ `plugin-primary-${PLUGIN_ID}-primary-action`, + REMOVE_SEPARATOR, "archive", - DELETE_SEPARATOR, "delete", ]); }); diff --git a/apps/web/lib/kanban/task-actions-menu-entries.ts b/apps/web/lib/kanban/task-actions-menu-entries.ts index 8565f3b113..8bd66ecc1e 100644 --- a/apps/web/lib/kanban/task-actions-menu-entries.ts +++ b/apps/web/lib/kanban/task-actions-menu-entries.ts @@ -1,5 +1,6 @@ import { buildArchiveEntry, + buildGroupedMenuEntries, buildDeleteEntry, buildKanbanCardMenuEntries, resolvePluginMenuContext, @@ -37,12 +38,13 @@ function buildArchivedTaskActionsMenuEntries({ disabled: isProcessing, context: resolvePluginMenuContext(pluginMenuContext), }); - const entries: KanbanCardMenuEntry[] = [...pluginEntries]; - if (pluginEntries.length > 0) { - entries.push({ kind: "separator", key: "delete-separator" }); - } - entries.push(buildDeleteEntry({ isDeleting, isProcessing, onDelete })); - return entries; + return buildGroupedMenuEntries([ + { key: "plugins", entries: pluginEntries }, + { + key: "remove", + entries: [buildDeleteEntry({ isDeleting, isProcessing, onDelete })], + }, + ]); } function buildUnresolvedRowTaskActionsMenuEntries({ @@ -54,13 +56,20 @@ function buildUnresolvedRowTaskActionsMenuEntries({ pluginMenuContext, }: BuildKanbanCardMenuEntriesArgs): KanbanCardMenuEntry[] { const isProcessing = Boolean(disabled || isDeleting || isArchiving); - return [ - ...buildPrimaryPluginEntries({ - disabled: isProcessing, - context: resolvePluginMenuContext(pluginMenuContext), - }), - buildArchiveEntry({ isArchiving, isProcessing, onArchive }), - { kind: "separator", key: "delete-separator" }, - buildDeleteEntry({ isDeleting, isProcessing, onDelete }), - ]; + return buildGroupedMenuEntries([ + { + key: "plugins", + entries: buildPrimaryPluginEntries({ + disabled: isProcessing, + context: resolvePluginMenuContext(pluginMenuContext), + }), + }, + { + key: "remove", + entries: [ + buildArchiveEntry({ isArchiving, isProcessing, onArchive }), + buildDeleteEntry({ isDeleting, isProcessing, onDelete }), + ], + }, + ]); } diff --git a/apps/web/lib/plugins/types.ts b/apps/web/lib/plugins/types.ts index 9d331cbfc4..b5b22e592a 100644 --- a/apps/web/lib/plugins/types.ts +++ b/apps/web/lib/plugins/types.ts @@ -342,8 +342,8 @@ export type PluginTaskMenuContext = PluginSDK.PluginTaskMenuContext; * Registration accepted by `PluginRegistry.registerTaskMenuAction`: * contributes an item to the kanban card context/dropdown menu. Group * "edit" nests the item inside the card's `Edit` submenu; group "primary" - * renders it as a flat, top-level menu item, positioned between the "Move - * to"/"Send to workflow" submenus and the "Link" submenu. + * renders it as a flat, top-level menu item after the movement group and + * before the `Archive`/`Delete` removal group. */ export type TaskMenuActionRegistration = PluginSDK.TaskMenuActionRegistration; diff --git a/docs/plans/plugins/PLUGIN-API.md b/docs/plans/plugins/PLUGIN-API.md index 0d773ad0d9..1bf63856bc 100644 --- a/docs/plans/plugins/PLUGIN-API.md +++ b/docs/plans/plugins/PLUGIN-API.md @@ -899,8 +899,8 @@ interface PluginRegistry { registerTaskPanel(registration: TaskPanelRegistration): void; // Contributes an item to the kanban card's Edit submenu (group "edit") or - // a flat, top-level card menu item between "Move to"/"Send to workflow" - // and "Link" (group "primary"). See "Kanban card contributions" below. + // a flat, top-level card menu item after "Move to"/"Send to workflow" + // and before "Archive"/"Delete" (group "primary"). See "Kanban card contributions" below. registerTaskMenuAction(registration: TaskMenuActionRegistration): void; // Contributes a client-side filter section to the kanban board's display @@ -1184,8 +1184,8 @@ interface TaskMenuActionRegistration { label: string; icon?: React.ReactNode; // "edit" nests the item in the card's Edit submenu; "primary" renders it - // as a flat, top-level item between the "Move to"/"Send to workflow" - // submenus and the "Link" submenu. + // 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 run(context: PluginTaskMenuContext): void | Promise; // a rejection is caught and logged @@ -1300,10 +1300,11 @@ 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 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). +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). `"task-card-indicators"` (documented above with the other slots) is the matching read-only surface: a small icon/badge rendered beside the PR status diff --git a/docs/plans/task-menu-grouping/plan.md b/docs/plans/task-menu-grouping/plan.md new file mode 100644 index 0000000000..5405330fb8 --- /dev/null +++ b/docs/plans/task-menu-grouping/plan.md @@ -0,0 +1,220 @@ +--- +created: 2026-09-10 +status: complete +requirements: + - REQ-TASKS-MENU-GROUPING-001 +system_design: + - ../../specs/tasks/system-design/task-menu-grouping.md +legacy_specs: [] +--- + +# Implementation Plan: Task menu grouping + +## Overview + +Group existing task actions by purpose across task rows, cards, preview, and desktop detail menus. +Implement task-row composition first, then the shared card builder and its consumers. +Each work order includes focused unit and browser evidence. + +## Scope + +### In scope + +- The agreed action order, thin dividers, conditional groups, and plugin placement. +- Existing single-task, bulk, archived, and unresolved menu variants. +- Desktop context and dropdown menus, plus existing phone touch menus. +- Documentation that describes the previous plugin or task menu position. + +### Out of scope + +- New actions, card Pin/Color parity, working Duplicate, backend changes, or new plugin groups. +- New mobile shells, group headings, or localization keys. +- Commits, PR creation, or implementation during this design turn. + +## Technical approach + +Task rows use `SingleSelectionMenuItems` and `BulkSelectionMenuItems`. +The implementation makes action eligibility explicit enough to omit empty groups. +Leaf-owned separators move to the group composition without changing other callers. +Existing selection callbacks, confirmations, and event guards remain intact. + +Cards use `buildKanbanCardMenuEntries` for both renderers. +Compose nonempty entry arrays and insert one separator between them. +`buildTaskActionsMenuEntries` inherits normal ordering and groups its reduced variants consistently. +Preserve Edit plugin nesting on cards and flat Edit on preview/detail surfaces. + +The task system owns the requirement because the contract concerns task actions across surfaces. +The existing preview/detail pair links to the new grouping pair for ordering. +No linked companion plan exists for that pair in this checkout. +Existing priority plans remain historical records of their action behavior, which this change preserves. + +## ASCII UI preview + +UI-01: Task-row context menu or visible ellipsis, normal task. +Source baseline: Pin, Edit, Priority, Rename, Create subtask, Duplicate, Archive, +Color, Nest under, plugins, Link, movement, Detach, Delete. Availability varies. + +Proposed groups: + +```text ++---------------------------+ +| Pin | +| Color > | +| Priority > | ++---------------------------+ +| Edit | +| Rename | +| Duplicate (disabled) | ++---------------------------+ +| Create subtask | +| Nest under > | +| Link > | +| Detach from parent* | ++---------------------------+ +| Move to > | +| Send to workflow > | ++---------------------------+ +| Primary plugin actions* | ++---------------------------+ +| Archive | +| Delete | ++---------------------------+ +``` + +UI-02: Card right-click or three-dot menu, normal task. +Preview/detail use the same order, with flat Edit and their existing eligibility. +Source baseline: Edit, Priority, Move to, Send to workflow, plugins, Link, +Archive, Detach, separator, Delete. + +```text ++---------------------------+ +| Priority > | ++---------------------------+ +| Edit * | ++---------------------------+ +| Link > | +| Detach from parent* | ++---------------------------+ +| Move to > | +| Send to workflow > | ++---------------------------+ +| Primary plugin actions* | ++---------------------------+ +| Archive | +| Delete | ++---------------------------+ +``` + +An asterisk marks conditional content, not literal copy. +Card Edit becomes a submenu only when admitted Edit plugin actions exist. +All existing eligibility rules apply. Empty groups and adjacent dividers disappear. +Archive is neutral. Delete is red. Group order is required, while spacing is illustrative. + +UI-03: Phone, visible row/card ellipsis, below 640px. +The existing inset menu uses the same applicable rows as UI-01 or UI-02. + +```text ++-----------------------------+ +| Task list or task drawer | +| Task title ... | +| | +| dimmed underlying surface | +| +-------------------------+ | +| | Pin / Color / Priority | | +| |------------------------| | | +| | Edit / Rename / ... | | | <- internal scroll +| |------------------------| | | +| | Relationships | | | +| | Movement / plugins v | | +| |-------------------------| | +| | Archive | | +| | Delete | | +| +-------------------------+ | +| safe-area spacing | ++-----------------------------+ +``` + +Slash-separated lines abbreviate separate action rows, not combined controls. +The entire menu body scrolls, including Archive/Delete. There is no fixed action footer. +Touch rows retain at least 44px targets. Submenus retain the existing touch presentation. +At 640px and above, existing anchored placement remains in force. + +UI-04: Reduced states. + +```text +Bulk row Archived detail Unresolved detail +Pin N tasks [plugin actions] [plugin actions] +---------------- ---------------- ---------------- +Move to > Delete Archive +Send to workflow > Delete +---------------- +Archive N tasks +Delete N tasks +``` + +Plugin rows and their divider disappear when no primary action is admitted. +Archived row menus preserve their own existing eligibility rather than adopting the detail entry set. +Disabled and pending actions retain existing styling and cannot invoke a second operation. + +UI-01/02 cover criteria .1-.5 and .8. UI-03 covers .7-.8. UI-04 covers .3-.6. + +## Tests + +| Evidence | Criteria | +| --- | --- | +| `task-switcher-context-menu.test.tsx`: grouped normal/subtask order, hidden groups, disabled Duplicate | .1, .3, .4 | +| Same suite: bulk and archived membership, callbacks and event guards | .4, .6, .8 | +| `kanban-card-menu-items.test.tsx`: exact group boundaries and both renderer parity | .2-.4 | +| Same suite: primary registration order and visibility changes, Edit/Link placement | .5 | +| `task-actions-menu-entries.test.ts`: normal, archived, unresolved, no-plugin variants | .2, .3, .6 | + +Paths above are under `apps/web/components/task`, `apps/web/components`, and `apps/web/lib/kanban` respectively. +Use behavioral assertions for visible order and separators, with callback assertions for moved actions. + +## E2E tests + +- New `tests/task/task-menu-grouping.spec.ts`, chromium: row grouping, Color before Priority, keyboard order, and one persisted priority update. +- Existing `tests/task/mobile-sidebar-task-actions.spec.ts`, mobile-chrome: tap opener, grouping, nested choices, internal scrolling, and archive confirmation cancellation. +- Existing `tests/kanban/card-menu-delete-archive.spec.ts`, chromium: both card openers, grouping, and existing removal outcomes. +- Existing `tests/kanban/task-actions-menu-preview.spec.ts` and `task-actions-menu-detail.spec.ts`, chromium: inherited grouping and existing focus/confirmation outcomes. +- Existing `tests/kanban/mobile-task-priority.spec.ts`, mobile-chrome: card touch menu grouping and priority persistence. + +Phone checks include long-menu containment, 44px targets, last-action reachability, +and no document horizontal overflow. Capture desktop and phone screenshots for preview comparison. +Keep all existing mutation scenarios. Add ordering checks alongside them. + +## Work orders + +- [x] [Task 01: Group task-row actions](task-01-task-row-groups.md) +- [x] [Task 02: Group card actions](task-02-card-groups.md) + +Execute sequentially. Work orders do not authorize delegation. +Exact verification commands are in each work order. + +## Verification results + +Implementation checks (2026-09-10): + +- Focused Vitest: 3 files passed, 50 tests passed. +- ESLint on changed source, test, and E2E files: passed with 0 errors; the repository's existing file/function-size and complexity rules reported warnings only. +- `pnpm run typecheck`: passed. +- `pnpm run i18n:check`: passed, including 3018 guarded files. +- Desktop row E2E: 1 passed after the managed runner build; the corrected fixture uses a root task because sidebar Pin is not offered for subtasks. +- Mobile sidebar E2E: 17 passed, including grouping, internal scroll, touch-target, and removal reachability checks. +- Desktop card/preview/detail E2E: 9 passed. +- Mobile card priority E2E: 1 passed. +- Fresh desktop and phone menu screenshots were captured, inspected, PNG-compressed, and validated against the two-entry PR asset manifest. + +Document checks (2026-09-10): + +- `python3 scripts/lint-spec-files.test.py`: passed, 36 tests. +- `python3 scripts/lint-spec-files.py --all`: all specification files passed. +- `git diff --check`: passed. + +## Risks + +- Children that return null can leave dividers around empty groups. +- Leaf-owned dividers can duplicate the new group boundaries. +- Plugin placement descriptions currently name the old location. +- Added dividers increase phone menu height and require real scrolling evidence. +- Existing tests can encode the previous order. Update order assertions without removing behavior coverage. diff --git a/docs/plans/task-menu-grouping/task-01-task-row-groups.md b/docs/plans/task-menu-grouping/task-01-task-row-groups.md new file mode 100644 index 0000000000..dac375cc0e --- /dev/null +++ b/docs/plans/task-menu-grouping/task-01-task-row-groups.md @@ -0,0 +1,149 @@ +--- +id: "01-task-row-groups" +title: "Group task-row actions" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +requirements: + - REQ-TASKS-MENU-GROUPING-001 +acceptance_criteria: + - AC-TASKS-MENU-GROUPING-001.1 + - AC-TASKS-MENU-GROUPING-001.2 + - AC-TASKS-MENU-GROUPING-001.3 + - AC-TASKS-MENU-GROUPING-001.4 + - AC-TASKS-MENU-GROUPING-001.5 + - AC-TASKS-MENU-GROUPING-001.6 + - AC-TASKS-MENU-GROUPING-001.7 + - AC-TASKS-MENU-GROUPING-001.8 +system_design: + - ../../specs/tasks/system-design/task-menu-grouping.md +--- + +# Task 01: Group task-row actions + +## Summary + +Reorder single-task and bulk task-row menus. Keep groups free of empty or duplicate dividers. +Preserve action eligibility, touch interaction, selection clearing, and confirmations. + +## In scope + +- The files and menu variants named below. +- Focused unit and browser coverage for changed order, dividers, and existing action outcomes. +- TDD: establish failing order/group assertions before production edits. + +## Out of scope + +- New actions, backend changes, plugin registration types, and replacement mobile shells. + +## Acceptance + +1. Normal, subtask, archived, and bulk menu variants have the specified groups with no empty dividers. +2. Existing handlers, disabled states, selection behavior, and drag guards pass their regression tests. +3. Desktop and phone tests prove ordering and an action outcome, including scrolling to removal actions. + +## ASCII UI preview + +UI-01: Task-row menu. UI-03: Same sequence in the phone inset scrolling menu. + +```text +Pin / Color > / Priority > +------------------------- +Edit / Rename / Duplicate (disabled) +------------------------- +Create subtask / Nest under > / Link > / Detach* +------------------------- +Move to > / Send to workflow > +------------------------- +Primary plugin actions* +------------------------- +Archive / Delete +``` + +Each slash denotes a separate action row. Asterisks denote conditional entries. +UI-04 bulk order is Pin, movement, removal. Existing archived-row eligibility remains intact. + +The [full previews](plan.md#ascii-ui-preview) define UI-01 through UI-04. +Criteria AC-TASKS-MENU-GROUPING-001.1 through .8 apply to the relevant variants. +The phone entry point is the existing visible ellipsis. +Below 640px, the existing inset menu scrolls internally and preserves safe-area spacing. +At wider widths, existing anchored placement remains. Touch rows retain 44px targets. +No menu heading or fixed removal footer is added. Compare rendered desktop and phone screenshots with these previews. + +## Verification + +Run from the repository root. Managed E2E commands rebuild the application. +Do not overlap desktop and mobile runs. Record discovered tests and final results. + +```bash +(cd apps && pnpm install --frozen-lockfile) +(cd apps/web && pnpm test components/task/task-switcher-context-menu.test.tsx) +(cd apps/web && pnpm exec eslint components/task/task-switcher-context-menu.tsx components/task/task-switcher-action-items.tsx components/task/task-switcher-plugin-menu-items.tsx components/task/task-switcher-link-menu.tsx components/task/task-move-context-menu.tsx) +(cd apps/web && pnpm run typecheck) +(cd apps/web && pnpm run i18n:check) +(cd apps/web && pnpm e2e:run --project chromium tests/task/task-menu-grouping.spec.ts) +(cd apps/web && pnpm e2e:run --project mobile-chrome tests/task/mobile-sidebar-task-actions.spec.ts) +python3 scripts/lint-spec-files.test.py +python3 scripts/lint-spec-files.py --all +git diff --check +git status --short -- docs/plans/task-menu-grouping +``` + +Run targeted tests and lint for any additional helper extracted during implementation. +The workspace install is required before the first package command in this worktree. +Existing tests remain in place. New tests assert group boundaries, not merely label presence. +Phone tests must tap the opener and a submenu, complete an action, and reach the final removal row. +Use existing confirmation flows and cancel removal when the scenario only checks reachability. +For long menus, assert viewport containment, internal scroll, and no document horizontal overflow. + +## Files likely touched + +- `apps/web/components/task/task-switcher-context-menu.tsx` +- `apps/web/components/task/task-switcher-action-items.tsx` +- `apps/web/components/task/task-switcher-plugin-menu-items.tsx` +- `apps/web/components/task/task-switcher-link-menu.tsx` +- `apps/web/components/task/task-move-context-menu.tsx` +- `apps/web/components/task/task-switcher-context-menu.test.tsx` +- `apps/web/e2e/tests/task/task-menu-grouping.spec.ts (new)` +- `apps/web/e2e/tests/task/mobile-sidebar-task-actions.spec.ts` + +## Dependencies + +None. + +## Risks + +JSX children can return null after group composition. Existing action components can insert their own separators. + +## Parallelism + +`sequential` + +## Inputs + +- [Requirements](../../specs/tasks/requirements/task-menu-grouping.md). +- [System design](../../specs/tasks/system-design/task-menu-grouping.md). +- [Preview/detail contract](../../specs/tasks/requirements/task-actions-menu.md). +- Existing tests beside the named source files and the listed E2E scenarios. +- Scoped web guidance and the mobile-parity, TDD, and E2E skills. + +## Results + +Implemented the grouped single-task and bulk task-row composition with one +conditional divider between nonempty groups. Existing action eligibility, +selection clearing, drag guards, archive confirmation, touch sizing, and +mobile scrolling remain intact. Primary plugin actions are rendered as a +separate group before removal. + +Verification on 2026-09-10: + +- `pnpm test components/task/task-switcher-context-menu.test.tsx ...`: 3 files passed, 50 tests passed. +- `pnpm run typecheck`: passed. +- `pnpm run i18n:check`: passed. +- `pnpm e2e:run --no-build --project chromium tests/task/task-menu-grouping.spec.ts`: 1 passed after the managed runner build. +- `pnpm e2e:run --no-build --project mobile-chrome tests/task/mobile-sidebar-task-actions.spec.ts`: 17 passed. +- Fresh desktop and phone task-row screenshots were captured, inspected, compressed, and mapped in the PR asset manifest. +- `python3 scripts/lint-spec-files.test.py`: 36 tests passed. +- `python3 scripts/lint-spec-files.py --all`: passed. +- `git diff --check`: passed. diff --git a/docs/plans/task-menu-grouping/task-02-card-groups.md b/docs/plans/task-menu-grouping/task-02-card-groups.md new file mode 100644 index 0000000000..8e5090ae86 --- /dev/null +++ b/docs/plans/task-menu-grouping/task-02-card-groups.md @@ -0,0 +1,153 @@ +--- +id: "02-card-groups" +title: "Group card actions" +status: done +wave: 2 +depends_on: ["01-task-row-groups"] +plan: "plan.md" +requirements: + - REQ-TASKS-MENU-GROUPING-001 +acceptance_criteria: + - AC-TASKS-MENU-GROUPING-001.1 + - AC-TASKS-MENU-GROUPING-001.2 + - AC-TASKS-MENU-GROUPING-001.3 + - AC-TASKS-MENU-GROUPING-001.4 + - AC-TASKS-MENU-GROUPING-001.5 + - AC-TASKS-MENU-GROUPING-001.6 + - AC-TASKS-MENU-GROUPING-001.7 + - AC-TASKS-MENU-GROUPING-001.8 +system_design: + - ../../specs/tasks/system-design/task-menu-grouping.md +--- + +# Task 02: Group card actions + +## Summary + +Reorder the shared card entries and the inherited preview/detail menus. +Preserve conditional plugin placement and the archived/unresolved action sets. +Synchronize descriptions of the old menu positions in the plugin contract documentation. + +## In scope + +- The files and menu variants named below. +- Focused unit and browser coverage for changed order, dividers, and existing action outcomes. +- TDD: establish failing order/group assertions before production edits. + +## Out of scope + +- New actions, backend changes, plugin registration types, and replacement mobile shells. + +## Acceptance + +1. Card context/dropdown menus and normal preview/detail menus use the required order and dividers. +2. Reduced tiers and plugin visibility changes produce no empty groups or changes to action eligibility. +3. Browser checks preserve action outcomes and phone reachability. Placement documentation matches the new groups. + +## ASCII UI preview + +UI-02: Card context/three-dot menu. UI-03: Same sequence in the phone inset scrolling menu. + +```text +Priority > +------------------------- +Edit [submenu only for card plugin actions] +------------------------- +Link > / Detach from parent* +------------------------- +Move to > / Send to workflow > +------------------------- +Primary plugin actions* +------------------------- +Archive / Delete +``` + +Each slash denotes a separate row. Asterisks denote conditional entries. +UI-04 archived detail: plugins, divider, Delete. Without plugins, show Delete alone. +UI-04 unresolved detail: plugins, divider, Archive and Delete together. + +The [full previews](plan.md#ascii-ui-preview) define UI-01 through UI-04. +Criteria AC-TASKS-MENU-GROUPING-001.1 through .8 apply to the relevant variants. +The phone entry point is the existing visible ellipsis. +Below 640px, the existing inset menu scrolls internally and preserves safe-area spacing. +At wider widths, existing anchored placement remains. Touch rows retain 44px targets. +No menu heading or fixed removal footer is added. Compare rendered desktop and phone screenshots with these previews. + +## Verification + +Run from the repository root. Managed E2E commands rebuild the application. +Do not overlap desktop and mobile runs. Record discovered tests and final results. + +```bash +(cd apps/web && pnpm test components/kanban-card-menu-items.test.tsx lib/kanban/task-actions-menu-entries.test.ts) +(cd apps/web && pnpm exec eslint components/kanban-card-menu-items.tsx lib/kanban/task-actions-menu-entries.ts components/plugins/task-menu-actions.ts) +(cd apps/web && pnpm run typecheck) +(cd apps/web && pnpm run i18n:check) +(cd apps/web && pnpm e2e:run --project chromium tests/kanban/card-menu-delete-archive.spec.ts tests/kanban/task-actions-menu-preview.spec.ts tests/kanban/task-actions-menu-detail.spec.ts) +(cd apps/web && pnpm e2e:run --project mobile-chrome tests/kanban/mobile-task-priority.spec.ts) +python3 scripts/lint-spec-files.test.py +python3 scripts/lint-spec-files.py --all +git diff --check +git status --short -- docs/plans/task-menu-grouping +``` + +Run targeted tests and lint for any additional helper extracted during implementation. +Task 01 supplies the workspace dependency installation. +Existing tests remain in place. New tests assert group boundaries, not merely label presence. +Phone tests must tap the opener and a submenu, complete an action, and reach the final removal row. +Use existing confirmation flows and cancel removal when the scenario only checks reachability. +For long menus, assert viewport containment, internal scroll, and no document horizontal overflow. + +## Files likely touched + +- `apps/web/components/kanban-card-menu-items.tsx` +- `apps/web/lib/kanban/task-actions-menu-entries.ts` +- `apps/web/components/plugins/task-menu-actions.ts` +- `apps/web/components/kanban-card-menu-items.test.tsx` +- `apps/web/lib/kanban/task-actions-menu-entries.test.ts` +- `apps/web/e2e/tests/kanban/card-menu-delete-archive.spec.ts` +- `apps/web/e2e/tests/kanban/task-actions-menu-preview.spec.ts` +- `apps/web/e2e/tests/kanban/task-actions-menu-detail.spec.ts` +- `apps/web/e2e/tests/kanban/mobile-task-priority.spec.ts` +- `docs/plans/plugins/PLUGIN-API.md` +- `apps/packages/plugin-sdk (placement comments only, if present)` +- `apps/web/lib/plugins/types.ts (placement comments only, if present)` + +## Dependencies + +Task 01. Keep any shared helper compatible with its task-row consumers. + +## Risks + +The shared builder affects preview/detail automatically. Plugin comments and tests can still describe the old primary-action location. + +## Parallelism + +`sequential` + +## Inputs + +- [Requirements](../../specs/tasks/requirements/task-menu-grouping.md). +- [System design](../../specs/tasks/system-design/task-menu-grouping.md). +- [Preview/detail contract](../../specs/tasks/requirements/task-actions-menu.md). +- Existing tests beside the named source files and the listed E2E scenarios. +- Scoped web guidance and the mobile-parity, TDD, and E2E skills. + +## Results + +Implemented shared card grouping and inherited normal preview/detail ordering. +Archived and unresolved tiers now insert separators only between admitted plugin +and removal groups. Primary plugin placement documentation now describes the +movement-to-removal position, while Edit plugin nesting and Link plugin +placement remain unchanged. + +Verification on 2026-09-10: + +- `pnpm test components/kanban-card-menu-items.test.tsx lib/kanban/task-actions-menu-entries.test.ts`: 3 focused files passed, 50 tests passed with the row suite. +- `pnpm run typecheck`: passed. +- `pnpm run i18n:check`: passed. +- `pnpm e2e:run --no-build --project chromium tests/kanban/card-menu-delete-archive.spec.ts tests/kanban/task-actions-menu-preview.spec.ts tests/kanban/task-actions-menu-detail.spec.ts`: 9 passed. +- `pnpm e2e:run --no-build --project mobile-chrome tests/kanban/mobile-task-priority.spec.ts`: 1 passed. +- `python3 scripts/lint-spec-files.test.py`: 36 tests passed. +- `python3 scripts/lint-spec-files.py --all`: passed. +- `git diff --check`: passed. diff --git a/docs/specs/tasks/README.md b/docs/specs/tasks/README.md index 59e45fa75b..d739e6878b 100644 --- a/docs/specs/tasks/README.md +++ b/docs/specs/tasks/README.md @@ -88,6 +88,7 @@ signals, and task-scoped scheduling contracts. - [Session Delete Preserves Task Workspaces](requirements/session-delete-resource-cleanup.md) - [Sidebar Task Editing](requirements/sidebar-task-edit.md) - [Task Actions Menu on Preview and Detail Surfaces](requirements/task-actions-menu.md) +- [Task menu grouping](requirements/task-menu-grouping.md) - [Task Actions Menu In-Flight and Concurrency](requirements/task-actions-menu-concurrency.md) - [Task Actions Menu Action Outcomes](requirements/task-actions-menu-outcomes.md) - [Subtasks as Workflow Checklist](requirements/subtask-checklist.md) @@ -158,6 +159,7 @@ signals, and task-scoped scheduling contracts. - [Passthrough Initial Prompt Turn Boundary](system-design/passthrough-initial-prompt-turn-boundary.md) - [Prompt attachments](system-design/prompt-attachments.md) - [Task Actions Menu on Preview and Detail Surfaces](system-design/task-actions-menu.md) +- [Task menu grouping](system-design/task-menu-grouping.md) - [Task Archive Confirmation](system-design/archive-confirmation.md) - [Task removal navigation](system-design/removal-navigation.md) - [Task plan content size limit](system-design/plan-content-size-limit.md) diff --git a/docs/specs/tasks/requirements/task-actions-menu.md b/docs/specs/tasks/requirements/task-actions-menu.md index 92f7201279..a361a1b603 100644 --- a/docs/specs/tasks/requirements/task-actions-menu.md +++ b/docs/specs/tasks/requirements/task-actions-menu.md @@ -158,10 +158,10 @@ in the same order, with the same labels, wherever the menu is opened. in-flight disabled state, which is per surface under AC-TASKS-TASK-ACTIONS-MENU-004.1b and so may differ from the card's for the duration of a request. -- **AC-TASKS-TASK-ACTIONS-MENU-002.2:** The order named in - AC-TASKS-TASK-ACTIONS-MENU-002.1 is: Edit, Priority, Move to, Send to - workflow, plugin primary actions, Link, Archive, Detach from parent, a - separator, Delete. The system shall omit Priority when the card actions menu +- **AC-TASKS-TASK-ACTIONS-MENU-002.2:** The order and dividers named in + AC-TASKS-TASK-ACTIONS-MENU-002.1 follow + [Task menu grouping](task-menu-grouping.md). + The system shall omit Priority when the card actions menu omits it. It shall omit any of Move to, Send to workflow, Link, and Detach from parent whose availability condition in the card actions menu is unmet, and shall not reorder the entries that remain. @@ -243,7 +243,8 @@ in the same order, with the same labels, wherever the menu is opened. - **AC-TASKS-TASK-ACTIONS-MENU-002.9:** The system shall present no entry on these two surfaces that the card actions menu does not present for a single, unselected task, and shall present no bulk-selection variant of the menu. -- **AC-TASKS-TASK-ACTIONS-MENU-002.10:** The system shall leave the card actions +- **AC-TASKS-TASK-ACTIONS-MENU-002.10:** Except for the ordering and dividers + defined in [Task menu grouping](task-menu-grouping.md), the system shall leave the card actions menu, the card context menu, and the task switcher sidebar menu unchanged in entry membership, labels, top-level order, and behavior, with one named exception. Adopting `sortWorkflowStepsByPosition` inside the shared @@ -274,9 +275,8 @@ actions. It lives in - **The mobile preview surface.** There is none: on mobile the board navigates straight to the task detail route and never renders the preview panel, so there is no surface to add a trigger to. -- **Changing the card actions menu, the card context menu, or the task - switcher sidebar menu.** Their entry sets, ordering, and labels are frozen by - this requirement, not revised by it. +- **Changing card or sidebar action membership and behavior.** + [Task menu grouping](task-menu-grouping.md) owns their revised ordering and dividers. - **Reconciling the card menu's entry set with the task switcher sidebar's divergent one.** The sidebar offers Pin, Rename, Create subtask, Duplicate, Color, and Nest, and omits several the card has. These surfaces standardise on diff --git a/docs/specs/tasks/requirements/task-menu-grouping.md b/docs/specs/tasks/requirements/task-menu-grouping.md new file mode 100644 index 0000000000..ae06f3c4da --- /dev/null +++ b/docs/specs/tasks/requirements/task-menu-grouping.md @@ -0,0 +1,59 @@ +--- +status: draft +system: tasks +created: 2026-09-10 +owners: + - Kandev +--- + +# Task menu grouping requirements + +## Overview + +Task menus group related actions so users can find them as the action list grows. +The task system owns this contract because the groups describe task operations across task surfaces. +This document extends [task actions menus](task-actions-menu.md) to cover ordering across cards and task rows. + +## Requirements + +### REQ-TASKS-MENU-GROUPING-001: Consistent action groups + +#### Acceptance criteria + +- **AC-TASKS-MENU-GROUPING-001.1:** Single-task row menus shall use the following group order and action order: + + | Group | Actions in order | + | --- | --- | + | Mark | Pin or Unpin, Color, Priority | + | Edit | Edit, Rename, Duplicate | + | Relationships | Create subtask, Nest under, Link, Detach from parent | + | Move | Move to, Send to workflow | + | Plugins | Admitted primary plugin actions in registration order | + | Remove | Archive, Delete | + +- **AC-TASKS-MENU-GROUPING-001.2:** Card context and three-dot menus shall use Priority, Edit, Link, Detach from parent, Move to, Send to workflow, primary plugins, Archive, Delete. + Group boundaries shall match the table. Preview and desktop detail menus shall inherit this order with their existing availability exceptions. +- **AC-TASKS-MENU-GROUPING-001.3:** Menus shall show one thin divider between nonempty groups, with no visible group headings. + Menus shall show no leading, trailing, or consecutive dividers. Archive and Delete shall share a group. +- **AC-TASKS-MENU-GROUPING-001.4:** Menus shall preserve existing labels, icons, eligibility, disabled states, callbacks, confirmations, selection semantics, and navigation outcomes. + Duplicate shall remain disabled where it already appears. Archive shall retain neutral styling, and Delete shall retain destructive styling. +- **AC-TASKS-MENU-GROUPING-001.5:** Plugin Edit actions shall remain in the card-only Edit submenu. Plugin Link actions shall remain under Link. + Primary plugin actions shall remain flat, reactive, and ordered by registration, in their separate group before removal actions. +- **AC-TASKS-MENU-GROUPING-001.6:** Bulk row menus shall retain only existing bulk actions, ordered as Pin, movement actions, then Archive and Delete. + Archived and unresolved menus shall retain their existing action sets, with the same grouping and divider rules. +- **AC-TASKS-MENU-GROUPING-001.7:** Touch users shall reach the same applicable groups through existing visible task-row and card action controls. + Phone menus shall scroll internally, respect safe areas, and keep nested choices and the last action reachable without document horizontal overflow. + Touch action rows shall retain targets of at least 44px. Desktop menus shall retain their existing density. +- **AC-TASKS-MENU-GROUPING-001.8:** Keyboard users shall reach actions in visual order. Escape shall dismiss the menu and restore the existing opener focus behavior. + Menu interaction shall not select, navigate to, or drag the underlying task as a side effect. + +## Out of scope + +- Adding Pin, Color, Rename, Duplicate, or nesting capabilities to cards. +- New action handlers, backend contracts, persistence, or localization keys. +- New plugin registration groups, classification by label, or wider plugin eligibility. +- Replacing the mobile menu shell or adding a mobile detail-header trigger. + +## Implementation Plans + +- [Task menu grouping](../../../plans/task-menu-grouping/plan.md) diff --git a/docs/specs/tasks/system-design/task-actions-menu.md b/docs/specs/tasks/system-design/task-actions-menu.md index 1df970b363..797b3001c6 100644 --- a/docs/specs/tasks/system-design/task-actions-menu.md +++ b/docs/specs/tasks/system-design/task-actions-menu.md @@ -118,9 +118,9 @@ 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 is Edit, Priority, Move to, Send -to workflow, plugin primary actions, Link, Archive, Detach from parent, a -separator, and Delete. The Priority submenu uses the row's current priority and +For a normal resolved row, the top-level order and dividers follow +[Task menu grouping](task-menu-grouping.md). +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. diff --git a/docs/specs/tasks/system-design/task-menu-grouping.md b/docs/specs/tasks/system-design/task-menu-grouping.md new file mode 100644 index 0000000000..4547ead018 --- /dev/null +++ b/docs/specs/tasks/system-design/task-menu-grouping.md @@ -0,0 +1,85 @@ +--- +status: draft +system: tasks +requirements: + - REQ-TASKS-MENU-GROUPING-001 +--- + +# Task menu grouping system design + +## Purpose and boundaries + +This change reorders existing task operations and centralizes divider placement within each menu composition. +The [task actions menu design](task-actions-menu.md) continues to own subject resolution, requests, confirmations, and navigation. +This design owns its new ordering. No API, storage, permission, or state subscription changes are necessary. + +## Requirement mapping + +| Criteria | Design section | +| --- | --- | +| AC-TASKS-MENU-GROUPING-001.1 through .3 | Menu composition | +| AC-TASKS-MENU-GROUPING-001.4 through .6 | Conditional entries and plugins | +| AC-TASKS-MENU-GROUPING-001.7 and .8 | Responsive interaction | + +## Menu composition + +`buildKanbanCardMenuEntries` in `apps/web/components/kanban-card-menu-items.tsx` +already supplies both card renderers. It will assemble arrays for mark, edit, +relationships, move, primary plugins, and removal groups. +The composition filters empty arrays before it inserts separators. +Existing entry keys, submenu children, and callbacks remain stable. + +`buildTaskActionsMenuEntries` in `apps/web/lib/kanban/task-actions-menu-entries.ts` +inherits the card order for normal rows. Its archived and unresolved branches +will use the same divider rule. An unresolved row has a plugin group, if visible, +then Archive and Delete together. An archived row has plugins, if visible, then Delete. + +`SingleSelectionMenuItems` and `BulkSelectionMenuItems` in +`apps/web/components/task/task-switcher-context-menu.tsx` compose task-row actions. +Their group boundaries must use actual entry eligibility, including children that currently return null. +Counting JSX child elements cannot establish whether a group is empty. +Keep eligibility beside its existing action owner and expose visible entries or explicit visibility to the composition. +Use small focused helpers as necessary. Do not build a general menu registry. + +Inspect separators owned by `task-switcher-action-items.tsx` and +`task-move-context-menu.tsx` before moving their items. +Remove or make those separators optional for grouped callers, while preserving other callers. +The parent composition owns every top-level divider. + +## Conditional entries and plugins + +The change preserves existing distinctions between cards, rows, bulk selection, +archived tasks, and unresolved board rows. In particular, it does not add card capabilities. +Subtask detachment moves into relationships without changing its eligibility. + +`buildPrimaryPluginEntries` and `TaskPluginPrimaryMenuItems` retain registry order, +visibility evaluation, immutable task context, and current failure handling. +The primary group moves after movement and before removal. +Edit and Link plugin actions retain their current nesting and surface restrictions. +No label-based classification or plugin SDK schema change is necessary. + +Existing location descriptions in `docs/plans/plugins/PLUGIN-API.md`, SDK comments, +host types, and `components/plugins/task-menu-actions.ts` need synchronization during implementation. +Only placement descriptions change. Existing plugin registration and callback types remain intact. + +## Responsive interaction + +The nearest shipped examples are the card dropdown and the task-row menu inside +`components/task/mobile/session-task-switcher-sheet.tsx`. +The shared Radix menu treatment in `app/globals.css` supplies the inset bottom sheet below 640px. +This short-lived action choice retains that shell and its internal scroll owner. +At wider widths, existing anchored menus and pointer behavior remain intact. + +Both presentations share action eligibility and handlers. +Phone users open the visible ellipsis, tap a submenu, and select its action. +The existing sheet uses dynamic viewport limits and safe-area spacing. +The implementation must preserve that geometry when additional dividers increase menu height. +No new fixed footer, drawer stack, or separate mobile action list is necessary. + +Task-row event propagation guards and touch-drag cancellation remain in place. +Escape, focus restoration, confirmation anchors, and close-before-dialog sequencing retain their existing owners. +Long-menu tests cover internal scrolling, submenu containment, touch targets, and last-row reachability. + +## Implementation Plans + +- [Task menu grouping](../../../plans/task-menu-grouping/plan.md)