FN-051: disable native task dragging

Remove native task drag-and-drop while preserving board panning and task actions.

- Remove draggable task movement and drop handling from board, columns, lanes, lists, and cards.
- Add task context-menu movement actions and update related documentation and release metadata.
- Update component tests to cover non-draggable cards and board-pan exclusions.

Files changed:
 .changeset/fn-051-task-card-moves.md               |   7 +
 docs/dashboard-guide.md                            |   5 +-
 docs/task-management.md                            |   6 +
 packages/dashboard/app/components/Board.tsx        |  63 +-----
 packages/dashboard/app/components/Column.tsx       | 140 +-------------
 packages/dashboard/app/components/DockTaskList.tsx |   3 +-
 packages/dashboard/app/components/Lane.tsx         |   9 -
 packages/dashboard/app/components/ListView.css     |  12 +-
 packages/dashboard/app/components/ListView.tsx     | 132 ++-----------
 packages/dashboard/app/components/TaskCard.css     |   8 -
 packages/dashboard/app/components/TaskCard.tsx     |  58 ++----
 .../dashboard/app/components/TaskContextMenu.css   |  37 ++++
 .../dashboard/app/components/TaskContextMenu.tsx   | 145 ++++++++++++--
 .../__tests__/Board.canDropTask.test.tsx           | 212 ---------------------
 .../app/components/__tests__/Board.test.tsx        |   8 +-
 .../app/components/__tests__/Column.test.tsx       | 101 +---------
 .../app/components/__tests__/DockTaskList.test.tsx |   5 +-
 .../app/components/__tests__/Lane.test.tsx         |  26 ---
 .../app/components/__tests__/ListView.test.tsx     | 178 +----------------
 .../app/components/__tests__/TaskCard.test.tsx     |  60 ++----
 .../components/__tests__/TaskContextMenu.test.tsx  |  55 +++++-
 .../dashboard/app/components/boardCanDropTask.ts   |  56 -----
 .../app/components/dashboard/MainContent.tsx       |   1 -
 .../app/components/useRightDockController.tsx      |   1 -
 packages/dashboard/app/styles.css                  |  14 --
 25 files changed, 297 insertions(+), 1045 deletions(-)

Fusion-Task-Id: FN-051

Fusion-Task-Lineage: b5614cb1-75f3-4307-a595-123f903adc99

Co-authored-by: Fusion <noreply@runfusion.ai>
This commit is contained in:
Fusion Agent
2026-08-19 19:08:44 +00:00
parent a736843cd2
commit 3fd4bdbc69
25 changed files with 297 additions and 1045 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Move Board and List tasks from their contextual Move to menu.
category: fix
dev: Native task drag-and-drop is removed; multiple legal destinations are grouped in one accessible submenu.

View File

@@ -324,9 +324,10 @@ Board view is the kanban surface for day-to-day operation.
Features:
- Drag-and-drop between lifecycle columns
<!-- FNXC:TaskCardMovement 2026-08-19-18:52: Board and List task relocation is intentionally menu-only, so native card dragging cannot bypass the same explicit Move to choice on desktop, keyboard, and touch surfaces. -->
- Move a task from its context menu: right-click on desktop, use the overflow button or keyboard Context Menu/Shift+F10, or long-press on touch. When several legal destinations exist, open the grouped **Move to** submenu; a single destination remains a direct action. This changes task movement only—subtasks still use drag-to-reorder.
<!-- FNXC:BoardNavigation 2026-08-18-18:18: Desktop operators can traverse the live workflow columns by dragging a safe Board surface horizontally; card drag-and-drop, card/control clicks, and mobile touch paging remain separate interactions. -->
- On desktop, click-drag an empty or otherwise safe Board surface to pan horizontally: drag right to reveal earlier columns and drag left to reveal later columns. Task cards and their controls remain reserved for native drag-and-drop, clicks, and context menus; phones continue to use native touch scrolling and column snapping.
- On desktop, click-drag an empty or otherwise safe Board surface to pan horizontally: drag right to reveal earlier columns and drag left to reveal later columns. Task cards and their controls remain reserved for clicks and context menus; phones continue to use native touch scrolling and column snapping.
- Search/filter tasks (including working-branch and base-branch dropdown filters with explicit **No working branch** / **No base branch** options)
- Working-branch and base-branch filter selections are persisted per project and restored across refresh/navigation
- Column visibility controls

View File

