feat(ui): group task and card menu actions (#3599)

This commit is contained in:
Carlos Florêncio
2026-09-11 15:51:57 +01:00
committed by GitHub
parent 86b96fd14e
commit a4077cf9cf
31 changed files with 2092 additions and 673 deletions
@@ -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");
});
});
@@ -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: [],
+60 -93
View File
@@ -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<string, WorkflowSnapshotData["steps"]> => {
const result: Record<string, WorkflowSnapshotData["steps"]> = {};
for (const [workflowId, snapshot] of Object.entries(state.kanbanMulti.snapshots)) {
result[workflowId] = snapshot.steps;
}
return result;
}),
);
const workflowItems = useMemo<TaskMoveWorkflow[]>(() => {
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<Record<string, TaskMoveStep[]>>(() => {
const result: Record<string, TaskMoveStep[]> = {};
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 <ContextMenuSeparator />;
if (entry.kind === "submenu") {
@@ -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,
@@ -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 (
<>
<ContextMenuSeparator />
{showSeparator ? <ContextMenuSeparator /> : null}
<ContextMenuItem
variant="destructive"
disabled={isDeleting}
@@ -0,0 +1,63 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
import { StateProvider } from "@/components/state-provider";
import { ToastProvider } from "@/components/toast-provider";
import { TaskItemWithContextMenu } from "./task-switcher-context-menu";
import type { TaskSwitcherItem } from "./task-switcher-types";
afterEach(cleanup);
function task(overrides: Partial<TaskSwitcherItem> = {}): TaskSwitcherItem {
return { id: "task-1", title: "Task 1", state: "IN_PROGRESS", ...overrides };
}
function ArchiveAwareRow() {
return <div data-testid="task-row">Task 1</div>;
}
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(
<StateProvider>
<ToastProvider>
<TaskItemWithContextMenu
task={task()}
selectedTaskIds={new Set(["task-1"])}
onBulkArchive={onBulkArchive}
>
<ArchiveAwareRow />
</TaskItemWithContextMenu>
</ToastProvider>
</StateProvider>,
);
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(
<StateProvider>
<ToastProvider>
<TaskItemWithContextMenu
task={task()}
selectedTaskIds={new Set(["task-2"])}
onBulkArchive={vi.fn()}
>
<ArchiveAwareRow />
</TaskItemWithContextMenu>
</ToastProvider>
</StateProvider>,
);
fireEvent.contextMenu(screen.getByTestId("task-row"));
const menu = await screen.findByRole("menu");
expect(within(menu).queryByRole("menuitem", { name: /archive/i })).toBeNull();
});
});
@@ -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<typeof selectTaskLinkActions>;
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) => (
<Fragment key={group.key}>
{index > 0 && <ContextMenuSeparator />}
{group.content}
</Fragment>
))}
</>
);
}
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 <SingleSelectionMenuGroups {...props} {...useSingleMenuState(props)} />;
}
function SingleSelectionMenuGroups(props: SingleSelectionMenuProps & SingleMenuState) {
const { task } = props;
return (
<TaskContextMenuGroups
groups={[
{
key: "mark",
visible: Boolean(props.onTogglePin || !task.isArchived),
content: <SingleMarkGroup {...props} />,
},
{
key: "edit",
visible: Boolean(!task.isArchived || props.onRenameTask),
content: <SingleEditGroup {...props} />,
},
{
key: "relationships",
visible: props.hasRelationshipsGroup,
content: <SingleRelationshipsGroup {...props} onDetachTask={props.onDetach} />,
},
{
key: "move",
visible: props.hasMoveGroup,
content: <TaskMoveItems {...props} />,
},
{
key: "plugins",
visible: props.pluginPrimaryEntries.length > 0,
content: <KanbanCardContextMenuItems entries={props.pluginPrimaryEntries} />,
},
{
key: "remove",
visible: hasSingleRemoveGroup(props),
content: <SingleRemoveGroup {...props} onDeleteTask={props.onDelete} />,
},
]}
/>
);
}
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 (
<>
<TaskPinItem
taskId={task.id}
isPinned={isPinned}
disabled={isDeleting}
onTogglePin={withSelectionClear(actingOnSelection, onClearSelection, onTogglePin)}
/>
{!task.isArchived && (
<TaskColorMenu
taskId={task.id}
disabled={isDeleting}
automaticColorSource={task.automaticColorSource}
/>
)}
{!task.isArchived && (
<TaskPriorityContextMenu
currentPriority={task.priority}
disabled={isDeleting}
onSelect={(priority) => void updateTaskPriority(task.id, priority)}
/>
)}
</>
);
}
function SingleEditGroup({
task,
isDeleting,
onEditTask,
onRenameTask,
}: Pick<SingleGroupProps, "task" | "isDeleting" | "onEditTask" | "onRenameTask">) {
const { t } = useTranslation();
return (
<>
<TaskEditItem task={task} disabled={isDeleting} onEditTask={onEditTask} />
<TaskRenameItem task={task} disabled={isDeleting} onRenameTask={onRenameTask} />
{!task.isArchived && (
<ContextMenuItem disabled>
<IconCopy className="mr-2 h-4 w-4" />
{t("settings:duplicate")}
</ContextMenuItem>
)}
</>
);
}
function SingleRelationshipsGroup({
task,
isDeleting,
onCreateSubtask,
onDetachTask,
closeMenu,
linkActions,
pluginLinkActions,
hasLinkGroup,
}: Pick<
SingleGroupProps,
| "task"
| "isDeleting"
| "onCreateSubtask"
| "onDetachTask"
| "closeMenu"
| "linkActions"
| "pluginLinkActions"
| "hasLinkGroup"
>) {
return (
<>
<TaskCreateSubtaskItem task={task} disabled={isDeleting} onCreateSubtask={onCreateSubtask} />
{!task.isArchived && <TaskNestContextMenuItems task={task} disabled={isDeleting} />}
{hasLinkGroup && linkActions && pluginLinkActions && (
<TaskPluginLinkMenu
disabled={isDeleting}
closeMenu={closeMenu}
linkActions={linkActions}
pluginLinkActions={pluginLinkActions}
/>
)}
<TaskDetachItem task={task} disabled={isDeleting} onDetachTask={onDetachTask} />
</>
);
}
function SingleRemoveGroup({
task,
isDeleting,
isArchiving,
onArchiveTask,
onDeleteTask,
actingIds,
actingOnSelection,
onBulkArchive,
}: Pick<
SingleGroupProps,
| "task"
| "isDeleting"
| "isArchiving"
| "onArchiveTask"
| "onDeleteTask"
| "actingIds"
| "actingOnSelection"
| "onBulkArchive"
>) {
return (
<>
<TaskArchiveItem
taskId={task.id}
actingIds={actingIds}
actingOnSelection={actingOnSelection}
disabled={isDeleting || isArchiving}
onArchiveTask={onArchiveTask}
onBulkArchive={onBulkArchive}
/>
<TaskDeleteItem
taskId={task.id}
isDeleting={isDeleting}
onDeleteTask={onDeleteTask}
showSeparator={false}
/>
</>
);
}
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<string, StepDef[]>;
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<typeof useTaskWorkflowMove>;
};
function BulkSelectionMenuItems(props: BulkSelectionMenuProps) {
const { task, workflows, stepsByWorkflowId, steps, isMixedWorkflowSelection } = props;
const hasMoveGroup = hasBulkMoveGroup({
task,
workflows,
stepsByWorkflowId,
steps,
isMixedWorkflowSelection,
});
return (
<TaskContextMenuGroups
groups={[
{
key: "mark",
visible: Boolean(props.onBulkPin),
content: <BulkPinMenuItem {...props} />,
},
{
key: "move",
visible: hasMoveGroup,
content: <BulkMoveGroup {...props} />,
},
{
key: "remove",
visible: Boolean(props.onBulkArchive || props.onBulkDelete),
content: <BulkRemoveGroup {...props} />,
},
]}
/>
);
}
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<BulkSelectionMenuProps, "actingIds" | "pinnedTaskIds" | "onBulkPin">) {
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 (
<ContextMenuItem onSelect={() => onBulkPin(actingIds)}>
{allPinned ? (
<IconPinFilled className="mr-2 h-4 w-4" />
) : (
<IconPin className="mr-2 h-4 w-4" />
)}
{label}
</ContextMenuItem>
);
}
function BulkMoveGroup(props: BulkSelectionMenuProps) {
return (
<TaskMoveItems
task={props.task}
workflows={props.workflows}
stepsByWorkflowId={props.stepsByWorkflowId}
steps={props.steps}
onMoveToStep={undefined}
actingIds={props.actingIds}
actingOnSelection
onBulkMove={props.onBulkMove}
isMixedWorkflowSelection={props.isMixedWorkflowSelection}
closeMenu={props.closeMenu}
moveTasks={props.moveTasks}
/>
);
}
function BulkRemoveGroup({
task,
actingIds,
onBulkArchive,
onBulkDelete,
}: Pick<BulkSelectionMenuProps, "task" | "actingIds" | "onBulkArchive" | "onBulkDelete">) {
const { t } = useTranslation();
return (
<>
<TaskArchiveItem
taskId={task.id}
actingIds={actingIds}
actingOnSelection
onArchiveTask={undefined}
onBulkArchive={onBulkArchive}
/>
{onBulkDelete && (
<ContextMenuItem variant="destructive" onSelect={() => onBulkDelete(actingIds)}>
<IconTrash className="mr-2 h-4 w-4" />
{t("task:deleteTasksCount", { count: actingIds.length })}
</ContextMenuItem>
)}
</>
);
}
function TaskPinItem({
taskId,
isPinned,
disabled,
onTogglePin,
}: {
taskId: string;
isPinned?: boolean;
disabled?: boolean;
onTogglePin?: (taskId: string) => void;
}) {
const { t } = useTranslation();
if (!onTogglePin) return null;
return (
<ContextMenuItem disabled={disabled} onSelect={() => onTogglePin(taskId)}>
{isPinned ? <IconPinFilled className="mr-2 h-4 w-4" /> : <IconPin className="mr-2 h-4 w-4" />}
{isPinned ? t("task:unpin") : t("task:annotationPin")}
</ContextMenuItem>
);
}
function TaskRenameItem({
task,
disabled,
onRenameTask,
}: {
task: TaskSwitcherItem;
disabled?: boolean;
onRenameTask?: (taskId: string, currentTitle: string) => void;
}) {
const { t } = useTranslation();
if (!onRenameTask) return null;
return (
<ContextMenuItem disabled={disabled} onSelect={() => onRenameTask(task.id, task.title)}>
<IconPencil className="mr-2 h-4 w-4" />
{t("task:rename")}
</ContextMenuItem>
);
}
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 (
<ContextMenuItem disabled={disabled} onSelect={() => onEditTask(task)}>
<IconEdit className="mr-2 h-4 w-4" />
{t("common:edit")}
</ContextMenuItem>
);
}
export { BulkSelectionMenuItems, SingleSelectionMenuItems };
@@ -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<TaskContextMenuItemsProps, "onRenameTask" | "onArchiveTask" | "onDeleteTask"> & {
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 (
<TaskMoveContextMenuItems
currentWorkflowId={workflowId}
currentStepId={actingOnSelection ? undefined : task.workflowStepId}
workflows={workflows ?? []}
stepsByWorkflowId={stepsByWorkflowId ?? (steps ? { [workflowId]: steps } : {})}
disabled={isDeleting || task.isArchived}
showSeparator={false}
onMoveToStep={moveToStep}
onMoveToStepWithOptions={
actingIds.length === 1 && onMoveToStepWithOptions ? onMoveToStepWithOptions : undefined
}
onSubmitWithOptions={
actingIds.length === 1 && onSubmitWithOptions ? onSubmitWithOptions : undefined
}
isMoving={moveOptionsBusy}
onSendToWorkflow={(targetWorkflowId, stepId) => {
if (actingOnSelection) {
runSelectionMove(targetWorkflowId, stepId, "workflow");
return;
}
closeMenu();
void moveTasks([task.id], targetWorkflowId, stepId, "workflow").catch(() => {
// useTaskWorkflowMove already shows the failure toast.
});
}}
/>
);
}
@@ -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(
<StateProvider initialState={{ workspaces: { items: [], activeId: "workspace-1" } }}>
<ToastProvider>
<TaskItemWithContextMenu task={task({ workflowStepId: "step-1" })}>
<TaskItemWithContextMenu task={task({ workflowStepId: STEP_ID })}>
<div data-testid="plugin-task-row">Task 1</div>
</TaskItemWithContextMenu>
</ToastProvider>
@@ -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(
<StateProvider initialState={{ workspaces: { items: [], activeId: "workspace-1" } }}>
<ToastProvider>
<TaskItemWithContextMenu
task={task({
workflowId: WORKFLOW_ID,
workflowStepId: STEP_ID,
parentTaskId: "parent-1",
})}
workflows={[
{ id: WORKFLOW_ID, name: "Workflow 1" },
{ id: "workflow-2", name: "Workflow 2" },
]}
stepsByWorkflowId={{
[WORKFLOW_ID]: [
{ id: STEP_ID, title: "Step 1" },
{ id: "step-2", title: "Step 2" },
],
}}
onTogglePin={vi.fn()}
onEditTask={vi.fn()}
onRenameTask={vi.fn()}
onArchiveTask={vi.fn()}
onCreateSubtask={vi.fn()}
onDeleteTask={vi.fn()}
onDetachTask={vi.fn()}
onMoveToStep={vi.fn()}
onLinkPullRequest={vi.fn()}
>
<ArchiveAwareRow />
</TaskItemWithContextMenu>
</ToastProvider>
</StateProvider>,
);
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(
<StateProvider>
<ToastProvider>
<TaskItemWithContextMenu
task={task({ workflowId: WORKFLOW_ID, workflowStepId: STEP_ID })}
selectedTaskIds={new Set(["task-1", "task-2"])}
workflows={[
{ id: WORKFLOW_ID, name: "Workflow 1" },
{ id: "workflow-2", name: "Workflow 2" },
]}
stepsByWorkflowId={{
[WORKFLOW_ID]: [
{ id: STEP_ID, title: "Step 1" },
{ id: "step-2", title: "Step 2" },
],
}}
onBulkPin={vi.fn()}
onBulkArchive={vi.fn()}
onBulkDelete={vi.fn()}
onBulkMove={vi.fn()}
>
<ArchiveAwareRow />
</TaskItemWithContextMenu>
</ToastProvider>
</StateProvider>,
);
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();
@@ -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 <BulkSelectionMenuItems {...props} actingIds={actingIds} />;
}
@@ -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 (
<>
<TaskPinItem
taskId={task.id}
isPinned={isPinned}
disabled={isDeleting}
onTogglePin={withSelectionClear(actingOnSelection, onClearSelection, onTogglePin)}
/>
<TaskEditItem task={task} disabled={isDeleting} onEditTask={onEditTask} />
{!task.isArchived && (
<TaskPriorityContextMenu
currentPriority={task.priority}
disabled={isDeleting}
onSelect={(priority) => void updateTaskPriority(task.id, priority)}
/>
)}
<TaskRenameItem task={task} disabled={isDeleting} onRenameTask={onRenameTask} />
<TaskCreateSubtaskItem task={task} disabled={isDeleting} onCreateSubtask={onCreateSubtask} />
{!task.isArchived && (
<ContextMenuItem disabled>
<IconCopy className="mr-2 h-4 w-4" />
{t("settings:duplicate")}
</ContextMenuItem>
)}
</>
);
}
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 (
<>
<TaskHeaderMenuItems
task={task}
isDeleting={isDeleting}
isPinned={isPinned}
actingOnSelection={actingOnSelection}
onClearSelection={onClearSelection}
onTogglePin={onTogglePin}
onEditTask={onEditTask}
onRenameTask={onRenameTask}
onCreateSubtask={onCreateSubtask}
/>
<TaskArchiveItem
taskId={task.id}
actingIds={actingIds}
actingOnSelection={actingOnSelection}
disabled={isDeleting || isArchiving}
onArchiveTask={onArchiveTask}
onBulkArchive={onBulkArchive}
/>
{!task.isArchived && (
<TaskColorMenu
taskId={task.id}
disabled={isDeleting}
automaticColorSource={task.automaticColorSource}
/>
)}
<TaskNestContextMenuItems task={task} disabled={isDeleting} />
<TaskPluginPrimaryMenuItems task={task} disabled={isDeleting} />
<TaskPluginLinkMenu
task={task}
disabled={isDeleting}
closeMenu={closeMenu}
linkActions={selectTaskLinkActions(task, closeMenu, linkHandlers)}
/>
{!task.isArchived && (
<TaskMoveItems
task={task}
workflows={workflows}
stepsByWorkflowId={stepsByWorkflowId}
steps={steps}
isDeleting={isDeleting}
onMoveToStep={onMoveToStep}
onMoveToStepWithOptions={onMoveToStepWithOptions}
onSubmitWithOptions={onSubmitWithOptions}
moveOptionsBusy={moveOptionsBusy}
actingIds={actingIds}
actingOnSelection={actingOnSelection}
onBulkMove={onBulkMove}
isMixedWorkflowSelection={isMixedWorkflowSelection}
closeMenu={closeMenu}
moveTasks={moveTasks}
/>
)}
<TaskDetachItem task={task} disabled={isDeleting} onDetachTask={onDetach} />
<TaskDeleteItem taskId={task.id} isDeleting={isDeleting} onDeleteTask={onDelete} />
</>
);
}
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<string, StepDef[]>;
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<typeof useTaskWorkflowMove>;
}) {
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 && (
<ContextMenuItem onSelect={() => onBulkPin(actingIds)}>
{allPinned ? (
<IconPinFilled className="mr-2 h-4 w-4" />
) : (
<IconPin className="mr-2 h-4 w-4" />
)}
{pinLabel}
</ContextMenuItem>
)}
<TaskArchiveItem
taskId={task.id}
actingIds={actingIds}
actingOnSelection
onArchiveTask={undefined}
onBulkArchive={onBulkArchive}
/>
<TaskMoveItems
task={task}
workflows={workflows}
stepsByWorkflowId={stepsByWorkflowId}
steps={steps}
onMoveToStep={undefined}
actingIds={actingIds}
actingOnSelection
onBulkMove={onBulkMove}
isMixedWorkflowSelection={isMixedWorkflowSelection}
closeMenu={closeMenu}
moveTasks={moveTasks}
/>
{onBulkDelete && (
<>
<ContextMenuSeparator />
<ContextMenuItem variant="destructive" onSelect={() => onBulkDelete(actingIds)}>
<IconTrash className="mr-2 h-4 w-4" />
{t("task:deleteTasksCount", { count: n })}
</ContextMenuItem>
</>
)}
</>
);
}
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 (
<ContextMenuItem disabled={disabled} onSelect={() => onTogglePin(taskId)}>
{isPinned ? <IconPinFilled className="mr-2 h-4 w-4" /> : <IconPin className="mr-2 h-4 w-4" />}
{isPinned ? t("task:unpin") : t("task:annotationPin")}
</ContextMenuItem>
);
}
function TaskRenameItem({
task,
disabled,
onRenameTask,
}: {
task: TaskSwitcherItem;
disabled?: boolean;
onRenameTask?: (taskId: string, currentTitle: string) => void;
}) {
const { t } = useTranslation();
if (!onRenameTask) return null;
return (
<ContextMenuItem disabled={disabled} onSelect={() => onRenameTask(task.id, task.title)}>
<IconPencil className="mr-2 h-4 w-4" />
{t("task:rename")}
</ContextMenuItem>
);
}
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 (
<ContextMenuItem disabled={disabled} onSelect={() => onEditTask(task)}>
<IconEdit className="mr-2 h-4 w-4" />
{t("common:edit")}
</ContextMenuItem>
);
}
function TaskMoveItems({
task,
workflows,
stepsByWorkflowId,
steps,
isDeleting,
onMoveToStep,
onMoveToStepWithOptions,
onSubmitWithOptions,
moveOptionsBusy,
actingIds,
actingOnSelection,
onBulkMove,
isMixedWorkflowSelection,
closeMenu,
moveTasks,
}: Omit<TaskContextMenuItemsProps, "onRenameTask" | "onArchiveTask" | "onDeleteTask"> & {
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 (
<TaskMoveContextMenuItems
currentWorkflowId={workflowId}
// For a selection spanning several steps, don't disable the clicked row's
// step — the backend bulk move skips tasks already there, and the other
// selected rows still need it as a target.
currentStepId={actingOnSelection ? undefined : task.workflowStepId}
workflows={workflows ?? []}
stepsByWorkflowId={stepsByWorkflowId ?? (steps ? { [workflowId]: steps } : {})}
disabled={isDeleting || task.isArchived}
onMoveToStep={moveToStep}
onMoveToStepWithOptions={
actingIds.length === 1 && onMoveToStepWithOptions ? onMoveToStepWithOptions : undefined
}
onSubmitWithOptions={
actingIds.length === 1 && onSubmitWithOptions ? onSubmitWithOptions : undefined
}
isMoving={moveOptionsBusy}
onSendToWorkflow={(targetWorkflowId, stepId) => {
if (actingOnSelection) {
runSelectionMove(targetWorkflowId, stepId, "workflow");
return;
}
closeMenu();
void moveTasks([task.id], targetWorkflowId, stepId, "workflow").catch(() => {
// useTaskWorkflowMove already shows the failure toast.
});
}}
/>
);
}
@@ -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<ReturnType<typeof selectTaskLinkActions>>;
pluginLinkActions?: PluginLinkMenuAction[];
}) {
if (suppliedPluginLinkActions) {
return (
<TaskLinkMenuWithActions
disabled={disabled}
closeMenu={closeMenu}
linkActions={linkActions}
pluginLinkActions={suppliedPluginLinkActions}
/>
);
}
if (!task) return null;
return (
<TaskLinkMenuWithTask
task={task}
disabled={disabled}
closeMenu={closeMenu}
linkActions={linkActions}
/>
);
}
function TaskLinkMenuWithTask({
task,
disabled,
closeMenu,
linkActions,
}: {
task: TaskSwitcherItem;
disabled?: boolean;
@@ -106,6 +140,27 @@ export function TaskPluginLinkMenu({
linkActions: Partial<ReturnType<typeof selectTaskLinkActions>>;
}) {
const pluginLinkActions = useTaskPluginLinkActions(task.id, task.repositoryLinks ?? []);
return (
<TaskLinkMenuWithActions
disabled={disabled}
closeMenu={closeMenu}
linkActions={linkActions}
pluginLinkActions={pluginLinkActions}
/>
);
}
function TaskLinkMenuWithActions({
disabled,
closeMenu,
linkActions,
pluginLinkActions,
}: {
disabled?: boolean;
closeMenu: () => void;
linkActions: Partial<ReturnType<typeof selectTaskLinkActions>>;
pluginLinkActions: PluginLinkMenuAction[];
}) {
return (
<TaskLinkMenu
disabled={disabled}
@@ -138,7 +193,7 @@ function TaskLinkMenu({
onLinkJiraTicket?: () => void;
onLinkLinearIssue?: () => void;
onLinkSentryIssue?: () => void;
pluginLinkActions?: { id: string; label: string; icon?: PluginIcon; onSelect: () => void }[];
pluginLinkActions?: PluginLinkMenuAction[];
}) {
const { t } = useTranslation();
if (
@@ -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 <KanbanCardContextMenuItems entries={entries} />;
}
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 <KanbanCardContextMenuItems entries={buildPrimaryPluginEntries({ disabled, context })} />;
return buildPrimaryPluginEntries({ disabled, context });
}
@@ -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();
@@ -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();
@@ -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" });
@@ -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();
@@ -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",
@@ -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();
});
});
@@ -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<string, TaskMoveStep[]>;
};
/**
* 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<string, WorkflowSnapshotData["steps"]> => {
const result: Record<string, WorkflowSnapshotData["steps"]> = {};
for (const [workflowId, snapshot] of Object.entries(state.kanbanMulti.snapshots)) {
result[workflowId] = snapshot.steps;
}
return result;
}),
);
const workflowItems = useMemo<TaskMoveWorkflow[]>(() => {
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<Record<string, TaskMoveStep[]>>(() => {
const result: Record<string, TaskMoveStep[]> = {};
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 };
}
@@ -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",
]);
});
@@ -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 }),
],
},
]);
}
+2 -2
View File
@@ -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;
+9 -8
View File
@@ -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<void>; // 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
+220
View File
@@ -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.
@@ -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.
@@ -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.
+2
View File
@@ -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)
@@ -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
@@ -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)
@@ -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.
@@ -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)