From 3fd4bdbc69d1b0efa3701dc4a94f711bc1b0e6a6 Mon Sep 17 00:00:00 2001 From: Fusion Agent Date: Wed, 19 Aug 2026 19:08:44 +0000 Subject: [PATCH] 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 --- .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 +----------- .../dashboard/app/components/DockTaskList.tsx | 3 +- packages/dashboard/app/components/Lane.tsx | 9 - .../dashboard/app/components/ListView.css | 12 +- .../dashboard/app/components/ListView.tsx | 132 +---------- .../dashboard/app/components/TaskCard.css | 8 - .../dashboard/app/components/TaskCard.tsx | 58 ++--- .../app/components/TaskContextMenu.css | 37 +++ .../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 +-------- .../__tests__/DockTaskList.test.tsx | 5 +- .../app/components/__tests__/Lane.test.tsx | 26 --- .../components/__tests__/ListView.test.tsx | 178 +-------------- .../components/__tests__/TaskCard.test.tsx | 60 ++--- .../__tests__/TaskContextMenu.test.tsx | 55 ++++- .../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(-) create mode 100644 .changeset/fn-051-task-card-moves.md delete mode 100644 packages/dashboard/app/components/__tests__/Board.canDropTask.test.tsx delete mode 100644 packages/dashboard/app/components/boardCanDropTask.ts diff --git a/.changeset/fn-051-task-card-moves.md b/.changeset/fn-051-task-card-moves.md new file mode 100644 index 0000000000..9d00c3c55d --- /dev/null +++ b/.changeset/fn-051-task-card-moves.md @@ -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. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 8dbd0f3c46..ae3d950708 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -324,9 +324,10 @@ Board view is the kanban surface for day-to-day operation. Features: -- Drag-and-drop between lifecycle columns + +- 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. -- 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 diff --git a/docs/task-management.md b/docs/task-management.md index cc80898a00..506007b7de 100644 --- a/docs/task-management.md +++ b/docs/task-management.md @@ -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). + + +### 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 diff --git a/packages/dashboard/app/components/Board.tsx b/packages/dashboard/app/components/Board.tsx index 785344c4e8..1ed6c43b4f 100644 --- a/packages/dashboard/app/components/Board.tsx +++ b/packages/dashboard/app/components/Board.tsx @@ -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(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 | 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 /> ))} @@ -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 && (

{t("tasks.revertedTasks", "Reverted Tasks")}