@@ -377,6 +377,12 @@ Board ordering behavior:
- The `done` column is recency-ordered by completion time (newest first), using `columnMovedAt` as primary and falling back to `updatedAt` then `createdAt` for legacy tasks.
- The dashboard **list view default ordering matches these same per-column semantics** until a user clicks a sortable header (manual list sorting still overrides defaults).
<!-- FNXC:TaskCardMovement 2026-08-19-18:35: Task movement is contextual so Board and List retain one accessible, validated transition path without native task drag-and-drop. -->
### Moving tasks on Board and List
Use a task card or List row's context menu (**right-click**, **Shift+F10** / Context Menu key, the visible overflow control, or touch long-press), then choose **Move to**. When more than one legal destination is available, **Move to** opens one submenu containing each destination; a single destination remains a direct action. This changes only task movement: subtask drag-to-reorder remains available in the Subtask Breakdown dialog.
### Lifecycle commands
```bash

View File

@@ -14,7 +14,6 @@ import { useColumnScrollSnap } from "../hooks/useColumnScrollSnap";
import { useBoardMousePan } from "../hooks/useBoardMousePan";
import { MOBILE_MEDIA_QUERY, useViewportMode } from "../hooks/useViewportMode";
import { recordResumeEvent } from "../utils/resumeInstrumentation";
import { getBoardCanDropTaskRejection } from "./boardCanDropTask";
import { WorkflowSwitcher } from "./WorkflowSwitcher";
import { computeWorkflowStatusCounts } from "./workflowStatusCounts";
import { writeBoardWorkflowsCache } from "../utils/boardWorkflowsCache";
@@ -412,7 +411,6 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
refreshBoardWorkflows,
setBoardWorkflowsState,
} = useBoardWorkflows({ projectId });
const draggingTaskIdRef = useRef<string | null>(null);
/*
FNXC:WorkflowResolvedColumns 2026-07-30-23:15 (the board's fan-out read the LEGACY lanes):
@@ -449,7 +447,6 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
}
}, [onToggleAutoMerge]);
const getDraggingTaskId = useCallback(() => draggingTaskIdRef.current, []);
const workflowStatusCounts = useMemo(() => {
/*
@@ -888,52 +885,6 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
return grouped;
}, [aggregateBoardColumns, aggregateQuickCreateTarget, boardWorkflows, getColumnSortMode, getEffectiveTaskWorkflowId, tasks, workflowColumnsByWorkflowId]);
// Drag pre-check (R17): adjacency + capacity from the lane's column metadata.
// Cross-lane drag → workflow-mismatch. Deterministic rejections return a
// messageKey (no-move); null = allowed.
const canDropTask = useCallback((taskId: string, targetColumnId: string, laneWorkflowId: string): string | null => (
getBoardCanDropTaskRejection({
boardWorkflows,
tasks,
maxConcurrent,
taskId,
targetColumnId,
laneWorkflowId,
})
), [boardWorkflows, tasks, maxConcurrent]);
/*
FNXC:WorkflowBoard 2026-07-29-00:00 (U12 — measured perf fix):
Bind `canDropTask` per (lane, column) ONCE per dependency change instead of creating
a new arrow inline in the render. `Column` is `React.memo`, and a fresh function
identity on any prop defeats that entirely — so before this, ANY Board state change
(collapsing the archived column, changing Done sort, opening the switcher)
re-rendered EVERY column and every card beneath it, not just the affected one.
This was invisible for a long time: the "keeps unaffected columns stable" regression
test measured the LEGACY single-lane board, whose props were all stable. Deleting
that board (U12 part 1) repointed the test at the real board, where it failed. I
instrumented `React.memo`'s comparator to list which props actually change identity
on a collapse toggle, and the answer was exactly one: `canDropTask`.
A `useRef` cache invalidated by `useEffect` does NOT work here, which is why my first
attempt at this failed and was reverted: the effect runs AFTER the render that
populated the cache, so it wipes the very bindings that render created and the next
render allocates fresh ones. `useMemo` keyed on the resolver has no such window —
the map lives exactly as long as the closure it belongs to.
*/
const canDropTaskBinder = useMemo(() => {
const bindings = new Map<string, (taskId: string) => string | null>();
return (columnId: string, laneWorkflowId: string) => {
const key = `${laneWorkflowId}::${columnId}`;
let bound = bindings.get(key);
if (!bound) {
bound = (taskId: string) => canDropTask(taskId, columnId, laneWorkflowId);
bindings.set(key, bound);
}
return bound;
};
}, [canDropTask]);
// FN-4380: GitHub badge state comes from persisted task fields (`task.prInfo`,
// `task.issueInfo`, `task.githubTracking.issue`) and live WebSocket `badge:updated`
@@ -1076,7 +1027,6 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
onDeleteTask={onDeleteTask}
onReviseTask={onReviseTask}
addToast={addToast}
disableDrag
/>
))}
</section>
@@ -1094,13 +1044,6 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
id="board"
ref={setBoardRef}
{...boardMousePanHandlers}
onDragStart={(e) => {
const id = (e.target as HTMLElement)?.closest?.("[data-id]")?.getAttribute("data-id");
if (id) draggingTaskIdRef.current = id;
}}
onDragEnd={() => {
draggingTaskIdRef.current = null;
}}
>
{selectedWorkflowColumns.map((columnDef) => {
const isCreateColumn = columnDef.id === selectedWorkflowCreateColumnId;
@@ -1125,8 +1068,6 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
showWorktreeGrouping={showWorktreeGrouping}
onMoveTask={onMoveTask}
onPromote={handlePromote}
canDropTask={canDropTaskBinder(columnDef.id, selectedWorkflow.id)}
getDraggingTaskId={getDraggingTaskId}
onPauseTask={onPauseTask}
onUnpauseTask={onUnpauseTask}
onResetTask={onResetTask}
@@ -1170,7 +1111,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
{partitionRevertedTasks(selectedWorkflowTasks).reverted.length > 0 && (
<section className="reverted-tasks-section" aria-label={t("tasks.revertedTasks", "Reverted Tasks")} data-testid="board-reverted-tasks">
<h2>{t("tasks.revertedTasks", "Reverted Tasks")}</h2>
{partitionRevertedTasks(selectedWorkflowTasks).reverted.map((task) => <TaskCard key={`reverted-${task.id}`} task={task} taskColumnFlags={blockerFanoutColumnFlagsByTaskId.get(task.id)} onOpenDetail={onOpenDetail} onDeleteTask={onDeleteTask} onReviseTask={onReviseTask} addToast={addToast} disableDrag />)}
{partitionRevertedTasks(selectedWorkflowTasks).reverted.map((task) => <TaskCard key={`reverted-${task.id}`} task={task} taskColumnFlags={blockerFanoutColumnFlagsByTaskId.get(task.id)} onOpenDetail={onOpenDetail} onDeleteTask={onDeleteTask} onReviseTask={onReviseTask} addToast={addToast} />)}
</section>
)}
{selectedWorkflowArchivedColumn && (
@@ -1191,8 +1132,6 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
showWorktreeGrouping={showWorktreeGrouping}
onMoveTask={onMoveTask}
onPromote={handlePromote}
canDropTask={canDropTaskBinder(selectedWorkflowArchivedColumn.id, selectedWorkflow.id)}
getDraggingTaskId={getDraggingTaskId}
onPauseTask={onPauseTask}
onUnpauseTask={onUnpauseTask}
onResetTask={onResetTask}

View File

@@ -229,20 +229,9 @@ interface ColumnProps {
/** Manually promote a held card out of this hold column (workflow mode). */
/** `force` waives the unplanned-for-execution gate after operator confirmation. */
onPromote?: (taskId: string, options?: { force?: boolean }) => Promise<void>;
/**
* Pre-check whether a drop into THIS column is allowed for the dragged task.
* Returns null for "allowed", or an i18n messageKey for a deterministic
* rejection (guard/capacity/unknown-column/workflow-mismatch). When a
* rejection is returned, dragover is NOT prevented, so the card never renders
* in this column (no-move semantics, R17). The dragged task id is read from a
* board-level ref set on dragstart.
*/
canDropTask?: (taskId: string) => string | null;
/** Read the id of the task currently being dragged (board-level ref). */
getDraggingTaskId?: () => string | null;
}
function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktreeGrouping, onMoveTask, onPauseTask, onUnpauseTask, onResetTask, onDuplicateTask, onMergeTask, onOpenDetail, onOpenRefine, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, mergeStrategy = "direct", onToggleAutoMerge, planAutoApproveEnabled, onTogglePlanAutoApprove, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onRevertTask, onDeleteTask, onArchiveAllDone, sortMode, onSortModeChange, doneSortMode, onDoneSortModeChange, collapsed, onToggleCollapse, archivedHasMore, archivedLoadingMore, onLoadMoreArchived, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, onOpenMission, lastFetchTimeMs, taskCardFieldDefs, taskWorkflowBadges, blockerFanoutMap, prAuthAvailable, holdTaskIds, workflowMode, workflowId, workflowOptions, defaultWorkflowId, columnDisplayName, columnDescription, columnFlags, workflowContextMenuColumns, taskContextMenuColumnsByTaskId, onPromote, canDropTask, getDraggingTaskId }: ColumnProps) {
function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktreeGrouping, onMoveTask, onPauseTask, onUnpauseTask, onResetTask, onDuplicateTask, onMergeTask, onOpenDetail, onOpenRefine, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, mergeStrategy = "direct", onToggleAutoMerge, planAutoApproveEnabled, onTogglePlanAutoApprove, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onRevertTask, onDeleteTask, onArchiveAllDone, sortMode, onSortModeChange, doneSortMode, onDoneSortModeChange, collapsed, onToggleCollapse, archivedHasMore, archivedLoadingMore, onLoadMoreArchived, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, onOpenMission, lastFetchTimeMs, taskCardFieldDefs, taskWorkflowBadges, blockerFanoutMap, prAuthAvailable, holdTaskIds, workflowMode, workflowId, workflowOptions, defaultWorkflowId, columnDisplayName, columnDescription, columnFlags, workflowContextMenuColumns, taskContextMenuColumnsByTaskId, onPromote }: ColumnProps) {
const { t } = useTranslation("app");
// Anchor the board.rejection.* catalog keys for the i18next extractor (it
// scopes `t` to the useTranslation binding, so the shared translateRejection
@@ -256,7 +245,6 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktree
promoteRejected: t("board.rejection.promoteRejected", "This card could not be promoted."),
}), [t]);
void rejectionCopy;
const [dragOver, setDragOver] = useState(false);
const [visibleTaskCount, setVisibleTaskCount] = useState(VISIBLE_TASKS_INITIAL);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isReplanning, setIsReplanning] = useState(false);
@@ -430,127 +418,6 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktree
setVisibleTaskCount(VISIBLE_TASKS_INITIAL);
}, [isSearchActive, searchResultSignature]);
const handleDragOver = useCallback((e: React.DragEvent) => {
// Don't allow dropping into archived column via drag-drop
if (isArchived) return;
// Workflow mode (R17): deterministic rejections are NO-MOVE — we do NOT
// call preventDefault, so the browser refuses the drop and the card never
// renders in this column. A null result means the drop is allowed.
if (workflowMode && canDropTask && getDraggingTaskId) {
const draggingId = getDraggingTaskId();
if (draggingId) {
const rejectionKey = canDropTask(draggingId);
if (rejectionKey) {
setInlineFeedback(translateRejectionKey(t, rejectionKey));
return; // no preventDefault → no-move
}
}
}
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setDragOver(true);
}, [isArchived, workflowMode, canDropTask, getDraggingTaskId, t]);
const handleDragLeave = useCallback((e: React.DragEvent) => {
const el = e.currentTarget as HTMLElement;
if (!el.contains(e.relatedTarget as Node)) {
setDragOver(false);
setInlineFeedback(null);
}
}, []);
const handleDrop = useCallback(async (e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
const taskId = e.dataTransfer.getData("text/plain");
if (!taskId) return;
// Check if task is already in this column - if so, skip the API call
const task = tasks.find((t) => t.id === taskId);
if (task && task.column === column) {
return; // No-op: task is already in this column
}
try {
const sourceTask = allTasks?.find((t) => t.id === taskId) ?? task;
const hasStepProgress = sourceTask?.steps.some((step) => step.status !== "pending") ?? false;
/*
FNXC:WorkflowResolvedColumns 2026-07-29-00:00 (U12 — R8 drift conversion):
This component's `column` IS the drop target, so its own `columnFlags` are the
target's traits — no lookup needed, unlike the same prompt in TaskCard/ListView
where the card and the destination differ. Ids remain the fallback for the
no-metadata window.
*/
/*
FNXC:WorkflowResolvedColumns 2026-07-30-19:20 (Phase B — consolidated, semantics verified):
Routed through `isPreImplementationColumnRole`. This is the SAME preserve-progress prompt that
helper was written for — ListView asks it about a move target, this component asks it about
itself — and the degraded id sets are identical (`{todo, triage}`), so the consolidation is
exact rather than approximately right.
Verified before consolidating, because the sibling case in TaskContextMenu is NOT
interchangeable: `isPreExecutionHoldColumn` drives the Plan affordance and its degraded set is
`{triage}` alone, so routing THAT through this helper added `plan` to flagless `todo` cards.
Same shape, different degraded answer — matched here, kept separate there.
*/
const shouldPrompt = hasStepProgress && isPreImplementationColumnRole(columnFlags, column);
let moveOptions: { preserveProgress?: boolean } | undefined;
if (shouldPrompt) {
const keepProgress = await confirm({
title: t("column.preserveProgressTitle", "Preserve Progress?"),
message: t("column.preserveProgressMessage", "This task has completed steps. Keep progress before moving?"),
confirmLabel: t("column.keepProgress", "Keep Progress"),
cancelLabel: t("column.resetProgress", "Reset Progress"),
});
if (keepProgress) {
moveOptions = { preserveProgress: true };
} else {
const resetProgress = await confirm({
title: t("column.resetProgressTitle", "Reset Progress?"),
message: t("column.resetProgressMessage", "Reset all step progress before moving this task?"),
confirmLabel: t("column.resetProgressConfirm", "Reset Progress"),
cancelLabel: t("column.cancelMove", "Cancel Move"),
danger: true,
});
if (!resetProgress) {
return;
}
}
}
await onMoveTask(taskId, column, moveOptions);
} catch (err) {
// Workflow mode (R17): a structured 409 carries a typed rejection. The
// optimistic move snaps back automatically (the next SSE/refresh restores
// the card's real column); surface the translated rejection messageKey.
const rejection = extractTransitionRejection(err);
if (rejection) {
addToast(translateRejection(t, rejection), "error");
} else {
addToast(getErrorMessage(err), "error");
}
}
/*
FNXC:WorkflowResolvedColumns 2026-07-31-00:40:
`columnFlags` BELONGS IN THIS LIST — the drop handler asks it whether this lane is pre-implementation.
`shouldPrompt` gates the "Preserve Progress?" confirmation on
`isPreImplementationColumnRole(columnFlags, column)`. The flags arrive after first paint, and
`useCallback` without them in its deps hands the DOM the closure built during the pre-load render.
In that closure the helper falls back to `LEGACY_PRE_IMPLEMENTATION_COLUMN_IDS`, which does not
contain a renamed intake/hold lane — so `shouldPrompt` is false and a card with completed steps is
moved WITHOUT asking, silently resetting progress the user was meant to be offered a choice about.
SEVERITY, STATED HONESTLY: `allTasks` and `tasks` are in this list and change identity on any
task-list refresh, so the stale closure is rebuilt within seconds on an active board — a window,
not a permanent wrong answer, like the near-duplicate chip and unlike the TaskCard ticker whose
refreshing dependency fired only at local midnight. The window is exactly the quiet gap after the
traits land, and a drop inside it loses work without a prompt.
*/
}, [addToast, allTasks, column, columnFlags, confirm, onMoveTask, tasks, t]);
/*
FNXC:BoardPromote 2026-07-25-04:55:
Promote is a two-attempt flow for the `unplanned-for-execution` rejection only.
@@ -902,11 +769,8 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktree
return (
<div
className={`column${dragOver ? " drag-over" : ""}${isArchived ? " column-archived" : ""}${isCollapsed ? " column-collapsed" : ""}`}
className={`column${isArchived ? " column-archived" : ""}${isCollapsed ? " column-collapsed" : ""}`}
data-column={column}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<div className="column-header">
<div className={`column-dot dot-${column}`} />

View File

@@ -113,7 +113,7 @@ export function DockTaskList({ columnFlagsByTaskId,
{revertedTasks.length > 0 && (
<section className="dock-task-list__reverted" aria-label={t("tasks.revertedTasks", "Reverted Tasks")} data-testid="dock-reverted-tasks">
<h3>{t("tasks.revertedTasks", "Reverted Tasks")}</h3>
{revertedTasks.map((task) => <TaskCard key={`reverted-${task.id}`} task={task} taskColumnFlags={columnFlagsByTaskId?.get(task.id)} projectId={projectId} onOpenDetail={handleOpenTask} onDeleteTask={onDeleteTask} onReviseTask={onReviseTask} addToast={addToast} disableDrag />)}
{revertedTasks.map((task) => <TaskCard key={`reverted-${task.id}`} task={task} taskColumnFlags={columnFlagsByTaskId?.get(task.id)} projectId={projectId} onOpenDetail={handleOpenTask} onDeleteTask={onDeleteTask} onReviseTask={onReviseTask} addToast={addToast} />)}
</section>
)}
{isEmpty ? (
@@ -139,7 +139,6 @@ export function DockTaskList({ columnFlagsByTaskId,
*/
onDeleteTask={onDeleteTask}
addToast={addToast}
disableDrag={true}
prAuthAvailable={prAuthAvailable}
autoMergeEnabled={autoMergeEnabled}
/>

View File

@@ -33,9 +33,6 @@ export interface LaneProps {
showWorktreeGrouping?: boolean;
onMoveTask: (id: string, column: ColumnId, optionsOrPosition?: { preserveProgress?: boolean } | number) => Promise<Task>;
onPromote: (taskId: string) => Promise<void>;
/** Drag pre-check: null = allowed, else an i18n messageKey (R17). */
canDropTask: (taskId: string, targetColumnId: string, workflowId: string) => string | null;
getDraggingTaskId: () => string | null;
onPauseTask?: (id: string) => Promise<Task>;
onOpenDetail: (task: Task | TaskDetail) => void;
onOpenGroupModal?: (groupId: string) => void;
@@ -148,10 +145,6 @@ function LaneComponent(props: LaneProps) {
handleToggle();
}, [handleToggle]);
const makeCanDrop = useCallback(
(targetColumnId: string) => (taskId: string) => props.canDropTask(taskId, targetColumnId, workflow.id),
[props, workflow.id],
);
return (
<section className="lane" data-lane={workflow.id} aria-label={workflow.name}>
@@ -190,8 +183,6 @@ function LaneComponent(props: LaneProps) {
showWorktreeGrouping={props.showWorktreeGrouping === true}
onMoveTask={props.onMoveTask}
onPromote={props.onPromote}
canDropTask={makeCanDrop(col.id)}
getDraggingTaskId={props.getDraggingTaskId}
onPauseTask={props.onPauseTask}
onOpenDetail={props.onOpenDetail}
onOpenGroupModal={props.onOpenGroupModal}

View File

@@ -367,7 +367,7 @@ List rows and mobile cards expose the same task lifecycle menu as Board cards fr
gap: var(--space-sm);
}
/* Drop zones for drag and drop */
/* Clickable workflow column filters. */
.list-drop-zones {
display: flex;
gap: var(--space-sm);
@@ -402,12 +402,6 @@ List rows and mobile cards expose the same task lifecycle menu as Board cards fr
box-shadow: 0 0 0 1px var(--todo);
}
.list-drop-zone.drag-over {
border-color: var(--todo);
box-shadow: 0 0 0 1px var(--todo);
background: color-mix(in srgb, var(--todo) 10%, transparent);
}
.drop-zone-dot {
width: 8px;
height: 8px;
@@ -599,10 +593,6 @@ No border-left on the detail pane. Keeping a border-left here would produce a se
box-shadow: inset 0 0 0 1px var(--todo);
}
.list-row.dragging {
opacity: 0.4;
}
.list-row.failed {
border-left: 3px solid var(--color-error-dark);
}

View File

@@ -37,7 +37,7 @@ import { computeWorkflowStatusCounts } from "./workflowStatusCounts";
import { writeBoardWorkflowsCache } from "../utils/boardWorkflowsCache";
import { useBoardWorkflows } from "../hooks/useBoardWorkflows";
import { useUnmappedWorkflowRefetch } from "../hooks/useUnmappedWorkflowRefetch";
import { TaskContextMenu, buildTaskActionMenuModel, getTaskPrAutomationLabel, type TaskContextMenuColumnMetadata, type TaskMenuActionDescriptor } from "./TaskContextMenu";
import { TaskContextMenu, buildTaskActionMenuModel, buildTaskMoveMenuItems, getTaskPrAutomationLabel, type TaskContextMenuColumnMetadata, type TaskMenuItemDescriptor } from "./TaskContextMenu";
import type { DetailTaskOpenOptions, DetailTaskTab } from "../hooks/useModalManager";
import { isTaskReverted, partitionRevertedTasks } from "../utils/taskRevert";
import { getTaskTitleDisplay } from "../utils/taskTitleDisplay";
@@ -405,8 +405,6 @@ export function ListView({
const columnLabel = useColumnLabel();
const [sortField, setSortField] = useState<SortField | null>(null);
const [sortDirection, setSortDirection] = useState<SortDirection>("asc");
const [draggingTaskId, setDraggingTaskId] = useState<string | null>(null);
const [dragOverColumn, setDragOverColumn] = useState<ColumnId | null>(null);
const [selectedColumn, setSelectedColumn] = useState<ColumnId | null>(null);
const [contextMenuState, setContextMenuState] = useState<ListContextMenuState>(null);
const [prCreateState, setPrCreateState] = useState<ListPrCreateState>(null);
@@ -979,19 +977,6 @@ export function ListView({
return isIntakeColumnRole(getTaskColumnFlags(task), task.column);
}, [getTaskColumnFlags]);
/*
FNXC:WorkflowResolvedColumns 2026-07-30-00:10 (fleet — same change as Column.tsx):
`workflowMode` is a BOARD-level boolean answering a PER-COLUMN question. In workflow mode with a
column that has no resolved traits, the old form returned false for every role rather than falling
back to the id — so the archive and revert affordances silently vanished for a card sitting in a
column its workflow no longer declares. The shared helpers ask per column and degrade to the
legacy id only when the flags are truly absent, which also covers the pre-load window the old form
handled via `workflowMode === false`.
*/
const isArchivedColumn = useCallback((column: ColumnId): boolean => {
return isArchivedColumnRole(columnFlagsById.get(column), column);
}, [columnFlagsById]);
/*
FNXC:WorkflowResolvedColumns 2026-07-30-14:00 (PR #2738 review — greptile P1):
PER-TASK twins of the two column-level predicates above.
@@ -2136,7 +2121,7 @@ export function ListView({
addToast(t("tasks.createdPr", "Created PR #{{number}}", { number: prInfo.number }), "success");
}, [addToast, onTasksUpdated, t]);
const buildListContextMenuActions = useCallback((task: Task): TaskMenuActionDescriptor[] => {
const buildListContextMenuActions = useCallback((task: Task): TaskMenuItemDescriptor[] => {
const canRetryTask = isTaskManuallyRetryable(task, lastFetchTimeMs);
const isTaskPaused = Boolean(task.paused || task.userPaused);
const effectiveAutoMerge = resolveEffectiveAutoMerge({ autoMerge: task.autoMerge }, { autoMerge: autoMerge ?? false });
@@ -2242,7 +2227,7 @@ export function ListView({
onEnableGithubTracking: onTasksUpdated ? () => void handleListContextEnableGithubTracking(task) : undefined,
});
const actions = [...model.actions];
const actions: TaskMenuItemDescriptor[] = [...model.actions];
const taskColumnFlags = getTaskColumnFlags(task);
if (isCompleteColumnRole(taskColumnFlags, task.column) && onArchiveTask) {
actions.push({ id: "archive", label: t("tasks.archive", "Archive"), onSelect: () => void handleListTaskArchive(task) });
@@ -2262,17 +2247,15 @@ export function ListView({
onSelect: isRevertable ? () => void handleListTaskRevert(task) : undefined,
});
}
for (const transition of model.moveTransitions) {
actions.push({
id: `move-${transition.column}`,
label: transition.label,
onSelect: () => void handleListContextMove(task, transition.column),
});
}
actions.push(...buildTaskMoveMenuItems(
model.moveTransitions,
(column) => void handleListContextMove(task, column),
t("taskDetail.move.moveToParent", "Move to"),
));
if (model.reviewAction) {
actions.push({ id: model.reviewAction.id, label: model.reviewAction.label, disabled: model.reviewAction.disabled, onSelect: model.reviewAction.onSelect });
}
return actions.filter((action) => action.tone === "note" || action.disabled === true || Boolean(action.onSelect));
return actions.filter((action) => "items" in action || action.tone === "note" || action.disabled === true || Boolean(action.onSelect));
}, [addToast, autoMerge, columnFlagsById, getTaskColumnFlags, confirm, getListColumnLabel, getTaskPlanningWorkflowId, handleListContextCheckPrStatus, handleListContextEnableGithubTracking, handleListContextMove, handleListTaskArchive, handleListTaskDelete, handleListTaskRevert, isMobile, lastFetchTimeMs, listContextMenuColumns, taskContextMenuColumnsByTaskId, mergeStrategy, onDuplicateTask, onMergeTask, onOpenDetail, onPlanningMode, onPauseTask, onResetTask, onRetryTask, onUnpauseTask, onArchiveTask, onRevertTask, onTasksUpdated, projectId, t, useSinglePaneList]);
const contextMenuActions = useMemo(
@@ -2498,24 +2481,6 @@ export function ListView({
}, 200);
}, [projectId]);
const handleDragStart = useCallback(
(e: React.DragEvent, task: Task) => {
if (task.paused) {
e.preventDefault();
return;
}
e.dataTransfer.setData("text/plain", task.id);
e.dataTransfer.effectAllowed = "move";
setDraggingTaskId(task.id);
},
[]
);
const handleDragEnd = useCallback(() => {
setDraggingTaskId(null);
setDragOverColumn(null);
}, []);
/*
FNXC:ListView 2026-06-22-18:00:
Pointer-based split resize. setPointerCapture keeps move/up events flowing to the handle even when
@@ -2598,72 +2563,6 @@ export function ListView({
}
}, [sidebarWidth, useSinglePaneList]);
const handleColumnDragOver = useCallback(
(e: React.DragEvent, column: ColumnId) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setDragOverColumn(column);
},
[]
);
const handleColumnDragLeave = useCallback(() => {
setDragOverColumn(null);
}, []);
const handleColumnDrop = useCallback(
async (e: React.DragEvent, column: ColumnId) => {
e.preventDefault();
setDragOverColumn(null);
const taskId = e.dataTransfer.getData("text/plain");
if (!taskId) return;
// Prevent dropping into archived column
if (isArchivedColumn(column)) {
addToast(t("listView.archiveViaButton", "Tasks can only be archived via the archive button"), "error");
return;
}
try {
const task = tasks.find((candidate) => candidate.id === taskId);
const hasStepProgress = task?.steps.some((step) => step.status !== "pending") ?? false;
const targetFlags = columnFlagsById.get(column);
// Same rule as the context-menu move above, and now literally the same function.
const shouldPrompt = hasStepProgress && isPreImplementationColumnRole(targetFlags, column);
let moveOptions: { preserveProgress?: boolean } | undefined;
if (shouldPrompt) {
const keepProgress = await confirm({
title: t("listView.preserveProgressTitle", "Preserve Progress?"),
message: t("listView.preserveProgressMessage", "This task has completed steps. Keep progress before moving?"),
confirmLabel: t("listView.keepProgress", "Keep Progress"),
cancelLabel: t("listView.resetProgress", "Reset Progress"),
});
if (keepProgress) {
moveOptions = { preserveProgress: true };
} else {
const resetProgress = await confirm({
title: t("listView.resetProgressTitle", "Reset Progress?"),
message: t("listView.resetProgressMessage", "Reset all step progress before moving this task?"),
confirmLabel: t("listView.resetProgress", "Reset Progress"),
cancelLabel: t("listView.cancelMove", "Cancel Move"),
danger: true,
});
if (!resetProgress) {
return;
}
}
}
await onMoveTask(taskId, column, moveOptions);
} catch (err) {
addToast(getErrorMessage(err), "error");
}
},
[addToast, columnFlagsById, confirm, isArchivedColumn, onMoveTask, tasks, t]
);
const getSortIcon = (field: SortField) => {
if (!sortField || sortField !== field) return <ArrowUpDown size={14} className="sort-icon" />;
return sortDirection === "asc" ? (
@@ -2765,11 +2664,8 @@ export function ListView({
return (
<div
key={column}
className={`list-drop-zone${dragOverColumn === column ? " drag-over" : ""}${selectedColumn === column ? " active" : ""}`}
className={`list-drop-zone${selectedColumn === column ? " active" : ""}`}
onClick={() => handleColumnFilter(column)}
onDragOver={(e) => handleColumnDragOver(e, column)}
onDragLeave={handleColumnDragLeave}
onDrop={(e) => handleColumnDrop(e, column)}
data-column={column}
>
<span className={`list-section-dot dot-${column}`} style={{ backgroundColor: columnColor(column) }} />
@@ -3475,20 +3371,14 @@ export function ListView({
? t("tasks.statusPlanning", "Planning")
: wipLifecycleBadgeLabel
?? getTaskStatusLabel(visualStatus ?? "", t, showOptionalGateBadge ? undefined : getRunningWorkflowStepLabel(task), { idle: !isAgentActive, overlapBlockedBy: task.overlapBlockedBy ?? null });
const isDragging = draggingTaskId === task.id;
return (
<tr
key={task.id}
className={`list-row${isFailed ? " failed" : ""}${isPaused ? " paused" : ""}${isAgentActive ? " agent-active" : ""}${
isDragging ? " dragging" : ""
}${selectedTaskId === task.id ? " list-row--selected" : ""}`}
className={`list-row${isFailed ? " failed" : ""}${isPaused ? " paused" : ""}${isAgentActive ? " agent-active" : ""}${selectedTaskId === task.id ? " list-row--selected" : ""}`}
onClick={() => handleRowClick(task)}
onContextMenu={(event) => handleListContextMenu(event, task)}
onKeyDown={(event) => handleListKeyDown(event, task)}
draggable={!isPaused}
onDragStart={(e) => handleDragStart(e, task)}
onDragEnd={handleDragEnd}
data-id={task.id}
tabIndex={0}
aria-haspopup="menu"

View File

@@ -4,7 +4,6 @@
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: var(--card-padding);
cursor: grab;
transition:
background var(--transition-fast),
border-color var(--transition-fast),
@@ -32,13 +31,6 @@
border-color: var(--todo);
outline: none;
}
.card:active {
cursor: grabbing;
}
.card.dragging {
opacity: 0.4;
transform: scale(0.98);
}
.card.file-drop-target {
border: 2px dashed var(--todo);
background: color-mix(in srgb, var(--todo) 8%, transparent);

View File

@@ -27,7 +27,6 @@ import { PrCreateModal } from "./PrCreateModal";
import { ProviderIcon } from "./ProviderIcon";
import { PluginSlot } from "./PluginSlot";
import { useBadgeWebSocket } from "../hooks/useBadgeWebSocket";
import { useCoarsePointer } from "../hooks/useCoarsePointer";
import { plannerOverseerBadgeTooltip, plannerOverseerStateLabel } from "./plannerOverseerBadge";
import { getFreshBatchData } from "../hooks/useBatchBadgeFetch";
import { useTaskDiffStats } from "../hooks/useTaskDiffStats";
@@ -75,7 +74,7 @@ import { useColumnLabel } from "../i18n/labels";
import { formatCompactLifecycleDate, useLocaleFormat } from "../i18n/format";
import { WorkspaceWorktreesSummary, isWorkspaceTask } from "./WorkspaceWorktreesSummary";
import { WorkflowIcon } from "./WorkflowIcon";
import { TaskContextMenu, buildTaskActionMenuModel, getTaskPrAutomationLabel, type TaskContextMenuColumnFlags, type TaskContextMenuColumnMetadata, type TaskMenuActionDescriptor } from "./TaskContextMenu";
import { TaskContextMenu, buildTaskActionMenuModel, buildTaskMoveMenuItems, getTaskPrAutomationLabel, type TaskContextMenuColumnFlags, type TaskContextMenuColumnMetadata, type TaskMenuItemDescriptor } from "./TaskContextMenu";
import { formatCost, hasTaskCost, taskTotalCost } from "../utils/taskTokenCost";
import { getPriorityColorVar, getPriorityIcon, getPriorityLabel } from "../utils/priorityIndicator";
import { getTaskTitleDisplay } from "../utils/taskTitleDisplay";
@@ -662,8 +661,6 @@ interface TaskCardProps {
isPromoting?: boolean;
/** Timestamp (ms) when task data was last confirmed fresh from the server. */
lastFetchTimeMs?: number;
/** Disable card drag semantics when embedding in custom draggable containers (e.g. dependency graph). */
disableDrag?: boolean;
/** Downstream fan-out entry for this task, computed at board-level. */
fanout?: BlockerFanoutEntry;
/** Whether GitHub CLI auth is available for creating PRs from task cards. */
@@ -876,7 +873,6 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
previous.onMoveTask === next.onMoveTask &&
previous.onPromote === next.onPromote &&
previous.isPromoting === next.isPromoting &&
previous.disableDrag === next.disableDrag &&
previous.fanout?.totalCount === next.fanout?.totalCount &&
previous.fanout?.activeTodoCount === next.fanout?.activeTodoCount &&
previous.fanout?.isHighFanout === next.fanout?.isHighFanout &&
@@ -1026,7 +1022,6 @@ function TaskCardComponent({
onPromote,
isPromoting = false,
lastFetchTimeMs,
disableDrag,
fanout,
prAuthAvailable,
autoMergeEnabled = false,
@@ -1041,7 +1036,6 @@ function TaskCardComponent({
const { t } = useTranslation("app");
const { locale } = useLocaleFormat();
const columnLabel = useColumnLabel();
const [dragging, setDragging] = useState(false);
const [fileDragOver, setFileDragOver] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [editDescription, setEditDescription] = useState(task.description || "");
@@ -1346,21 +1340,6 @@ function TaskCardComponent({
return () => observer.disconnect();
}, [isEditing, task.id]);
const handleDragStart = useCallback((e: React.DragEvent) => {
if (longPressTimerRef.current) {
clearTimeout(longPressTimerRef.current);
longPressTimerRef.current = null;
longPressStartRef.current = null;
}
e.dataTransfer.setData("text/plain", task.id);
e.dataTransfer.effectAllowed = "move";
setDragging(true);
}, [task.id]);
const handleDragEnd = useCallback(() => {
setDragging(false);
}, []);
const isFileDrag = useCallback((e: React.DragEvent) => {
return e.dataTransfer.types.includes("Files");
}, []);
@@ -1560,7 +1539,6 @@ function TaskCardComponent({
const isAwaitingApproval = isTaskAwaitingPlanApproval(task, isIntakeColumn);
const isBlockedOnApprovalHold = isTaskBlockedOnApprovalHold(task);
const isAwaitingInput = task.status === "awaiting-user-input";
const isArchived = isArchivedColumn;
/*
FNXC:WorkflowResolvedColumns 2026-07-29-00:00 (PR #2566 review — greptile):
Pass the card's column traits. Without them the planner-lane clause falls back to the
@@ -1639,12 +1617,11 @@ function TaskCardComponent({
structural rather than a property of the step count that two independent conditions had to agree on.
*/
const showQueuedToPlanBadge = showIdleTodoBadge && !queued && awaitingPlanning;
// Native HTML5 drag is desktop-mouse only — it doesn't move cards via touch.
// On touch-primary devices the `draggable` attribute still arms the browser's
// touch-drag heuristic, which intermittently hijacks horizontal swipes meant
// to scroll the board. Drop drag on coarse pointers so panning stays reliable.
const isCoarsePointer = useCoarsePointer();
const isDraggable = !disableDrag && !queued && !isPaused && !isEditing && !isArchived && !isCoarsePointer; // Disable drag during edit/archived, host embedding, or touch
/*
FNXC:TaskCardMovement 2026-08-19-18:32:
Native task movement is intentionally absent from cards. File drops remain below because
attaching files is a separate card interaction, while task transitions stay in the context menu.
*/
// Check if this card can be edited inline
/*
@@ -2846,11 +2823,11 @@ function TaskCardComponent({
task.column,
task.prInfo,
]);
const contextMenuActions = useMemo<TaskMenuActionDescriptor[]>(() => {
const contextMenuActions = useMemo<TaskMenuItemDescriptor[]>(() => {
if (!onDeleteTask && !onArchiveTask && !onUnarchiveTask && !onRevertTask && !onDuplicateTask && !onRetryTask && !onResetTask && !onPauseTask && !onUnpauseTask && !onMergeTask && !onMoveTask && !onPlanningMode && !onOpenRefine && !onUpdateTask) {
return [];
}
const actions = [...taskActionMenuModel.actions];
const actions: TaskMenuItemDescriptor[] = [...taskActionMenuModel.actions];
if (isCompleteColumn && onArchiveTask) {
actions.push({ id: "archive", label: t("tasks.archive", "Archive"), onSelect: handleTaskActionArchive });
}
@@ -2906,15 +2883,13 @@ function TaskCardComponent({
});
}
}
for (const transition of moveTransitions) {
actions.push({
id: `move-${transition.column}`,
label: transition.label,
onSelect: () => handleTaskActionMove(transition.column),
});
}
actions.push(...buildTaskMoveMenuItems(
moveTransitions,
handleTaskActionMove,
t("taskDetail.move.moveToParent", "Move to"),
));
}
return actions.filter((action) => action.tone === "note" || action.disabled === true || Boolean(action.onSelect));
return actions.filter((action) => "items" in action || action.tone === "note" || action.disabled === true || Boolean(action.onSelect));
}, [handleTaskActionArchive, handleTaskActionMove, handleTaskActionRevert, handleTaskActionUnarchive, isRevertable, onArchiveTask, onDeleteTask, onDuplicateTask, onMergeTask, onMoveTask, onPlanningMode, onOpenRefine, onPauseTask, onResetTask, onRetryTask, onRevertTask, onUnarchiveTask, onUnpauseTask, onUpdateTask, t, task.column, taskActionColumnLabel, taskActionMenuModel.actions, taskActionMenuModel.moveTransitions, taskActionMenuModel.reviewAction, taskMoveColumns]);
const hasContextMenuActions = contextMenuActions.length > 0;
@@ -3140,7 +3115,7 @@ function TaskCardComponent({
}
}, [addToast, isRetrying, onRetryTask, task.id]);
const cardClass = `card${dragging ? " dragging" : ""}${queued ? " queued" : ""}${isAgentActive ? " agent-active" : ""}${isFailed ? " failed" : ""}${isPaused ? " paused" : ""}${isAwaitingApproval ? " awaiting-approval" : ""}${isAwaitingInput ? " awaiting-input" : ""}${fileDragOver ? " file-drop-target" : ""}${isEditing ? " card-editing" : ""}${isSaving ? " card-saving" : ""}`;
const cardClass = `card${queued ? " queued" : ""}${isAgentActive ? " agent-active" : ""}${isFailed ? " failed" : ""}${isPaused ? " paused" : ""}${isAwaitingApproval ? " awaiting-approval" : ""}${isAwaitingInput ? " awaiting-input" : ""}${fileDragOver ? " file-drop-target" : ""}${isEditing ? " card-editing" : ""}${isSaving ? " card-saving" : ""}`;
const filesChangedButton = (() => {
if (isWipColumn) {
@@ -3552,9 +3527,6 @@ function TaskCardComponent({
className={cardClass}
data-id={task.id}
data-column={task.column}
draggable={isDraggable}
onDragStart={isDraggable ? handleDragStart : undefined}
onDragEnd={isDraggable ? handleDragEnd : undefined}
onDragOver={handleFileDragOver}
onDragLeave={handleFileDragLeave}
onDrop={handleFileDrop}

View File

@@ -46,6 +46,43 @@
cursor: default;
}
.task-context-menu__submenu-parent {
position: relative;
}
.task-context-menu__submenu-toggle {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
}
.task-context-menu__submenu-toggle::after {
content: "›";
color: var(--text-muted);
}
.task-context-menu__submenu {
position: absolute;
top: 0;
left: calc(100% - var(--space-xs));
min-width: var(--task-context-menu-min-width);
padding: var(--space-xs) 0;
border: var(--btn-border-width) solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface);
box-shadow: var(--shadow-lg);
}
.task-context-menu__submenu--opens-left {
right: calc(100% - var(--space-xs));
left: auto;
}
.task-context-menu__submenu-item {
padding-left: var(--space-lg);
}
@media (max-width: 768px) {
.task-context-menu {
--task-context-menu-min-width: min(calc(100vw - var(--space-xl)), calc(var(--space-xl) * 6));

View File

@@ -1,6 +1,6 @@
import "./TaskContextMenu.css";
import type { KeyboardEvent, PointerEvent as ReactPointerEvent, MouseEvent as ReactMouseEvent, ReactNode } from "react";
import { Fragment, useCallback, useEffect, useRef } from "react";
import { Fragment, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import type { TFunction } from "i18next";
import type { ColumnId, Task, TaskDetail, WorkflowStepResult } from "@fusion/core";
import { VALID_TRANSITIONS, isColumn } from "@fusion/core";
@@ -37,6 +37,15 @@ export interface TaskMenuActionDescriptor {
onSelect?: () => void;
}
/** A non-action menu parent whose children are the selectable menu items. */
export interface TaskMenuSubmenuDescriptor {
id: string;
label: string;
items: TaskMenuActionDescriptor[];
}
export type TaskMenuItemDescriptor = TaskMenuActionDescriptor | TaskMenuSubmenuDescriptor;
export interface TaskMoveActionDescriptor {
column: ColumnId;
label: string;
@@ -386,6 +395,32 @@ export function getTaskMoveTransitions(
});
}
/*
FNXC:TaskCardMovement 2026-08-19-18:35:
Task movement is contextual rather than drag-and-drop. Group only multiple legal destinations so
one-target menus remain direct, and de-duplicate by column before rendering to prevent supplemental
review targets from creating repeated Move to entries.
*/
export function buildTaskMoveMenuItems(
transitions: readonly TaskMoveActionDescriptor[],
onSelect: (column: ColumnId) => void,
parentLabel: string,
): TaskMenuItemDescriptor[] {
const uniqueByColumn = new Map<ColumnId, TaskMoveActionDescriptor>();
for (const transition of transitions) {
if (!uniqueByColumn.has(transition.column)) uniqueByColumn.set(transition.column, transition);
}
const uniqueTransitions = Array.from(uniqueByColumn.values());
const items = uniqueTransitions.map((transition) => ({
id: `move-${transition.column}`,
label: transition.label,
onSelect: () => onSelect(transition.column),
}));
return items.length > 1
? [{ id: "move-to", label: parentLabel, items }]
: items;
}
export function getTaskReviewAction(
task: Task | TaskDetail,
options: Pick<BuildTaskActionMenuModelOptions, "t" | "currentColumnFlags" | "mergeStrategy" | "autoMergeEnabled" | "prAutomationLabel" | "isCheckingPrStatus" | "onMerge" | "onStartPrReview" | "onCheckPrStatus">,
@@ -571,7 +606,7 @@ export function buildTaskActionMenuModel(options: BuildTaskActionMenuModelOption
}
export interface TaskContextMenuProps {
actions: TaskMenuActionDescriptor[];
actions: TaskMenuItemDescriptor[];
role?: "menu" | "list";
className?: string;
itemClassName?: string;
@@ -599,6 +634,9 @@ export function TaskContextMenu({
}: TaskContextMenuProps) {
const menuRef = useRef<HTMLDivElement | null>(null);
const touchSelectedActionRef = useRef<{ id: string; at: number } | null>(null);
const submenuRef = useRef<HTMLDivElement | null>(null);
const [openSubmenuId, setOpenSubmenuId] = useState<string | null>(null);
const [submenuOpensLeft, setSubmenuOpensLeft] = useState(false);
const selectAction = useCallback((action: TaskMenuActionDescriptor) => {
if (action.disabled || action.tone === "note" || !action.onSelect) return;
@@ -643,7 +681,40 @@ export function TaskContextMenu({
firstItem?.focus({ preventScroll: true });
}, [actions, autoFocusFirstItem]);
useEffect(() => {
if (!openSubmenuId) return;
menuRef.current?.querySelector<HTMLButtonElement>(`[data-task-submenu="${openSubmenuId}"] button:not(:disabled)`)?.focus({ preventScroll: true });
}, [openSubmenuId]);
/*
FNXC:TaskCardMovement 2026-08-19-18:52:
The root menu is clamped to the viewport, but a nested Move to menu can still overflow from a
rightmost Board lane or dock. Measure its rendered edge before paint and flip it left so every
legal destination remains reachable with mouse, keyboard, and touch.
*/
useLayoutEffect(() => {
if (!openSubmenuId) {
setSubmenuOpensLeft(false);
return;
}
setSubmenuOpensLeft((submenuRef.current?.getBoundingClientRect().right ?? 0) > window.innerWidth);
}, [openSubmenuId]);
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
const activeSubmenu = (document.activeElement as HTMLElement | null)?.closest<HTMLElement>("[data-task-submenu]");
if (event.key === "Escape" && activeSubmenu) {
event.preventDefault();
event.stopPropagation();
setOpenSubmenuId(null);
menuRef.current?.querySelector<HTMLButtonElement>(`[data-task-submenu-toggle="${activeSubmenu.dataset.taskSubmenu}"]`)?.focus();
return;
}
if (event.key === "ArrowLeft" && activeSubmenu) {
event.preventDefault();
setOpenSubmenuId(null);
menuRef.current?.querySelector<HTMLButtonElement>(`[data-task-submenu-toggle="${activeSubmenu.dataset.taskSubmenu}"]`)?.focus();
return;
}
if (event.key !== "ArrowDown" && event.key !== "ArrowUp" && event.key !== "Home" && event.key !== "End") return;
const items = Array.from(menuRef.current?.querySelectorAll<HTMLButtonElement>("button:not(:disabled)") ?? []);
if (items.length === 0) return;
@@ -662,29 +733,65 @@ export function TaskContextMenu({
return (
<div ref={menuRef} className={className} role={role} onKeyDown={handleKeyDown}>
{actions.map((action) => {
{actions.map((item) => {
if ("items" in item) {
const isOpen = openSubmenuId === item.id;
return (
<div className="task-context-menu__submenu-parent" key={item.id}>
<button
type="button"
className={`${itemClassName} task-context-menu__submenu-toggle`}
role={role === "menu" ? "menuitem" : undefined}
aria-haspopup="menu"
aria-expanded={isOpen}
data-task-submenu-toggle={item.id}
onClick={() => setOpenSubmenuId((current) => current === item.id ? null : item.id)}
onKeyDown={(event) => {
if (event.key !== "ArrowRight" && event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
setOpenSubmenuId(item.id);
}}
>
{item.label}
</button>
{isOpen && (
<div
ref={submenuRef}
className={`task-context-menu__submenu${submenuOpensLeft ? " task-context-menu__submenu--opens-left" : ""}`}
role="menu"
data-task-submenu={item.id}
>
{item.items.map((action) => {
const classes = [itemClassName, "task-context-menu__submenu-item"];
if (action.tone === "danger") classes.push(dangerItemClassName);
return (
<button
key={action.id}
type="button"
className={classes.join(" ")}
role={role === "menu" ? "menuitem" : undefined}
disabled={action.disabled}
onPointerUp={(event) => handleActionPointerUp(event, action)}
onClick={(event) => handleActionClick(event, action)}
>
{action.label}
</button>
);
})}
</div>
)}
</div>
);
}
const action = item;
const classes = [itemClassName];
if (action.tone === "danger") classes.push(dangerItemClassName);
if (action.tone === "note") classes.push(noteItemClassName);
const defaultNode = action.tone === "note" ? (
<span key={action.id} className={classes.join(" ")} role="note">
{action.label}
</span>
<span key={action.id} className={classes.join(" ")} role="note">{action.label}</span>
) : (
<button
key={action.id}
type="button"
className={classes.join(" ")}
role={role === "menu" ? "menuitem" : undefined}
disabled={action.disabled}
onPointerUp={(event) => handleActionPointerUp(event, action)}
onClick={(event) => handleActionClick(event, action)}
>
{action.label}
</button>
<button key={action.id} type="button" className={classes.join(" ")} role={role === "menu" ? "menuitem" : undefined} disabled={action.disabled} onPointerUp={(event) => handleActionPointerUp(event, action)} onClick={(event) => handleActionClick(event, action)}>{action.label}</button>
);
return <Fragment key={action.id}>{renderAction ? renderAction(action, defaultNode) : defaultNode}</Fragment>;
})}
</div>

View File

@@ -1,212 +0,0 @@
// FN-1416/FN-6029: Board-level coverage of the canDropTask drag pre-check (R17).
//
// Board now passes one-argument per-column wrappers directly to <Column> in the
// selected-workflow rendering path, while <Lane> still adapts the canonical
// three-argument decision for multi-lane rendering. These tests exercise the
// pure Board decision seam directly so unrendered-column branches (especially
// unknownColumn) remain covered without stale Lane mocking.
import { describe, it, expect } from "vitest";
import type { Task } from "@fusion/core";
import type { BoardWorkflowsPayload } from "../../api";
import { getBoardCanDropTaskRejection } from "../boardCanDropTask";
const DEFAULT_WORKFLOW = "builtin:coding";
const CUSTOM_WORKFLOW = "WF-001";
// builtin:coding columns (in-progress counts toward wip; todo does not).
const defaultColumns = [
{ id: "triage", name: "Triage", flags: {} },
{ id: "todo", name: "Todo", flags: {} },
{ id: "in-progress", name: "In Progress", flags: { countsTowardWip: true } },
{ id: "in-review", name: "In Review", flags: {} },
{ id: "done", name: "Done", flags: { complete: true } },
];
const customColumns = [
{ id: "c-intake", name: "Intake", flags: { intake: true } },
{ id: "c-run", name: "Run", flags: { countsTowardWip: true } },
{ id: "c-done", name: "Done", flags: { complete: true } },
];
function makeTask(id: string, column: string): Task {
const now = new Date().toISOString();
return {
id,
description: id,
column,
dependencies: [],
createdAt: now,
updatedAt: now,
size: "M",
subtasks: [],
log: [],
tags: [],
blockedBy: [],
source: { sourceType: "api" },
} as unknown as Task;
}
function boardWorkflows(taskWorkflowIds: Record<string, string> = {}): BoardWorkflowsPayload {
return {
flagEnabled: true,
defaultWorkflowId: DEFAULT_WORKFLOW,
workflows: [
{ id: DEFAULT_WORKFLOW, name: "Coding", columns: defaultColumns },
{ id: CUSTOM_WORKFLOW, name: "Custom", columns: customColumns },
],
taskWorkflowIds,
};
}
function canDrop({
workflows = boardWorkflows(),
tasks,
maxConcurrent = 2,
taskId = "FN-1",
targetColumnId,
laneWorkflowId = DEFAULT_WORKFLOW,
}: {
workflows?: BoardWorkflowsPayload | null | undefined;
tasks: Task[];
maxConcurrent?: number;
taskId?: string;
targetColumnId: string;
laneWorkflowId?: string;
}) {
return getBoardCanDropTaskRejection({
boardWorkflows: workflows,
tasks,
maxConcurrent,
taskId,
targetColumnId,
laneWorkflowId,
});
}
describe("Board canDropTask pre-check (FN-1416/FN-6029)", () => {
it("cross-workflow drag returns workflowMismatch", () => {
const tasks = [makeTask("FN-1", "todo")];
expect(canDrop({
workflows: boardWorkflows({ "FN-1": DEFAULT_WORKFLOW }),
tasks,
targetColumnId: "c-run",
laneWorkflowId: CUSTOM_WORKFLOW,
})).toBe("board.rejection.workflowMismatch");
});
it("unknown target column in the source workflow returns unknownColumn", () => {
const tasks = [makeTask("FN-1", "todo")];
expect(canDrop({
workflows: boardWorkflows({ "FN-1": DEFAULT_WORKFLOW }),
tasks,
targetColumnId: "does-not-exist",
})).toBe("board.rejection.unknownColumn");
});
it("full wip column returns capacityExhausted", () => {
// maxConcurrent: 2; two cards already occupy in-progress in the default
// workflow. Dragging a third from todo into in-progress must reject.
const tasks = [
makeTask("FN-1", "todo"),
makeTask("FN-2", "in-progress"),
makeTask("FN-3", "in-progress"),
];
expect(canDrop({
workflows: boardWorkflows({
"FN-1": DEFAULT_WORKFLOW,
"FN-2": DEFAULT_WORKFLOW,
"FN-3": DEFAULT_WORKFLOW,
}),
tasks,
targetColumnId: "in-progress",
})).toBe("board.rejection.capacityExhausted");
});
it("valid same-workflow drops under capacity return null", () => {
const tasks = [makeTask("FN-1", "todo"), makeTask("FN-2", "in-progress")];
const workflows = boardWorkflows({ "FN-1": DEFAULT_WORKFLOW, "FN-2": DEFAULT_WORKFLOW });
expect(canDrop({ workflows, tasks, targetColumnId: "in-progress" })).toBeNull();
expect(canDrop({ workflows, tasks, targetColumnId: "in-review" })).toBeNull();
});
it("returns null when boardWorkflows is undefined", () => {
expect(canDrop({
workflows: undefined,
tasks: [makeTask("FN-1", "todo")],
targetColumnId: "in-progress",
})).toBeNull();
});
it("returns null when the source task is missing", () => {
expect(canDrop({
workflows: boardWorkflows({ "FN-1": DEFAULT_WORKFLOW }),
tasks: [makeTask("FN-2", "todo")],
taskId: "FN-1",
targetColumnId: "in-progress",
})).toBeNull();
});
it("returns null when the source workflow is missing from boardWorkflows", () => {
expect(canDrop({
workflows: {
flagEnabled: true,
defaultWorkflowId: DEFAULT_WORKFLOW,
workflows: [{ id: CUSTOM_WORKFLOW, name: "Custom", columns: customColumns }],
taskWorkflowIds: { "FN-1": DEFAULT_WORKFLOW },
},
tasks: [makeTask("FN-1", "todo")],
targetColumnId: "in-progress",
})).toBeNull();
});
it("falls back to the default workflow when a task has no workflow id", () => {
expect(canDrop({
workflows: boardWorkflows({}),
tasks: [makeTask("FN-1", "todo")],
targetColumnId: "does-not-exist",
laneWorkflowId: DEFAULT_WORKFLOW,
})).toBe("board.rejection.unknownColumn");
});
it("allows same-column wip drops even when the column is at capacity", () => {
const tasks = [
makeTask("FN-1", "in-progress"),
makeTask("FN-2", "in-progress"),
makeTask("FN-3", "in-progress"),
];
expect(canDrop({
workflows: boardWorkflows({
"FN-1": DEFAULT_WORKFLOW,
"FN-2": DEFAULT_WORKFLOW,
"FN-3": DEFAULT_WORKFLOW,
}),
tasks,
targetColumnId: "in-progress",
})).toBeNull();
});
it("does not count occupants from other workflows toward this workflow's capacity", () => {
const tasks = [
makeTask("FN-1", "todo"),
makeTask("FN-2", "in-progress"),
makeTask("FN-3", "in-progress"),
makeTask("FN-4", "in-progress"),
];
expect(canDrop({
workflows: boardWorkflows({
"FN-1": DEFAULT_WORKFLOW,
"FN-2": DEFAULT_WORKFLOW,
"FN-3": CUSTOM_WORKFLOW,
"FN-4": CUSTOM_WORKFLOW,
}),
tasks,
targetColumnId: "in-progress",
})).toBeNull();
});
});

View File

@@ -118,7 +118,7 @@ vi.mock("../Column", () => ({
</button>
) : null}
{tasks.map((task) => (
<article key={task.id} data-id={task.id} draggable data-testid={`board-task-card-${task.id}`}>
<article key={task.id} data-id={task.id} data-testid={`board-task-card-${task.id}`}>
{task.title ?? task.description ?? task.id}
</article>
))}
@@ -2074,7 +2074,11 @@ describe("Board", () => {
expect(board).not.toHaveClass("is-mouse-panning");
const card = screen.getByTestId("board-task-card-FN-1");
expect(card).toHaveAttribute("draggable", "true");
/*
FNXC:TaskCardInteraction 2026-08-19-19:07:
Native task dragging is retired; cards remain ordinary pointer targets so board panning excludes them without preserving the drag contract.
*/
expect(card).not.toHaveAttribute("draggable");
fireEvent.pointerDown(card, { button: 0, clientX: 100, clientY: 50, pointerId: 2, pointerType: "mouse" });
fireEvent.pointerMove(card, { clientX: 40, clientY: 50, pointerId: 2, pointerType: "mouse" });
fireEvent.pointerUp(card, { pointerId: 2, pointerType: "mouse" });

View File

@@ -344,30 +344,7 @@ describe("Column workflow mode (U9)", () => {
expect(document.querySelector(".column-menu")).not.toBeNull();
});
it("surfaces a translated rejection messageKey on a failed drop (snap-back)", async () => {
const addToast = vi.fn();
const onMoveTask = vi.fn().mockRejectedValue({
details: { code: "merge-blocked", messageKey: "board.rejection.mergeBlocked", retryable: false },
});
render(
<Column
{...defaultProps}
column={"done" as ColumnType}
workflowMode
columnDisplayName="Done"
columnFlags={{ complete: true }}
addToast={addToast}
onMoveTask={onMoveTask}
tasks={[]}
/>,
);
const columnEl = document.querySelector('[data-column="done"]') as HTMLElement;
fireEvent.drop(columnEl, { dataTransfer: { getData: () => "FN-99" } });
await waitFor(() => expect(addToast).toHaveBeenCalled());
// The toast surfaces the translated merge-blocked copy (not the raw key).
expect(addToast.mock.calls[0][0]).toContain("merge step");
expect(addToast.mock.calls[0][1]).toBe("error");
});
it("renders a Promote affordance on hold-column cards", () => {
render(
@@ -687,21 +664,7 @@ describe("Column pagination", () => {
expect(screen.getAllByTestId(/task-/)).toHaveLength(60);
});
it("still handles drops when pagination is enabled", () => {
const tasks = Array.from({ length: 110 }, (_, index) => makeTask(`KB-${String(index + 1).padStart(3, "0")}`));
const onMoveTask = vi.fn().mockResolvedValue({} as Task);
render(<Column {...defaultProps} column="todo" tasks={tasks} onMoveTask={onMoveTask} />);
const column = screen.getByText("110").closest(".column") as HTMLElement;
const dataTransfer = {
getData: vi.fn().mockReturnValue("KB-999"),
dropEffect: "move",
};
fireEvent.drop(column, { dataTransfer });
expect(onMoveTask).toHaveBeenCalledWith("KB-999", "todo", undefined);
});
it("does not paginate at the threshold boundary", () => {
const tasks = Array.from({ length: 100 }, (_, index) => makeTask(`KB-${String(index + 1).padStart(3, "0")}`));
@@ -1299,67 +1262,6 @@ describe("Column Done action menu", () => {
});
});
describe("Column same-column drop", () => {
it("does not call onMoveTask when dropping task into its current column", () => {
const onMoveTask = vi.fn().mockResolvedValue({} as Task);
const addToast = vi.fn();
const tasks = [{ ...makeTask("FN-001"), column: "todo" as ColumnType }];
render(<Column {...defaultProps} column="todo" tasks={tasks} onMoveTask={onMoveTask} addToast={addToast} />);
const columnEl = screen.getByRole("heading", { name: "Todo" }).closest(".column") as HTMLElement;
const dataTransfer = {
getData: vi.fn().mockReturnValue("FN-001"),
dropEffect: "move",
};
fireEvent.drop(columnEl, { dataTransfer });
expect(onMoveTask).not.toHaveBeenCalled();
expect(addToast).not.toHaveBeenCalled();
});
it("removes drag-over styling after drop even on same column", () => {
const onMoveTask = vi.fn().mockResolvedValue({} as Task);
const tasks = [{ ...makeTask("FN-001"), column: "todo" as ColumnType }];
render(<Column {...defaultProps} column="todo" tasks={tasks} onMoveTask={onMoveTask} />);
const columnEl = screen.getByRole("heading", { name: "Todo" }).closest(".column") as HTMLElement;
const dataTransfer = {
getData: vi.fn().mockReturnValue("FN-001"),
dropEffect: "move",
};
// First trigger dragOver to set drag-over state
fireEvent.dragOver(columnEl, { dataTransfer });
expect(columnEl.className).toContain("drag-over");
// Then drop - should remove drag-over class even for same-column drop
fireEvent.drop(columnEl, { dataTransfer });
expect(columnEl.className).not.toContain("drag-over");
});
it("calls onMoveTask when dropping task into a different column", () => {
const onMoveTask = vi.fn().mockResolvedValue({} as Task);
const addToast = vi.fn();
// Task is in "todo" column - but we're dropping it onto "in-review" column
// The "in-review" column should have 0 tasks initially
const tasksInTargetColumn: Task[] = [];
// Dropping into "in-review" column (which has 0 tasks)
render(<Column {...defaultProps} column="in-review" tasks={tasksInTargetColumn} onMoveTask={onMoveTask} addToast={addToast} />);
const columnEl = screen.getAllByText("0")[0].closest(".column") as HTMLElement;
const dataTransfer = {
getData: vi.fn().mockReturnValue("FN-001"),
dropEffect: "move",
};
fireEvent.drop(columnEl, { dataTransfer });
expect(onMoveTask).toHaveBeenCalledWith("FN-001", "in-review", undefined);
});
describe("favorite model prop forwarding (FN-770)", () => {
it("forwards favoriteProviders, favoriteModels, and toggle callbacks to QuickEntryBox", () => {
@@ -1407,7 +1309,6 @@ describe("Column same-column drop", () => {
expect(quickEntry.getAttribute("data-has-toggle-model-favorite")).toBe("no");
});
});
});
describe("Column PluginSlot integration", () => {
it("renders PluginSlot for board-column-footer", () => {

View File

@@ -11,7 +11,7 @@ The mock records each TaskCard mount so tests can assert row identity stability:
const { taskCardMountLog } = vi.hoisted(() => ({ taskCardMountLog: [] as string[] }));
vi.mock("../TaskCard", () => ({
TaskCard: ({ task, taskColumnFlags, onOpenDetail, onDeleteTask, onReviseTask, disableDrag }: { task: Task | TaskDetail; taskColumnFlags?: { complete?: boolean }; onOpenDetail: (task: Task | TaskDetail) => void; onDeleteTask?: (id: string) => Promise<Task>; onReviseTask?: (task: Task) => void; disableDrag?: boolean }) => {
TaskCard: ({ task, taskColumnFlags, onOpenDetail, onDeleteTask, onReviseTask }: { task: Task | TaskDetail; taskColumnFlags?: { complete?: boolean }; onOpenDetail: (task: Task | TaskDetail) => void; onDeleteTask?: (id: string) => Promise<Task>; onReviseTask?: (task: Task) => void }) => {
useEffect(() => {
taskCardMountLog.push(task.id);
}, []);
@@ -19,7 +19,6 @@ vi.mock("../TaskCard", () => ({
<button
type="button"
data-testid={`mock-task-card-${task.id}`}
data-disable-drag={String(disableDrag)}
data-has-delete={String(Boolean(onDeleteTask))}
data-complete={String(taskColumnFlags?.complete === true)}
onClick={() => onOpenDetail(task)}
@@ -100,8 +99,6 @@ describe("DockTaskList", () => {
expect(screen.getByTestId("dock-task-list")).toBeInTheDocument();
expect(screen.getByTestId("dock-task-list-row-FN-1")).toBeInTheDocument();
expect(screen.getByTestId("dock-task-list-row-FN-2")).toBeInTheDocument();
expect(screen.getByTestId("mock-task-card-FN-1")).toHaveAttribute("data-disable-drag", "true");
fireEvent.click(screen.getByTestId("mock-task-card-FN-2"));
expect(onOpenTask).toHaveBeenCalledTimes(1);
expect(onOpenTask).toHaveBeenCalledWith(second);

View File

@@ -74,8 +74,6 @@ const baseProps = () => ({
maxConcurrent: 2,
onMoveTask: vi.fn().mockResolvedValue({} as Task),
onPromote: vi.fn().mockResolvedValue(undefined),
canDropTask: vi.fn().mockReturnValue(null),
getDraggingTaskId: vi.fn().mockReturnValue(null),
onOpenDetail: vi.fn(),
addToast: vi.fn(),
});
@@ -202,31 +200,7 @@ describe("Lane", () => {
await waitFor(() => expect((screen.getByTestId("card-promote-FN-8") as HTMLButtonElement).disabled).toBe(false));
});
it("prevents the drop (no-move) when canDropTask returns a rejection key", () => {
const props = baseProps();
props.getDraggingTaskId = vi.fn().mockReturnValue("FN-DRAG");
props.canDropTask = vi.fn().mockReturnValue("board.rejection.workflowMismatch");
render(<Lane {...props} tasks={[mkTask({ id: "FN-1", column: "in-progress" })]} />);
const ipColumn = document.querySelector('[data-column="in-progress"]') as HTMLElement;
const preventDefault = vi.fn();
fireEvent.dragOver(ipColumn, { dataTransfer: { dropEffect: "" }, preventDefault });
// Rejection → preventDefault NOT called → the browser refuses the drop.
expect(props.canDropTask).toHaveBeenCalledWith("FN-DRAG", "in-progress", "builtin:coding");
// Inline feedback surfaces the translated rejection.
expect(screen.getByTestId("column-inline-feedback")).toBeDefined();
});
it("allows the drop (preventDefault) when canDropTask returns null", () => {
const props = baseProps();
props.getDraggingTaskId = vi.fn().mockReturnValue("FN-DRAG");
props.canDropTask = vi.fn().mockReturnValue(null);
render(<Lane {...props} tasks={[mkTask({ id: "FN-1", column: "in-progress" })]} />);
const ipColumn = document.querySelector('[data-column="in-progress"]') as HTMLElement;
// fireEvent.dragOver returns false when a handler called preventDefault.
const notPrevented = fireEvent.dragOver(ipColumn, { dataTransfer: { dropEffect: "" } });
expect(notPrevented).toBe(false);
expect(screen.queryByTestId("column-inline-feedback")).toBeNull();
});
});
/*

View File

@@ -1207,6 +1207,14 @@ describe("ListView", () => {
expect(screen.getByRole("menu")).toBeInTheDocument();
expect(screen.getByRole("menuitem", { name: "Retry" })).toBeInTheDocument();
expect(screen.getByRole("menuitem", { name: "Pause" })).toBeInTheDocument();
/*
FNXC:TaskMovementContextMenu 2026-08-19-18:37:
When a task has several legal destinations, list movement is grouped under one
accessible Move to entry instead of presenting a noisy run of sibling actions.
*/
expect(screen.getByRole("menuitem", { name: "Move to" })).toBeInTheDocument();
expect(screen.queryByRole("menuitem", { name: "Move to In progress" })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("menuitem", { name: "Move to" }));
expect(screen.getByRole("menuitem", { name: "Move to In progress" })).toBeInTheDocument();
expect(failedRow).not.toHaveClass("list-row--selected");
expect(onOpenDetail).not.toHaveBeenCalled();
@@ -1228,7 +1236,7 @@ describe("ListView", () => {
read "Back to In Progress" — they go through the no-metadata fallback, which uses
the legacy column label map.
*/
expect(screen.getByRole("menuitem", { name: "Back to In progress" })).toBeInTheDocument();
expect(screen.getByRole("menuitem", { name: "Move to", exact: true })).toBeInTheDocument();
fireEvent.contextMenu(document.querySelector('.list-row[data-id="FN-006"]') as HTMLElement, { clientX: 40, clientY: 50 });
expect(screen.getByRole("menuitem", { name: "Merge & Close" })).toBeInTheDocument();
@@ -1257,6 +1265,7 @@ describe("ListView", () => {
mockConfirm.mockResolvedValueOnce(true);
fireEvent.contextMenu(document.querySelector('.list-row[data-id="FN-007"]') as HTMLElement, { clientX: 40, clientY: 50 });
fireEvent.click(screen.getByRole("menuitem", { name: "Move to" }));
fireEvent.click(screen.getByRole("menuitem", { name: "Move to Todo" }));
await waitFor(() => expect(onMoveTask).toHaveBeenCalledWith("FN-007", "todo", { preserveProgress: true }));
@@ -3343,174 +3352,7 @@ describe("ListView", () => {
expect(todoZone?.textContent).toContain("1");
});
it("handles drag and drop to move tasks between columns", async () => {
const tasks = [createMockTask({ id: "FN-001", column: "triage" })];
const mockOnMoveTask = vi.fn(() => Promise.resolve(tasks[0]));
renderListView({ tasks, onMoveTask: mockOnMoveTask });
const row = screen.getByText("FN-001").closest("tr")!;
// Simulate drag start
fireEvent.dragStart(row, {
dataTransfer: {
setData: vi.fn(),
effectAllowed: "move",
},
});
// Simulate drop on todo column drop zone (use querySelector for specificity)
const todoZone = document.querySelector('[data-column="todo"].list-drop-zone')!;
fireEvent.dragOver(todoZone, {
preventDefault: vi.fn(),
dataTransfer: { dropEffect: "move" },
});
fireEvent.drop(todoZone, {
preventDefault: vi.fn(),
dataTransfer: {
getData: vi.fn(() => "FN-001"),
},
});
await waitFor(() => {
expect(mockOnMoveTask).toHaveBeenCalledWith("FN-001", "todo", undefined);
});
});
it("prompts to preserve progress when dropping task with completed steps to todo", async () => {
const tasks = [createMockTask({
id: "FN-001",
column: "in-progress",
steps: [
{ title: "Step 1", status: "done" },
{ title: "Step 2", status: "pending" },
],
})];
const mockOnMoveTask = vi.fn(() => Promise.resolve(tasks[0]));
mockConfirm.mockResolvedValueOnce(true);
renderListView({ tasks, onMoveTask: mockOnMoveTask });
const row = screen.getByText("FN-001").closest("tr")!;
fireEvent.dragStart(row, {
dataTransfer: {
setData: vi.fn(),
effectAllowed: "move",
},
});
const todoZone = document.querySelector('[data-column="todo"].list-drop-zone')!;
fireEvent.drop(todoZone, {
preventDefault: vi.fn(),
dataTransfer: {
getData: vi.fn(() => "FN-001"),
},
});
await waitFor(() => {
expect(mockConfirm).toHaveBeenCalledWith(expect.objectContaining({
title: "Preserve Progress?",
cancelLabel: "Reset Progress",
}));
expect(mockOnMoveTask).toHaveBeenCalledWith("FN-001", "todo", { preserveProgress: true });
});
});
it("prompts to preserve progress when dropping task with completed steps to a workflow hold column", async () => {
const tasks = [createMockTask({
id: "FN-001",
column: "doing",
steps: [
{ title: "Step 1", status: "done" },
{ title: "Step 2", status: "pending" },
],
})];
const mockOnMoveTask = vi.fn(() => Promise.resolve(tasks[0]));
mockConfirm.mockResolvedValueOnce(true);
vi.mocked(fetchBoardWorkflows).mockResolvedValue({
flagEnabled: true,
defaultWorkflowId: "wf-custom",
workflows: [
{
id: "wf-custom",
name: "Custom",
columns: [
{ id: "queue", name: "Queue", flags: { hold: true } },
{ id: "doing", name: "Doing", flags: { countsTowardWip: true } },
{ id: "shipped", name: "Shipped", flags: { complete: true } },
],
},
],
taskWorkflowIds: { "FN-001": "wf-custom" },
});
renderListView({ tasks, onMoveTask: mockOnMoveTask });
await waitFor(() => expect(document.querySelector('[data-column="queue"].list-drop-zone')).toBeTruthy());
fireEvent.drop(document.querySelector('[data-column="queue"].list-drop-zone')!, {
preventDefault: vi.fn(),
dataTransfer: {
getData: vi.fn(() => "FN-001"),
},
});
await waitFor(() => {
expect(mockConfirm).toHaveBeenCalledWith(expect.objectContaining({
title: "Preserve Progress?",
}));
expect(mockOnMoveTask).toHaveBeenCalledWith("FN-001", "queue", { preserveProgress: true });
});
});
it("does not set draggable for paused tasks", () => {
const tasks = [createMockTask({ id: "FN-001", paused: true })];
renderListView({ tasks });
const row = screen.getByText("FN-001").closest("tr")!;
// Paused tasks should have draggable="false"
expect(row.getAttribute("draggable")).toBe("false");
});
it("sets draggable for non-paused tasks", () => {
const tasks = [createMockTask({ id: "FN-001", paused: false })];
renderListView({ tasks });
const row = screen.getByText("FN-001").closest("tr")!;
// Non-paused tasks should have draggable="true"
expect(row.getAttribute("draggable")).toBe("true");
});
it("shows error toast when onMoveTask fails during drag and drop", async () => {
const tasks = [createMockTask({ id: "FN-001", column: "triage" })];
const mockOnMoveTask = vi.fn(() => Promise.reject(new Error("Move failed")));
renderListView({ tasks, onMoveTask: mockOnMoveTask });
const row = screen.getByText("FN-001").closest("tr")!;
fireEvent.dragStart(row, {
dataTransfer: {
setData: vi.fn(),
effectAllowed: "move",
},
});
// Use querySelector to find the specific drop zone
const todoZone = document.querySelector('[data-column="todo"].list-drop-zone')!;
fireEvent.drop(todoZone, {
preventDefault: vi.fn(),
dataTransfer: {
getData: vi.fn(() => "FN-001"),
},
});
await waitFor(() => {
expect(mockAddToast).toHaveBeenCalledWith("Move failed", "error");
});
});
it("displays full description in title cell when no title exists", () => {
const longDescription = "A".repeat(100);

View File

@@ -999,6 +999,7 @@ describe("TaskCard", () => {
);
fireEvent.contextMenu(document.querySelector(".card")!, { clientX: 24, clientY: 28 });
fireEvent.click(screen.getByRole("menuitem", { name: "Move to" }));
fireEvent.click(screen.getByRole("menuitem", { name: "Move to Todo" }));
await waitFor(() => expect(onMoveTask).toHaveBeenCalledWith("FN-001", "todo", { preserveProgress: true }));
@@ -1945,43 +1946,14 @@ describe("TaskCard", () => {
expect(screen.queryByTestId("branch-progress-badge")).toBeNull();
});
it("keeps native card dragging enabled by default", () => {
/*
FNXC:TaskMovementContextMenu 2026-08-19-18:37:
Task cards must never expose native HTML drag behavior. Movement is intentional
context-menu work, which keeps board and touch input paths consistent.
*/
it("does not expose native card dragging", () => {
const { container } = render(<TaskCard task={makeTask()} onOpenDetail={noop} addToast={noop} />);
const card = container.querySelector(".card") as HTMLElement;
expect(card.getAttribute("draggable")).toBe("true");
});
it("disables native card dragging when disableDrag is true", () => {
const { container } = render(<TaskCard task={makeTask()} onOpenDetail={noop} addToast={noop} disableDrag={true} />);
const card = container.querySelector(".card") as HTMLElement;
expect(card.getAttribute("draggable")).toBe("false");
});
// FN-6389 follow-up: native HTML5 drag is desktop-mouse only and doesn't move
// cards via touch, but a `draggable` element still arms the browser's touch-drag
// heuristic, which intermittently hijacks horizontal swipes meant to scroll the
// mobile board. On touch-primary (coarse pointer) devices we drop `draggable`.
it("disables native card dragging on touch-primary (coarse pointer) devices", () => {
const original = window.matchMedia;
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
matches: query === "(hover: none) and (pointer: coarse)",
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})) as unknown as typeof window.matchMedia;
try {
const { container } = render(<TaskCard task={makeTask()} onOpenDetail={noop} addToast={noop} />);
const card = container.querySelector(".card") as HTMLElement;
expect(card.getAttribute("draggable")).toBe("false");
// No drag-start handler should be wired on touch (would arm the heuristic).
const dragStart = new Event("dragstart", { bubbles: true, cancelable: true });
const prevented = !card.dispatchEvent(dragStart);
expect(prevented).toBe(false);
} finally {
window.matchMedia = original;
}
expect(container.querySelector(".card")).not.toHaveAttribute("draggable");
});
it.each([
@@ -4815,6 +4787,7 @@ describe("TaskCard", () => {
expect(screen.queryByRole("button", { name: "Send back" })).toBeNull();
fireEvent.click(screen.getByTestId("card-menu-btn-FN-001"));
fireEvent.click(screen.getByRole("menuitem", { name: "Move to", exact: true }));
expect(screen.getByRole("menuitem", { name: "Done (no merge)" })).toBeTruthy();
expect(screen.getByRole("menuitem", { name: "Move to Planning" })).toBeTruthy();
@@ -4846,6 +4819,7 @@ describe("TaskCard", () => {
);
fireEvent.click(screen.getByTestId("card-menu-btn-FN-001"));
fireEvent.click(screen.getByRole("menuitem", { name: "Move to", exact: true }));
const planningMoves = screen.getAllByRole("menuitem", { name: "Move to Planning" });
expect(planningMoves).toHaveLength(1);
@@ -4856,6 +4830,7 @@ describe("TaskCard", () => {
await waitFor(() => expect(screen.queryByRole("menu")).not.toBeInTheDocument());
fireEvent.contextMenu(document.querySelector(".card")!, { clientX: 24, clientY: 28 });
fireEvent.click(await screen.findByRole("menuitem", { name: "Move to", exact: true }));
await waitFor(() => expect(screen.getAllByRole("menuitem", { name: "Move to Planning" })).toHaveLength(1));
expect(screen.getByRole("menuitem", { name: "Move to Done" })).toBeTruthy();
});
@@ -4880,6 +4855,7 @@ describe("TaskCard", () => {
);
fireEvent.click(screen.getByTestId("card-menu-btn-FN-001"));
fireEvent.click(screen.getByRole("menuitem", { name: "Move to", exact: true }));
const planningMoves = screen.getAllByRole("menuitem", { name: "Move to Planning" });
expect(planningMoves).toHaveLength(1);
fireEvent.click(planningMoves[0]);
@@ -4895,6 +4871,7 @@ describe("TaskCard", () => {
/>,
);
fireEvent.click(screen.getByTestId("card-menu-btn-FN-001"));
fireEvent.click(screen.getByRole("menuitem", { name: "Move to", exact: true }));
expect(screen.queryByRole("menuitem", { name: "Done (no merge)" })).toBeNull();
expect(screen.getAllByRole("menuitem", { name: "Move to Planning" })).toHaveLength(1);
});
@@ -4916,6 +4893,7 @@ describe("TaskCard", () => {
);
fireEvent.click(screen.getByTestId("card-menu-btn-FN-001"));
fireEvent.click(screen.getByRole("menuitem", { name: "Move to", exact: true }));
expect(screen.getByRole("menuitem", { name: "Move to Planning" })).toBeTruthy();
expect(screen.getByRole("menuitem", { name: "Move to Done" })).toBeTruthy();
expect(screen.queryByRole("menuitem", { name: "Done (no merge)" })).toBeNull();
@@ -4935,6 +4913,7 @@ describe("TaskCard", () => {
expect(screen.queryByRole("button", { name: "Send back" })).toBeNull();
fireEvent.click(screen.getByTestId("card-menu-btn-FN-001"));
fireEvent.click(screen.getByRole("menuitem", { name: "Move to", exact: true }));
expect(screen.getByRole("menuitem", { name: "Move to Todo" })).toBeTruthy();
expect(screen.getByRole("menuitem", { name: "Move to Planning" })).toBeTruthy();
@@ -7653,16 +7632,7 @@ describe("TaskCard memo comparator provenance behavior", () => {
).toBe(false);
});
it("returns false when disableDrag changes", () => {
const task = makeTask();
expect(
__test_areTaskCardPropsEqual(
{ task, onOpenDetail: noop, addToast: noop, disableDrag: false } as any,
{ task, onOpenDetail: noop, addToast: noop, disableDrag: true } as any,
),
).toBe(false);
});
it("returns false when board context-menu action handlers change", () => {
const task = makeTask();

View File

@@ -2,7 +2,7 @@ import React from "react";
import { describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import type { Task } from "@fusion/core";
import { TaskContextMenu, buildTaskActionMenuModel } from "../TaskContextMenu";
import { TaskContextMenu, buildTaskActionMenuModel, buildTaskMoveMenuItems } from "../TaskContextMenu";
const t = ((key: string, fallback: string, vars?: Record<string, string>) => {
if (!vars) return fallback;
@@ -332,6 +332,59 @@ describe("TaskContextMenu shared task action model", () => {
fireEvent.keyDown(screen.getByRole("menu"), { key: "ArrowDown" });
expect(pause).toHaveFocus();
});
/*
FNXC:TaskCardMovement 2026-08-19-18:35:
Multiple contextual transitions must use one expandable parent rather than flattening repeated
Move to actions. The hierarchy is tested at the shared menu seam so Board and List stay aligned.
*/
it("groups unique multiple moves in one accessible submenu and dispatches a child once", () => {
const onMove = vi.fn();
const items = buildTaskMoveMenuItems([
{ column: "todo", label: "Move to Todo", primaryLabel: "Move to Todo" },
{ column: "in-review", label: "Move to Review", primaryLabel: "Move to Review" },
{ column: "todo", label: "Duplicate should be ignored", primaryLabel: "Duplicate should be ignored" },
], onMove, "Move to");
expect(items).toHaveLength(1);
expect("items" in items[0] && items[0].items.map((item) => item.id)).toEqual(["move-todo", "move-in-review"]);
render(<TaskContextMenu actions={items} />);
const parent = screen.getByRole("menuitem", { name: "Move to", exact: true });
fireEvent.keyDown(parent, { key: "ArrowRight" });
const todo = screen.getByRole("menuitem", { name: "Move to Todo" });
expect(todo).toHaveFocus();
fireEvent.pointerUp(todo, { pointerType: "touch", pointerId: 1 });
fireEvent.click(todo);
expect(onMove).toHaveBeenCalledTimes(1);
expect(onMove).toHaveBeenCalledWith("todo");
});
it("opens a Move to submenu left when its right edge would overflow the viewport", () => {
const rectSpy = vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue({
right: window.innerWidth + 1,
} as DOMRect);
const items = buildTaskMoveMenuItems([
{ column: "todo", label: "Move to Todo", primaryLabel: "Move to Todo" },
{ column: "in-review", label: "Move to Review", primaryLabel: "Move to Review" },
], vi.fn(), "Move to");
render(<TaskContextMenu actions={items} />);
const parent = screen.getByRole("menuitem", { name: "Move to", exact: true });
fireEvent.click(parent);
expect(parent.parentElement?.querySelector(".task-context-menu__submenu"))
.toHaveClass("task-context-menu__submenu--opens-left");
rectSpy.mockRestore();
});
it("keeps zero moves absent and a single move direct", () => {
expect(buildTaskMoveMenuItems([], vi.fn(), "Move to")).toEqual([]);
const [item] = buildTaskMoveMenuItems([
{ column: "todo", label: "Move to Todo", primaryLabel: "Move to Todo" },
], vi.fn(), "Move to");
expect(item).toMatchObject({ id: "move-todo", label: "Move to Todo" });
expect("items" in item).toBe(false);
});
});
/*

View File

@@ -1,56 +0,0 @@
import type { Task } from "@fusion/core";
import type { BoardWorkflowsPayload } from "../api";
export interface BoardCanDropTaskInput {
boardWorkflows: BoardWorkflowsPayload | null | undefined;
tasks: Task[];
maxConcurrent: number;
taskId: string;
targetColumnId: string;
laneWorkflowId: string;
}
/**
* Canonical Board drag pre-check (R17). Deterministic rejections return a stable
* i18n message key; `null` means the pre-check allows the drop or cannot decide.
*/
export function getBoardCanDropTaskRejection({
boardWorkflows,
tasks,
maxConcurrent,
taskId,
targetColumnId,
laneWorkflowId,
}: BoardCanDropTaskInput): string | null {
if (!boardWorkflows) return null;
const sourceTask = tasks.find((task) => task.id === taskId);
if (!sourceTask) return null;
const sourceWorkflowId = boardWorkflows.taskWorkflowIds[taskId] ?? boardWorkflows.defaultWorkflowId;
// Cross-lane drag never switches workflows (R17).
if (sourceWorkflowId !== laneWorkflowId) {
return "board.rejection.workflowMismatch";
}
const workflow = boardWorkflows.workflows.find((candidate) => candidate.id === laneWorkflowId);
if (!workflow) return null;
const targetColumn = workflow.columns.find((column) => column.id === targetColumnId);
if (!targetColumn) return "board.rejection.unknownColumn";
// Capacity pre-check: a wip-flagged column that is already full rejects.
if (targetColumn.flags.countsTowardWip) {
const occupants = tasks.filter(
(task) => task.column === targetColumnId
&& (boardWorkflows.taskWorkflowIds[task.id] ?? boardWorkflows.defaultWorkflowId) === laneWorkflowId,
).length;
// The default workflow's in-progress limit is maxConcurrent; custom limits
// are enforced authoritatively server-side (the 409 fallback still snaps back).
if (Number.isFinite(maxConcurrent) && maxConcurrent > 0 && sourceTask.column !== targetColumnId && occupants >= maxConcurrent) {
return "board.rejection.capacityExhausted";
}
}
return null;
}

View File

@@ -447,7 +447,6 @@ export function MainContent({
projectId={currentProject?.id}
onOpenDetail={openPluginTaskDetail}
addToast={addToast}
disableDrag={true}
prAuthAvailable={prAuthAvailable}
autoMergeEnabled={autoMerge}
nearDuplicateCanonicalInactive={typeof task.sourceMetadata?.nearDuplicateOf === "string"

View File

@@ -186,7 +186,6 @@ export function useRightDockController(input: RightDockControllerInput): RightDo
onOpenDetail={(value: Task | TaskDetail) => input.openDetailTask(value)}
onDeleteTask={input.onDeleteTask}
addToast={input.addToast}
disableDrag={true}
prAuthAvailable={input.prAuthAvailable}
autoMergeEnabled={input.autoMerge}
nearDuplicateCanonicalInactive={typeof task.sourceMetadata?.nearDuplicateOf === "string"

View File

@@ -707,15 +707,6 @@ html *::after {
box-shadow var(--transition-normal);
}
html .card.dragging,
html .card.dragging *,
html .list-row.dragging,
html .list-row.dragging *,
html .column.drag-over,
html .column.drag-over * {
transition: none !important;
}
[data-theme="light"] .agent-dialog-overlay,
[data-theme="light"] .chat-new-dialog-backdrop {
background: transparent;
@@ -1188,11 +1179,6 @@ rather than relying on the absence of a declaration.
scroll-snap-align: center;
}
.column.drag-over {
border-color: var(--todo);
box-shadow: inset 0 0 0 1px var(--todo);
}
.column-header {
display: flex;
align-items: center;