- {partitionRevertedTasks(selectedWorkflowTasks).reverted.map((task) => )} + {partitionRevertedTasks(selectedWorkflowTasks).reverted.map((task) => )}
)} {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} diff --git a/packages/dashboard/app/components/Column.tsx b/packages/dashboard/app/components/Column.tsx index 1d332ab8fa..465eb362a5 100644 --- a/packages/dashboard/app/components/Column.tsx +++ b/packages/dashboard/app/components/Column.tsx @@ -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; - /** - * 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 (
diff --git a/packages/dashboard/app/components/DockTaskList.tsx b/packages/dashboard/app/components/DockTaskList.tsx index d8b17fd6fd..7376da64c2 100644 --- a/packages/dashboard/app/components/DockTaskList.tsx +++ b/packages/dashboard/app/components/DockTaskList.tsx @@ -113,7 +113,7 @@ export function DockTaskList({ columnFlagsByTaskId, {revertedTasks.length > 0 && (

{t("tasks.revertedTasks", "Reverted Tasks")}

- {revertedTasks.map((task) => )} + {revertedTasks.map((task) => )}
)} {isEmpty ? ( @@ -139,7 +139,6 @@ export function DockTaskList({ columnFlagsByTaskId, */ onDeleteTask={onDeleteTask} addToast={addToast} - disableDrag={true} prAuthAvailable={prAuthAvailable} autoMergeEnabled={autoMergeEnabled} /> diff --git a/packages/dashboard/app/components/Lane.tsx b/packages/dashboard/app/components/Lane.tsx index e5b9a81cb7..10074e1545 100644 --- a/packages/dashboard/app/components/Lane.tsx +++ b/packages/dashboard/app/components/Lane.tsx @@ -33,9 +33,6 @@ export interface LaneProps { showWorktreeGrouping?: boolean; onMoveTask: (id: string, column: ColumnId, optionsOrPosition?: { preserveProgress?: boolean } | number) => Promise; onPromote: (taskId: string) => Promise; - /** 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; 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 (
@@ -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} diff --git a/packages/dashboard/app/components/ListView.css b/packages/dashboard/app/components/ListView.css index f8ee2b5e84..d81b040e8c 100644 --- a/packages/dashboard/app/components/ListView.css +++ b/packages/dashboard/app/components/ListView.css @@ -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); } diff --git a/packages/dashboard/app/components/ListView.tsx b/packages/dashboard/app/components/ListView.tsx index b17c5c47e7..a80b8cda00 100644 --- a/packages/dashboard/app/components/ListView.tsx +++ b/packages/dashboard/app/components/ListView.tsx @@ -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(null); const [sortDirection, setSortDirection] = useState("asc"); - const [draggingTaskId, setDraggingTaskId] = useState(null); - const [dragOverColumn, setDragOverColumn] = useState(null); const [selectedColumn, setSelectedColumn] = useState(null); const [contextMenuState, setContextMenuState] = useState(null); const [prCreateState, setPrCreateState] = useState(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 ; return sortDirection === "asc" ? ( @@ -2765,11 +2664,8 @@ export function ListView({ return (
handleColumnFilter(column)} - onDragOver={(e) => handleColumnDragOver(e, column)} - onDragLeave={handleColumnDragLeave} - onDrop={(e) => handleColumnDrop(e, column)} data-column={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 ( 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" diff --git a/packages/dashboard/app/components/TaskCard.css b/packages/dashboard/app/components/TaskCard.css index 7526019715..b7474eb7c3 100644 --- a/packages/dashboard/app/components/TaskCard.css +++ b/packages/dashboard/app/components/TaskCard.css @@ -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); diff --git a/packages/dashboard/app/components/TaskCard.tsx b/packages/dashboard/app/components/TaskCard.tsx index 105fa19593..4718de2019 100644 --- a/packages/dashboard/app/components/TaskCard.tsx +++ b/packages/dashboard/app/components/TaskCard.tsx @@ -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(() => { + const contextMenuActions = useMemo(() => { 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} diff --git a/packages/dashboard/app/components/TaskContextMenu.css b/packages/dashboard/app/components/TaskContextMenu.css index a44c463fa8..e000e0d285 100644 --- a/packages/dashboard/app/components/TaskContextMenu.css +++ b/packages/dashboard/app/components/TaskContextMenu.css @@ -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)); diff --git a/packages/dashboard/app/components/TaskContextMenu.tsx b/packages/dashboard/app/components/TaskContextMenu.tsx index 345239cb15..e6ca790ef7 100644 --- a/packages/dashboard/app/components/TaskContextMenu.tsx +++ b/packages/dashboard/app/components/TaskContextMenu.tsx @@ -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(); + 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, @@ -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(null); const touchSelectedActionRef = useRef<{ id: string; at: number } | null>(null); + const submenuRef = useRef(null); + const [openSubmenuId, setOpenSubmenuId] = useState(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(`[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) => { + const activeSubmenu = (document.activeElement as HTMLElement | null)?.closest("[data-task-submenu]"); + if (event.key === "Escape" && activeSubmenu) { + event.preventDefault(); + event.stopPropagation(); + setOpenSubmenuId(null); + menuRef.current?.querySelector(`[data-task-submenu-toggle="${activeSubmenu.dataset.taskSubmenu}"]`)?.focus(); + return; + } + if (event.key === "ArrowLeft" && activeSubmenu) { + event.preventDefault(); + setOpenSubmenuId(null); + menuRef.current?.querySelector(`[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("button:not(:disabled)") ?? []); if (items.length === 0) return; @@ -662,29 +733,65 @@ export function TaskContextMenu({ return (
- {actions.map((action) => { + {actions.map((item) => { + if ("items" in item) { + const isOpen = openSubmenuId === item.id; + return ( +
+ + {isOpen && ( +
+ {item.items.map((action) => { + const classes = [itemClassName, "task-context-menu__submenu-item"]; + if (action.tone === "danger") classes.push(dangerItemClassName); + return ( + + ); + })} +
+ )} +
+ ); + } + 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" ? ( - - {action.label} - + {action.label} ) : ( - + ); - return {renderAction ? renderAction(action, defaultNode) : defaultNode}; })}
diff --git a/packages/dashboard/app/components/__tests__/Board.canDropTask.test.tsx b/packages/dashboard/app/components/__tests__/Board.canDropTask.test.tsx deleted file mode 100644 index bbe19f8f03..0000000000 --- a/packages/dashboard/app/components/__tests__/Board.canDropTask.test.tsx +++ /dev/null @@ -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 in the -// selected-workflow rendering path, while 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 = {}): 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(); - }); -}); diff --git a/packages/dashboard/app/components/__tests__/Board.test.tsx b/packages/dashboard/app/components/__tests__/Board.test.tsx index f752670715..39ae4c5add 100644 --- a/packages/dashboard/app/components/__tests__/Board.test.tsx +++ b/packages/dashboard/app/components/__tests__/Board.test.tsx @@ -118,7 +118,7 @@ vi.mock("../Column", () => ({ ) : null} {tasks.map((task) => ( -
+
{task.title ?? task.description ?? task.id}
))} @@ -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" }); diff --git a/packages/dashboard/app/components/__tests__/Column.test.tsx b/packages/dashboard/app/components/__tests__/Column.test.tsx index 91fc427f86..8b79449afb 100644 --- a/packages/dashboard/app/components/__tests__/Column.test.tsx +++ b/packages/dashboard/app/components/__tests__/Column.test.tsx @@ -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( - , - ); - 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(); - 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(); - - 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(); - - 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(); - - 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", () => { diff --git a/packages/dashboard/app/components/__tests__/DockTaskList.test.tsx b/packages/dashboard/app/components/__tests__/DockTaskList.test.tsx index 91fed6f489..7bedbfd0b7 100644 --- a/packages/dashboard/app/components/__tests__/DockTaskList.test.tsx +++ b/packages/dashboard/app/components/__tests__/DockTaskList.test.tsx @@ -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; 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; onReviseTask?: (task: Task) => void }) => { useEffect(() => { taskCardMountLog.push(task.id); }, []); @@ -19,7 +19,6 @@ vi.mock("../TaskCard", () => ({