diff --git a/.changeset/fn-7255-card-context-menu.md b/.changeset/fn-7255-card-context-menu.md new file mode 100644 index 0000000000..36bc5cac13 --- /dev/null +++ b/.changeset/fn-7255-card-context-menu.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add task context menus on board and list cards. +category: feature +dev: Board TaskCard and ListView row/card surfaces now support right-click, keyboard, and touch long-press action menus. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 836aa04fbd..0073476764 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -139,6 +139,9 @@ Features: - GitHub provenance marker on task cards imported from GitHub (`sourceType: github_import`), shown in the footer with other external-source metadata - Task card header meta badges group priority, fast mode, agent-created provenance, workflow name, and elapsed/created-time chips into one wrapping row; agent labels prefer `sourceMetadata.agentName` over raw agent IDs - Task detail surfaces show the selected/effective workflow name near the task's workflow controls so individual cards remain understandable when Board is in **All workflows** or another aggregate/mixed context. +- Board task cards support a context menu from right-click, keyboard context menu / Shift+F10, or touch long-press for detail-aligned lifecycle actions without changing normal card clicks. Actions that require additional detail-only UI, such as opening the refinement feedback modal, remain available from task detail. + @@ -169,6 +172,9 @@ Features: - Bulk selection + batch model updates - Bulk Pause / Unpause / Archive actions from the selection toolbar (`Pause selected`, `Unpause selected`, `Archive selected`) for fast batch task state management. - Bulk delete from the selection toolbar (`Delete selected`): archived selections are skipped automatically, and dependency-conflict failures can be force-deleted per task after a danger confirmation that removes dependency references. +- List rows and mobile cards support the same task context menu as Board cards from right-click, keyboard context menu / Shift+F10, or touch long-press without changing ordinary row selection or tap-to-open behavior. + ![List view](./screenshots/list-view.png) diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index c20fdc2025..37f219f47b 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -550,6 +550,7 @@ function AppInner() { const { maxConcurrent, autoMerge, + mergeStrategy, showWorktreeGrouping, globalPaused, isTestMode, @@ -1216,6 +1217,7 @@ function AppInner() { openFileInBrowser, prAuthAvailable, autoMerge, + mergeStrategy, settingsLoaded, skillsEnabled, experimentalFeatures, diff --git a/packages/dashboard/app/components/Board.tsx b/packages/dashboard/app/components/Board.tsx index de0144e3cc..d8c7a4732c 100644 --- a/packages/dashboard/app/components/Board.tsx +++ b/packages/dashboard/app/components/Board.tsx @@ -1,4 +1,4 @@ -import type { Task, TaskDetail, Column as ColumnType, TaskCreateInput, GithubIssueAction } from "@fusion/core"; +import type { Task, TaskDetail, Column as ColumnType, ColumnId, TaskCreateInput, GithubIssueAction, MergeResult } from "@fusion/core"; import { COLUMNS, DEFAULT_COLUMN, isColumn } from "@fusion/core"; import { sortTasksForDisplayColumn, type DoneColumnSortMode } from "./taskSorting"; import { Column } from "./Column"; @@ -16,20 +16,27 @@ import { WorkflowSwitcher } from "./WorkflowSwitcher"; import { computeWorkflowStatusCounts, type WorkflowStatusCounts } from "./workflowStatusCounts"; import { writeBoardWorkflowsCache } from "../utils/boardWorkflowsCache"; import { useBoardWorkflows } from "../hooks/useBoardWorkflows"; +import type { TaskContextMenuColumnMetadata } from "./TaskContextMenu"; interface BoardProps { tasks: Task[]; projectId?: string; maxConcurrent: number; showWorktreeGrouping: boolean; - onMoveTask: (id: string, column: ColumnType) => Promise; + onMoveTask: (id: string, column: ColumnId) => Promise; onPauseTask?: (id: string) => Promise; + onUnpauseTask?: (id: string) => Promise; + onResetTask?: (id: string) => Promise; + onDuplicateTask?: (id: string) => Promise; + onMergeTask?: (id: string) => Promise; onOpenDetail: (task: Task | TaskDetail) => void; onOpenGroupModal?: (groupId: string) => void; addToast: (message: string, type?: ToastType) => void; onQuickCreate?: (input: TaskCreateInput) => Promise; onNewTask: () => void; autoMerge: boolean; + /** Project merge strategy passed to Board-owned card context menus. */ + mergeStrategy?: string; onToggleAutoMerge: () => void; globalPaused?: boolean; onUpdateTask?: ( @@ -142,7 +149,7 @@ function BoardWorkflowSkeleton({ empty = false }: { empty?: boolean }) { ); } -export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, onMoveTask, onPauseTask, onOpenDetail, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, onLoadArchivedTasks, searchQuery = "", availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, taskStuckTimeoutMs, onOpenMission, staleHighFanoutBlockerAgeThresholdMs, lastFetchTimeMs, prAuthAvailable, onOpenWorkflowEditor, onCreateWorkflow, workflowColumnsEnabled, settingsLoaded, workflowControlsInHeader = false }: BoardProps) { +export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, onMoveTask, onPauseTask, onUnpauseTask, onResetTask, onDuplicateTask, onMergeTask, onOpenDetail, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, mergeStrategy = "direct", onToggleAutoMerge, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, onLoadArchivedTasks, searchQuery = "", availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, taskStuckTimeoutMs, onOpenMission, staleHighFanoutBlockerAgeThresholdMs, lastFetchTimeMs, prAuthAvailable, onOpenWorkflowEditor, onCreateWorkflow, workflowColumnsEnabled, settingsLoaded, workflowControlsInHeader = false }: BoardProps) { const [archivedCollapsed, setArchivedCollapsed] = useState(true); /* FNXC:DoneColumnSorting 2026-06-29-16:57: @@ -497,6 +504,31 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o ?? selectedWorkflowColumns.find((column) => !column.flags.archived)?.id; }, [selectedWorkflowColumns]); + const workflowContextMenuColumnsByWorkflowId = useMemo(() => { + const map = new Map(); + for (const workflow of boardWorkflows?.workflows ?? []) { + map.set(workflow.id, workflow.columns + .filter((column) => !column.flags.hiddenFromBoard) + .map((column) => ({ id: column.id, label: column.name, flags: column.flags }))); + } + return map; + }, [boardWorkflows]); + + const selectedWorkflowContextMenuColumns = useMemo(() => ( + selectedWorkflow ? workflowContextMenuColumnsByWorkflowId.get(selectedWorkflow.id) : undefined + ), [selectedWorkflow, workflowContextMenuColumnsByWorkflowId]); + + const taskContextMenuColumnsByTaskId = useMemo(() => { + const map = new Map(); + if (!workflowMode || !boardWorkflows) return map; + for (const task of tasks) { + const workflowId = getEffectiveTaskWorkflowId(task); + const columns = workflowId ? workflowContextMenuColumnsByWorkflowId.get(workflowId) : undefined; + if (columns) map.set(task.id, columns); + } + return map; + }, [boardWorkflows, getEffectiveTaskWorkflowId, tasks, workflowContextMenuColumnsByWorkflowId, workflowMode]); + const selectedWorkflowTasksByColumn = useMemo(() => { const grouped: Record = {}; if (!selectedWorkflow) return grouped; @@ -743,12 +775,17 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o workflowMode columnDisplayName={columnDef.name} columnFlags={columnDef.flags} + taskContextMenuColumnsByTaskId={taskContextMenuColumnsByTaskId} tasks={aggregateTasksByColumn[columnDef.id] ?? []} projectId={projectId} maxConcurrent={maxConcurrent} showWorktreeGrouping={showWorktreeGrouping} onMoveTask={onMoveTask} onPauseTask={onPauseTask} + onUnpauseTask={onUnpauseTask} + onResetTask={onResetTask} + onDuplicateTask={onDuplicateTask} + onMergeTask={onMergeTask} onOpenDetail={onOpenDetail} onOpenGroupModal={onOpenGroupModal} addToast={addToast} @@ -774,6 +811,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o blockerFanoutMap={blockerFanoutMap} prAuthAvailable={prAuthAvailable} autoMerge={autoMerge} + mergeStrategy={mergeStrategy} {...(isCreateColumn && aggregateQuickCreateTarget ? { workflowId: aggregateQuickCreateTarget.workflowId, onQuickCreate: handleAggregateWorkflowQuickCreate, onNewTask, onPlanningMode, onSubtaskBreakdown } : {})} {...(columnDef.flags.mergeBlocker || columnDef.flags.humanReview ? { onToggleAutoMerge: handleToggleAutoMerge } : {})} {...(columnDef.id === "done" ? { onArchiveAllDone } : {})} @@ -813,6 +851,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o workflowId={selectedWorkflow.id} columnDisplayName={columnDef.name} columnFlags={columnDef.flags} + workflowContextMenuColumns={selectedWorkflowContextMenuColumns} tasks={selectedWorkflowTasksByColumn[columnDef.id] ?? []} allTasks={selectedWorkflowTasks} projectId={projectId} @@ -823,6 +862,10 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o canDropTask={(taskId) => canDropTask(taskId, columnDef.id, selectedWorkflow.id)} getDraggingTaskId={getDraggingTaskId} onPauseTask={onPauseTask} + onUnpauseTask={onUnpauseTask} + onResetTask={onResetTask} + onDuplicateTask={onDuplicateTask} + onMergeTask={onMergeTask} onOpenDetail={onOpenDetail} onOpenGroupModal={onOpenGroupModal} addToast={addToast} @@ -846,6 +889,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o blockerFanoutMap={blockerFanoutMap} prAuthAvailable={prAuthAvailable} autoMerge={autoMerge} + mergeStrategy={mergeStrategy} {...(isCreateColumn ? { onQuickCreate: handleWorkflowQuickCreate, onNewTask, onPlanningMode, onSubtaskBreakdown } : {})} {...(columnDef.flags.mergeBlocker || columnDef.flags.humanReview ? { onToggleAutoMerge: handleToggleAutoMerge } : {})} {...(columnDef.id === "done" ? { onArchiveAllDone } : {})} @@ -861,6 +905,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o workflowId={selectedWorkflow.id} columnDisplayName={selectedWorkflowArchivedColumn.name} columnFlags={selectedWorkflowArchivedColumn.flags} + workflowContextMenuColumns={selectedWorkflowContextMenuColumns} tasks={selectedWorkflowTasksByColumn[selectedWorkflowArchivedColumn.id] ?? []} allTasks={selectedWorkflowTasks} projectId={projectId} @@ -871,6 +916,10 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o canDropTask={(taskId) => canDropTask(taskId, selectedWorkflowArchivedColumn.id, selectedWorkflow.id)} getDraggingTaskId={getDraggingTaskId} onPauseTask={onPauseTask} + onUnpauseTask={onUnpauseTask} + onResetTask={onResetTask} + onDuplicateTask={onDuplicateTask} + onMergeTask={onMergeTask} onOpenDetail={onOpenDetail} onOpenGroupModal={onOpenGroupModal} addToast={addToast} @@ -894,6 +943,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o blockerFanoutMap={blockerFanoutMap} prAuthAvailable={prAuthAvailable} autoMerge={autoMerge} + mergeStrategy={mergeStrategy} collapsed={archivedCollapsed} onToggleCollapse={handleToggleArchivedCollapse} /> @@ -916,6 +966,10 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o showWorktreeGrouping={showWorktreeGrouping} onMoveTask={onMoveTask} onPauseTask={onPauseTask} + onUnpauseTask={onUnpauseTask} + onResetTask={onResetTask} + onDuplicateTask={onDuplicateTask} + onMergeTask={onMergeTask} onOpenDetail={onOpenDetail} onOpenGroupModal={onOpenGroupModal} addToast={addToast} @@ -940,6 +994,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o blockerFanoutMap={blockerFanoutMap} prAuthAvailable={prAuthAvailable} autoMerge={autoMerge} + mergeStrategy={mergeStrategy} {...(col === "triage" ? { onQuickCreate, onNewTask, onPlanningMode, onSubtaskBreakdown } : {})} {...(col === "in-review" ? { onToggleAutoMerge: handleToggleAutoMerge } : {})} {...(col === "done" ? { onArchiveAllDone, doneSortMode, onDoneSortModeChange: setDoneSortMode } : {})} diff --git a/packages/dashboard/app/components/Column.tsx b/packages/dashboard/app/components/Column.tsx index e90d40e178..ca51bcf780 100644 --- a/packages/dashboard/app/components/Column.tsx +++ b/packages/dashboard/app/components/Column.tsx @@ -2,7 +2,7 @@ import { memo, useMemo, useState, useCallback, useEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; import { useFlashOnIncrease } from "../hooks/useFlashOnIncrease"; import { useConfirm } from "../hooks/useConfirm"; -import type { Task, TaskDetail, Column as ColumnType, TaskCreateInput, GithubIssueAction } from "@fusion/core"; +import type { Task, TaskDetail, Column as ColumnType, ColumnId, TaskCreateInput, GithubIssueAction, MergeResult } from "@fusion/core"; import { COLUMN_LABELS, COLUMN_DESCRIPTIONS, getErrorMessage } from "@fusion/core"; import { isNearDuplicateCanonicalInactive } from "../../../core/src/near-duplicate-canonical"; import { TaskCard } from "./TaskCard"; @@ -11,6 +11,7 @@ import { QuickEntryBox } from "./QuickEntryBox"; import { PluginSlot } from "./PluginSlot"; import { groupByWorktree } from "../utils/worktreeGrouping"; import type { ToastType } from "../hooks/useToast"; +import type { TaskContextMenuColumnMetadata } from "./TaskContextMenu"; import { ChevronDown, ChevronUp, Archive, MoreVertical } from "lucide-react"; import type { ModelInfo, BoardWorkflowColumnFlags } from "../api"; import type { BlockerFanoutEntry } from "../hooks/useBlockerFanout"; @@ -93,14 +94,20 @@ interface ColumnProps { projectId?: string; maxConcurrent: number; showWorktreeGrouping: boolean; - onMoveTask: (id: string, column: ColumnType, optionsOrPosition?: { preserveProgress?: boolean } | number) => Promise; + onMoveTask: (id: string, column: ColumnId, optionsOrPosition?: { preserveProgress?: boolean } | number) => Promise; onPauseTask?: (id: string) => Promise; + onUnpauseTask?: (id: string) => Promise; + onResetTask?: (id: string) => Promise; + onDuplicateTask?: (id: string) => Promise; + onMergeTask?: (id: string) => Promise; onOpenDetail: (task: Task | TaskDetail) => void; onOpenGroupModal?: (groupId: string) => void; addToast: (message: string, type?: ToastType) => void; onQuickCreate?: (input: TaskCreateInput) => Promise; onNewTask?: () => void; autoMerge?: boolean; + /** Project merge strategy for Task Detail-equivalent card context actions. */ + mergeStrategy?: string; onToggleAutoMerge?: () => void; globalPaused?: boolean; onUpdateTask?: ( @@ -164,6 +171,10 @@ interface ColumnProps { columnDisplayName?: string; /** Resolved trait flags for this column (workflow mode). */ columnFlags?: BoardWorkflowColumnFlags; + /** Ordered workflow columns for deriving context-menu move targets in workflow mode. */ + workflowContextMenuColumns?: readonly TaskContextMenuColumnMetadata[]; + /** Per-task workflow columns for aggregate Board cards whose tasks come from different workflows. */ + taskContextMenuColumnsByTaskId?: ReadonlyMap; /** Manually promote a held card out of this hold column (workflow mode). */ onPromote?: (taskId: string) => Promise; /** @@ -179,7 +190,7 @@ interface ColumnProps { getDraggingTaskId?: () => string | null; } -function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktreeGrouping, onMoveTask, onPauseTask, onOpenDetail, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, doneSortMode, onDoneSortModeChange, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, taskStuckTimeoutMs, onOpenMission, lastFetchTimeMs, taskCardFieldDefs, taskWorkflowBadges, blockerFanoutMap, prAuthAvailable, workflowMode, workflowId, columnDisplayName, columnFlags, onPromote, canDropTask, getDraggingTaskId }: ColumnProps) { +function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktreeGrouping, onMoveTask, onPauseTask, onUnpauseTask, onResetTask, onDuplicateTask, onMergeTask, onOpenDetail, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, mergeStrategy = "direct", onToggleAutoMerge, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, doneSortMode, onDoneSortModeChange, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, taskStuckTimeoutMs, onOpenMission, lastFetchTimeMs, taskCardFieldDefs, taskWorkflowBadges, blockerFanoutMap, prAuthAvailable, workflowMode, workflowId, columnDisplayName, columnFlags, workflowContextMenuColumns, taskContextMenuColumnsByTaskId, onPromote, canDropTask, getDraggingTaskId }: 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 @@ -249,6 +260,12 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktree const isHoldColumn = workflowMode && Boolean(columnFlags?.hold); const isCollapsed = isArchived && collapsed; const isWipProcessingColumn = workflowMode ? Boolean(columnFlags?.countsTowardWip) : column === "in-progress"; + const getTaskContextMenuColumns = useCallback((task: Task) => ( + taskContextMenuColumnsByTaskId?.get(task.id) ?? workflowContextMenuColumns + ), [taskContextMenuColumnsByTaskId, workflowContextMenuColumns]); + const getTaskColumnFlags = useCallback((task: Task) => ( + getTaskContextMenuColumns(task)?.find((candidate) => candidate.id === task.column)?.flags ?? (task.column === column ? columnFlags : undefined) + ), [column, columnFlags, getTaskContextMenuColumns]); /* FNXC:WorktreeGroupingSetting 2026-06-27-22:30: The project setting is an explicit show/hide control: worktree grouping and labels render only when enabled and only for the board's WIP/processing column. Turning it off must leave plain task cards with no legacy group shell in either legacy or workflow-mode columns. @@ -753,10 +770,19 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktree queuedTasks={group.queuedTasks} projectId={projectId} onOpenDetail={onOpenDetail} + onMoveTask={onMoveTask} addToast={addToast} globalPaused={globalPaused} onUpdateTask={onUpdateTask} + onPauseTask={onPauseTask} onRetryTask={onRetryTask} + onUnpauseTask={onUnpauseTask} + onResetTask={onResetTask} + onDuplicateTask={onDuplicateTask} + onMergeTask={onMergeTask} + onArchiveTask={onArchiveTask} + onUnarchiveTask={onUnarchiveTask} + onDeleteTask={onDeleteTask} onOpenDetailWithTab={onOpenDetailWithTab} taskStuckTimeoutMs={taskStuckTimeoutMs} onOpenMission={onOpenMission} @@ -766,6 +792,9 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktree blockerFanoutMap={blockerFanoutMap} prAuthAvailable={prAuthAvailable} autoMergeEnabled={Boolean(autoMerge)} + mergeStrategy={mergeStrategy} + workflowContextMenuColumns={workflowContextMenuColumns} + taskContextMenuColumnsByTaskId={taskContextMenuColumnsByTaskId} allTasks={allTasks} /> )) @@ -784,7 +813,12 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktree addToast={addToast} globalPaused={globalPaused} onUpdateTask={onUpdateTask} + onPauseTask={onPauseTask} onRetryTask={onRetryTask} + onUnpauseTask={onUnpauseTask} + onResetTask={onResetTask} + onDuplicateTask={onDuplicateTask} + onMergeTask={onMergeTask} onArchiveTask={onArchiveTask} onUnarchiveTask={onUnarchiveTask} onDeleteTask={onDeleteTask} @@ -792,6 +826,8 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktree taskStuckTimeoutMs={taskStuckTimeoutMs} onOpenMission={onOpenMission} onMoveTask={onMoveTask} + taskColumnFlags={getTaskColumnFlags(task)} + taskMoveColumns={getTaskContextMenuColumns(task)} onPromote={isHoldColumn && onPromote ? handlePromote : undefined} isPromoting={isHoldColumn && onPromote ? promotingIds.has(task.id) : undefined} lastFetchTimeMs={lastFetchTimeMs} @@ -800,6 +836,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktree fanout={blockerFanoutMap?.get(task.id)} prAuthAvailable={prAuthAvailable} autoMergeEnabled={Boolean(autoMerge)} + mergeStrategy={mergeStrategy} nearDuplicateCanonicalInactive={resolveNearDuplicateCanonicalInactive(task)} /> ))} diff --git a/packages/dashboard/app/components/Lane.tsx b/packages/dashboard/app/components/Lane.tsx index 0d73eef5d2..6e78110623 100644 --- a/packages/dashboard/app/components/Lane.tsx +++ b/packages/dashboard/app/components/Lane.tsx @@ -1,7 +1,7 @@ import "./Lane.css"; import { memo, useCallback, useEffect, useMemo, useRef, type KeyboardEvent } from "react"; import { useTranslation } from "react-i18next"; -import type { Task, TaskDetail, Column as ColumnType, TaskCreateInput, GithubIssueAction } from "@fusion/core"; +import type { Task, TaskDetail, Column as ColumnType, ColumnId, TaskCreateInput, GithubIssueAction } from "@fusion/core"; import { Column } from "./Column"; import { sortTasksForDisplayColumn } from "./taskSorting"; import type { ModelInfo, BoardWorkflowDefinition } from "../api"; @@ -32,7 +32,7 @@ export interface LaneProps { projectId?: string; maxConcurrent: number; showWorktreeGrouping?: boolean; - onMoveTask: (id: string, column: ColumnType, optionsOrPosition?: { preserveProgress?: boolean } | number) => Promise; + 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; @@ -83,6 +83,12 @@ function LaneComponent(props: LaneProps) { () => workflow.columns.filter((col) => !col.flags.archived && !col.flags.hiddenFromBoard), [workflow.columns], ); + const contextMenuColumns = useMemo( + () => workflow.columns + .filter((col) => !col.flags.hiddenFromBoard) + .map((col) => ({ id: col.id, label: col.name, flags: col.flags })), + [workflow.columns], + ); const createColumnId = useMemo(() => ( visibleColumns.find((col) => col.flags.intake && !col.flags.archived)?.id ?? visibleColumns.find((col) => !col.flags.archived)?.id @@ -174,6 +180,7 @@ function LaneComponent(props: LaneProps) { workflowId={workflow.id} columnDisplayName={col.name} columnFlags={col.flags} + workflowContextMenuColumns={contextMenuColumns} tasks={tasksByColumn[col.id] ?? []} allTasks={tasks} projectId={props.projectId} diff --git a/packages/dashboard/app/components/ListView.css b/packages/dashboard/app/components/ListView.css index c8d4a01a55..7cc2ad4a42 100644 --- a/packages/dashboard/app/components/ListView.css +++ b/packages/dashboard/app/components/ListView.css @@ -229,6 +229,21 @@ View options was oversized (full-width stacked button). Render it as a compact i cursor: not-allowed; } +/* +FNXC:ListContextMenu 2026-06-29-00:00: +List rows and mobile cards expose the same task lifecycle menu as Board cards from right-click or mobile long-press. The popover is portal-positioned so table rows, split-pane sizing, and mobile card layout do not gain new in-flow width or height. +*/ +.list-context-menu-popover { + position: fixed; + z-index: 1000; +} + +.list-context-menu { + position: static; + min-width: var(--task-context-menu-min-width); + max-width: var(--task-context-menu-max-width); +} + /* Checkbox column in table */ .list-header-checkbox, .list-cell-checkbox { @@ -574,6 +589,11 @@ No border-left on the detail pane. Keeping a border-left here would produce a se background: var(--card-hover); } +.list-row:focus-visible { + outline: none; + box-shadow: var(--focus-ring-strong); +} + .list-row--selected { background: color-mix(in srgb, var(--todo) 12%, transparent); box-shadow: inset 0 0 0 1px var(--todo); @@ -1060,6 +1080,11 @@ In the split sidebar the title cell must allow the title to wrap to two lines (h border-color: var(--text-muted); } + .list-card:focus-visible { + outline: none; + box-shadow: var(--focus-ring-strong); + } + .list-card:active { background: var(--surface); transform: scale(0.99); diff --git a/packages/dashboard/app/components/ListView.tsx b/packages/dashboard/app/components/ListView.tsx index ffaf3a2139..4ca988cffc 100644 --- a/packages/dashboard/app/components/ListView.tsx +++ b/packages/dashboard/app/components/ListView.tsx @@ -1,15 +1,17 @@ import "./ListView.css"; -import { useState, useCallback, useMemo, Fragment, useEffect, useRef } from "react"; +import { useState, useCallback, useMemo, Fragment, useEffect, useLayoutEffect, useRef } from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; import { ArrowUpDown, ArrowUp, ArrowDown, Link, Columns3, EyeOff, Eye, ChevronRight, Zap, Trash2, Pause, Play, Archive } from "lucide-react"; -import type { Task, TaskDetail, Column, ColumnId, TaskCreateInput, MergeResult, GithubIssueAction } from "@fusion/core"; +import type { Task, TaskDetail, Column, ColumnId, TaskCreateInput, MergeResult, GithubIssueAction, PrInfo } from "@fusion/core"; import { COLUMNS, DEFAULT_COLUMN, getErrorMessage, isColumn } from "@fusion/core"; +import { resolveEffectiveAutoMerge } from "../../../core/src/task-merge"; import { useColumnLabel } from "../i18n/labels"; import { sortTasksForDisplayColumn } from "./taskSorting"; -import { batchUpdateTaskModels, fetchNodes, fetchTaskDetail } from "../api"; +import { batchUpdateTaskModels, fetchNodes, fetchTaskDetail, rebuildTaskSpec, refreshPrStatus } from "../api"; import { TaskDetailContent } from "./TaskDetailModal"; +import { PrCreateModal } from "./PrCreateModal"; import type { BoardWorkflowColumn, BoardWorkflowsPayload, ModelInfo, NodeInfo } from "../api"; import { QuickEntryBox } from "./QuickEntryBox"; import { CustomModelDropdown } from "./CustomModelDropdown"; @@ -25,6 +27,7 @@ import { WorkflowSwitcher } from "./WorkflowSwitcher"; import { computeWorkflowStatusCounts } from "./workflowStatusCounts"; import { writeBoardWorkflowsCache } from "../utils/boardWorkflowsCache"; import { useBoardWorkflows } from "../hooks/useBoardWorkflows"; +import { TaskContextMenu, buildTaskActionMenuModel, getTaskPrAutomationLabel, type TaskContextMenuColumnMetadata, type TaskMenuActionDescriptor } from "./TaskContextMenu"; const COLUMN_COLOR_MAP: Record = { triage: "var(--triage)", @@ -42,6 +45,18 @@ function columnColor(column: ColumnId): string { } const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finalizing", "merging", "merging-fix"]); +const LIST_TOUCH_CONTEXT_MENU_DELAY_MS = 550; +const LIST_TOUCH_MOVE_THRESHOLD = 10; +const LIST_CONTEXT_MENU_VIEWPORT_MARGIN = 8; +const LIST_KEYBOARD_CONTEXT_MENU_OFFSET = 32; + +type ListContextMenuState = { task: Task; x: number; y: number } | null; +type ListPrCreateState = { task: Task } | null; + +function isListContextInteractiveTarget(target: EventTarget | null): boolean { + if (!(target instanceof Element)) return false; + return Boolean(target.closest("button, a, input, textarea, select, label, [role='button']")); +} type SortField = "title" | "status" | "column" | "retries"; @@ -247,6 +262,8 @@ interface ListViewProps { lastFetchTimeMs?: number; prAuthAvailable?: boolean; autoMerge?: boolean; + /** Project merge strategy so list context menus match Task Detail before a PR exists. */ + mergeStrategy?: string; onOpenWorkflowEditor?: (workflowId?: string) => void; onCreateWorkflow?: () => void; workflowColumnsEnabled?: boolean; @@ -317,6 +334,7 @@ export function ListView({ lastFetchTimeMs, prAuthAvailable, autoMerge, + mergeStrategy = "direct", onOpenWorkflowEditor, onCreateWorkflow, workflowColumnsEnabled, @@ -330,6 +348,12 @@ export function ListView({ 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); + const contextMenuRef = useRef(null); + const longPressTimerRef = useRef | null>(null); + const longPressStartRef = useRef<{ x: number; y: number; pointerId: number } | null>(null); + const suppressNextRowClickRef = useRef(false); /* FNXC:BoardWorkflows 2026-06-20-09:07: ListView shares the board-workflows first-paint invariant with Board: hydrate per-project workflow metadata from sessionStorage and gate legacy list columns while workflowColumns settings or uncached lane metadata are still unknown. @@ -603,6 +627,11 @@ export function ListView({ return columnNameById.get(column) ?? columnLabel(column); }, [columnLabel, columnNameById]); + const listContextMenuColumns = useMemo(() => { + if (!workflowMode) return undefined; + return listColumns.map((column) => ({ id: column.id, label: column.name, flags: column.flags })); + }, [listColumns, workflowMode]); + const isArchivedColumn = useCallback((column: ColumnId): boolean => { return workflowMode ? Boolean(columnFlagsById.get(column)?.archived) : column === "archived"; }, [columnFlagsById, workflowMode]); @@ -1374,8 +1403,371 @@ export function ListView({ } }, [selectedTaskIds, tasks, executorModel, validatorModel, nodeOverride, projectId, addToast, clearSelection, isArchivedColumn, onTasksUpdated]); + const closeContextMenu = useCallback(() => { + setContextMenuState(null); + }, []); + + const clearLongPressTimer = useCallback(() => { + if (longPressTimerRef.current) { + clearTimeout(longPressTimerRef.current); + longPressTimerRef.current = null; + } + longPressStartRef.current = null; + }, []); + + const handleListTaskDelete = useCallback(async (task: Task) => { + const shouldDelete = await confirm({ + title: t("tasks.deleteTitle", "Delete Task"), + message: t("tasks.deleteConfirm", "Delete {{taskId}}?", { taskId: task.id }), + danger: true, + }); + if (!shouldDelete) return; + + try { + await onDeleteTask(task.id); + addToast(t("tasks.deleted", "Deleted {{taskId}}{{suffix}}", { taskId: task.id, suffix: "" }), "success"); + } catch (err) { + const dependencyConflict = extractDependencyDeleteConflict(err); + const lineageConflict = extractLineageDeleteConflict(err); + const shouldForce = dependencyConflict?.dependentIds.length || lineageConflict?.lineageChildIds.length; + if (!shouldForce) { + addToast(t("tasks.deleteFailed", "Failed to delete {{taskId}}: {{error}}", { taskId: task.id, error: getErrorMessage(err) }), "error"); + return; + } + const confirmed = await confirm({ + title: t("tasks.forceDeleteTitle", "Force Delete Task"), + message: dependencyConflict?.dependentIds.length + ? t("tasks.dependencyConflict", "{{taskId}} is a dependency of {{dependentList}}.\n\nDelete anyway by removing these dependency references first?", { taskId: task.id, dependentList: dependencyConflict.dependentIds.join(", ") }) + : t("tasks.lineageConflict", "{{taskId}} has lineage children ({{children}}) that reference it as a source parent.\n\nDelete anyway by unlinking these references first?", { taskId: task.id, children: lineageConflict?.lineageChildIds.join(", ") ?? "" }), + danger: true, + }); + if (!confirmed) return; + try { + await onDeleteTask(task.id, { removeDependencyReferences: true, removeLineageReferences: true }); + addToast(t("tasks.deletedRemovedDeps", "Deleted {{taskId}} after removing dependency references", { taskId: task.id }), "success"); + } catch (retryErr) { + addToast(t("tasks.deleteFailed", "Failed to delete {{taskId}}: {{error}}", { taskId: task.id, error: getErrorMessage(retryErr) }), "error"); + } + } + }, [addToast, confirm, onDeleteTask, t]); + + const handleListTaskArchive = useCallback(async (task: Task) => { + if (!onArchiveTask) return; + try { + await onArchiveTask(task.id); + addToast(t("tasks.archived", "Archived {{taskId}}", { taskId: task.id }), "success"); + } catch (err) { + const lineageConflict = extractLineageDeleteConflict(err); + if (!lineageConflict?.lineageChildIds.length) { + addToast(t("tasks.archiveFailed", "Failed to archive {{taskId}}: {{error}}", { taskId: task.id, error: getErrorMessage(err) }), "error"); + return; + } + const confirmed = await confirm({ + title: t("tasks.forceDeleteTitle", "Force Delete Task"), + message: t("tasks.lineageArchiveMessage", "{{taskId}} has lineage children ({{children}}) that reference it as a source parent.\n\nArchive anyway by unlinking these references first?", { taskId: task.id, children: lineageConflict.lineageChildIds.join(", ") }), + confirmLabel: t("common.archive", "Archive"), + cancelLabel: t("common.skip", "Skip"), + danger: true, + }); + if (!confirmed) return; + try { + await onArchiveTask(task.id, { removeLineageReferences: true }); + addToast(t("tasks.archivedUnlinked", "Archived {{taskId}} after unlinking lineage references", { taskId: task.id }), "success"); + } catch (retryErr) { + addToast(t("tasks.archiveFailed", "Failed to archive {{taskId}}: {{error}}", { taskId: task.id, error: getErrorMessage(retryErr) }), "error"); + } + } + }, [addToast, confirm, onArchiveTask, t]); + + const handleListContextMove = useCallback(async (task: Task, column: ColumnId) => { + try { + const hasStepProgress = task.steps.some((step) => step.status !== "pending"); + const targetFlags = columnFlagsById.get(column); + const shouldPrompt = hasStepProgress && ( + column === "todo" || column === "triage" || Boolean(targetFlags?.intake || targetFlags?.hold) + ); + let moveOptions: { preserveProgress?: boolean } | undefined; + + if (shouldPrompt) { + const keepProgress = await confirm({ + title: t("taskDetail.move.preserveProgressTitle", "Preserve Progress?"), + message: t("taskDetail.move.preserveProgressMessage", "This task has completed steps. Keep progress before moving?"), + confirmLabel: t("taskDetail.move.keepProgress", "Keep Progress"), + cancelLabel: t("taskDetail.move.resetProgress", "Reset Progress"), + }); + + if (keepProgress) { + moveOptions = { preserveProgress: true }; + } else { + const resetProgress = await confirm({ + title: t("taskDetail.move.resetProgressTitle", "Reset Progress?"), + message: t("taskDetail.move.resetProgressMessage", "Reset all step progress before moving this task?"), + confirmLabel: t("taskDetail.move.resetProgress", "Reset Progress"), + cancelLabel: t("taskDetail.move.cancelMove", "Cancel Move"), + danger: true, + }); + if (!resetProgress) return; + } + } + + await onMoveTask(task.id, column, moveOptions); + addToast(t("taskDetail.move.movedTo", "Moved to {{column}}", { column: getListColumnLabel(column) }), "success"); + } catch (err) { + addToast(getErrorMessage(err), "error"); + } + }, [addToast, columnFlagsById, getListColumnLabel, confirm, onMoveTask, t]); + + const handleListContextCheckPrStatus = useCallback(async (task: Task) => { + try { + await refreshPrStatus(task.id, projectId); + addToast(t("taskDetail.pr.statusRefreshed", "PR status refreshed"), "success"); + } catch (err) { + addToast(getErrorMessage(err), "error"); + } + }, [addToast, projectId, t]); + + const handleListPrCreated = useCallback((task: Task, prInfo: PrInfo) => { + const nextPrInfos = [...(task.prInfos ?? (task.prInfo ? [task.prInfo] : [])), prInfo]; + onTasksUpdated?.([{ ...task, prInfo: nextPrInfos[0] ?? prInfo, prInfos: nextPrInfos }]); + setPrCreateState(null); + addToast(t("tasks.createdPr", "Created PR #{{number}}", { number: prInfo.number }), "success"); + }, [addToast, onTasksUpdated, t]); + + const buildListContextMenuActions = useCallback((task: Task): TaskMenuActionDescriptor[] => { + const canRetryTask = + task.status === "failed" || + task.status === "stuck-killed" || + task.status === "planning" || + task.status === "needs-replan" || + (task.stuckKillCount ?? 0) > 0 || + (task.recoveryRetryCount ?? 0) > 0 || + Boolean(task.nextRecoveryAt); + const isTaskPaused = Boolean(task.paused || task.userPaused); + const effectiveAutoMerge = resolveEffectiveAutoMerge({ autoMerge: task.autoMerge }, { autoMerge: autoMerge ?? false }); + const model = buildTaskActionMenuModel({ + task, + t, + columnLabel: getListColumnLabel, + currentColumnFlags: columnFlagsById.get(task.column), + workflowMoveColumns: listContextMenuColumns, + canRetryTask, + hasDuplicateHandler: Boolean(onDuplicateTask), + hasRetryHandler: Boolean(onRetryTask), + hasResetHandler: Boolean(onResetTask), + hasAssignedAgent: Boolean(task.assignedAgentId), + autoMergeEnabled: effectiveAutoMerge, + mergeStrategy, + prAutomationLabel: getTaskPrAutomationLabel(t, task.status), + onDelete: () => void handleListTaskDelete(task), + onDuplicate: onDuplicateTask ? async () => { + const shouldDuplicate = await confirm({ + title: t("taskDetail.duplicate.title", "Duplicate Task"), + message: t("taskDetail.duplicate.message", "Duplicate {{id}}? This will create a new task in Triage with the same description and prompt.", { id: task.id }), + }); + if (!shouldDuplicate) return; + try { + const newTask = await onDuplicateTask(task.id); + addToast(t("taskDetail.duplicate.success", "Duplicated {{id}} → {{newId}}", { id: task.id, newId: newTask.id }), "success"); + } catch (err) { + addToast(getErrorMessage(err), "error"); + } + } : undefined, + onOpenRefine: undefined, + onRespecify: async () => { + const shouldRebuild = await confirm({ + title: t("taskDetail.plan.rebuildTitle", "Rebuild Plan"), + message: t("taskDetail.plan.rebuildMessage", "Rebuild the plan for this task? The task will move to planning for replanning."), + }); + if (!shouldRebuild) return; + try { + await rebuildTaskSpec(task.id, projectId); + addToast(t("taskDetail.plan.replanning", "Replanning {{id}}…", { id: task.id }), "info"); + } catch (err) { + addToast(getErrorMessage(err), "error"); + } + }, + onRetry: onRetryTask ? async () => { + try { + await onRetryTask(task.id); + } catch (err) { + addToast(t("tasks.retryFailed", "Failed to retry {{taskId}}: {{error}}", { taskId: task.id, error: getErrorMessage(err) }), "error"); + } + } : undefined, + onReset: onResetTask ? () => { + if (!window.confirm(t("taskDetail.reset.confirmMessage", "This will erase all progress for {{id}} and start the task from scratch. Continue?", { id: task.id }))) return; + void onResetTask(task.id) + .then(() => addToast(t("taskDetail.reset.resetSuccess", "Reset {{id}} — fresh run will be allocated", { id: task.id }), "success")) + .catch((err) => addToast(getErrorMessage(err), "error")); + } : undefined, + onTogglePause: (isTaskPaused ? onUnpauseTask : onPauseTask) ? async () => { + try { + if (isTaskPaused) { + if (!onUnpauseTask) return; + await onUnpauseTask(task.id); + addToast(t("taskDetail.pause.unpaused", "Unpaused {{id}}", { id: task.id }), "success"); + } else { + if (!onPauseTask) return; + await onPauseTask(task.id); + addToast(t("taskDetail.pause.paused", "Paused {{id}}", { id: task.id }), "success"); + } + } catch (err) { + addToast(getErrorMessage(err), "error"); + } + } : undefined, + onMerge: onMergeTask ? async () => { + const shouldMerge = await confirm({ + title: t("taskDetail.merge.title", "Merge Task"), + message: t("taskDetail.merge.message", "Merge {{id}} into the current branch?", { id: task.id }), + }); + if (!shouldMerge) return; + addToast(t("taskDetail.merge.merging", "Merging {{id}}…", { id: task.id }), "info"); + void onMergeTask(task.id) + .then((result) => addToast(result.merged + ? t("taskDetail.merge.merged", "Merged {{id}} (branch: {{branch}})", { id: task.id, branch: result.branch }) + : t("taskDetail.merge.closed", "Closed {{id}} ({{reason}})", { id: task.id, reason: result.error || t("taskDetail.merge.noBranchToMerge", "no branch to merge") }), "success")) + .catch((err) => addToast(getErrorMessage(err), "error")); + } : undefined, + onStartPrReview: () => setPrCreateState({ task }), + onCheckPrStatus: task.prInfo ? () => void handleListContextCheckPrStatus(task) : undefined, + }); + + const actions = [...model.actions]; + if (task.column === "done" && onArchiveTask) { + actions.push({ id: "archive", label: t("tasks.archive", "Archive"), onSelect: () => void handleListTaskArchive(task) }); + } + for (const transition of model.moveTransitions) { + actions.push({ + id: `move-${transition.column}`, + label: transition.label, + onSelect: () => void handleListContextMove(task, transition.column), + }); + } + 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)); + }, [addToast, autoMerge, columnFlagsById, confirm, getListColumnLabel, handleListContextCheckPrStatus, handleListContextMove, handleListTaskArchive, handleListTaskDelete, listContextMenuColumns, mergeStrategy, onDuplicateTask, onMergeTask, onPauseTask, onResetTask, onRetryTask, onUnpauseTask, onArchiveTask, projectId, t]); + + const contextMenuActions = useMemo( + () => (contextMenuState ? buildListContextMenuActions(contextMenuState.task) : []), + [buildListContextMenuActions, contextMenuState], + ); + const hasContextMenuActions = contextMenuActions.length > 0; + + const openContextMenuAt = useCallback((task: Task, clientX: number, clientY: number) => { + const actions = buildListContextMenuActions(task); + if (actions.length === 0) return; + setContextMenuState({ + task, + x: Math.max(LIST_CONTEXT_MENU_VIEWPORT_MARGIN, Math.min(clientX, window.innerWidth - LIST_CONTEXT_MENU_VIEWPORT_MARGIN)), + y: Math.max(LIST_CONTEXT_MENU_VIEWPORT_MARGIN, Math.min(clientY, window.innerHeight - LIST_CONTEXT_MENU_VIEWPORT_MARGIN)), + }); + }, [buildListContextMenuActions]); + + const handleListContextMenu = useCallback((event: React.MouseEvent, task: Task) => { + if (isListContextInteractiveTarget(event.target)) return; + event.preventDefault(); + event.stopPropagation(); + openContextMenuAt(task, event.clientX, event.clientY); + }, [openContextMenuAt]); + + const handleListPointerDown = useCallback((event: React.PointerEvent, task: Task) => { + if (!isMobile || event.pointerType === "mouse" || isListContextInteractiveTarget(event.target)) return; + clearLongPressTimer(); + longPressStartRef.current = { x: event.clientX, y: event.clientY, pointerId: event.pointerId }; + longPressTimerRef.current = setTimeout(() => { + longPressTimerRef.current = null; + suppressNextRowClickRef.current = true; + openContextMenuAt(task, event.clientX, event.clientY); + }, LIST_TOUCH_CONTEXT_MENU_DELAY_MS); + }, [clearLongPressTimer, isMobile, openContextMenuAt]); + + const handleListKeyDown = useCallback((event: React.KeyboardEvent, task: Task) => { + if (event.key !== "ContextMenu" && !(event.shiftKey && event.key === "F10")) return; + if (isListContextInteractiveTarget(event.target)) return; + event.preventDefault(); + event.stopPropagation(); + const rect = (event.currentTarget as HTMLElement).getBoundingClientRect(); + suppressNextRowClickRef.current = true; + openContextMenuAt( + task, + rect.left + Math.min(rect.width - LIST_CONTEXT_MENU_VIEWPORT_MARGIN, LIST_KEYBOARD_CONTEXT_MENU_OFFSET), + rect.top + Math.min(rect.height - LIST_CONTEXT_MENU_VIEWPORT_MARGIN, LIST_KEYBOARD_CONTEXT_MENU_OFFSET), + ); + }, [openContextMenuAt]); + + const handleListPointerMove = useCallback((event: React.PointerEvent) => { + const start = longPressStartRef.current; + if (!start || start.pointerId !== event.pointerId) return; + if (Math.abs(event.clientX - start.x) > LIST_TOUCH_MOVE_THRESHOLD || Math.abs(event.clientY - start.y) > LIST_TOUCH_MOVE_THRESHOLD) { + clearLongPressTimer(); + } + }, [clearLongPressTimer]); + + const handleListPointerUpOrCancel = useCallback(() => { + clearLongPressTimer(); + }, [clearLongPressTimer]); + + /* + FNXC:ListContextMenu 2026-06-30-00:15: + List menus are portaled out of table/card flow and then measured so desktop rows, mobile cards, and keyboard invocations stay inside the visible viewport without selecting the row. + + FNXC:ListContextMenu 2026-06-30-13:02: + Manual PR context actions must open the PR creation dialog from list rows, while Merge & Close remains wired to the direct merge handler. + */ + useLayoutEffect(() => { + if (!contextMenuState) return; + const menu = contextMenuRef.current; + if (!menu) return; + const rect = menu.getBoundingClientRect(); + const nextX = Math.max( + LIST_CONTEXT_MENU_VIEWPORT_MARGIN, + Math.min(contextMenuState.x, window.innerWidth - rect.width - LIST_CONTEXT_MENU_VIEWPORT_MARGIN), + ); + const nextY = Math.max( + LIST_CONTEXT_MENU_VIEWPORT_MARGIN, + Math.min(contextMenuState.y, window.innerHeight - rect.height - LIST_CONTEXT_MENU_VIEWPORT_MARGIN), + ); + if (nextX !== contextMenuState.x || nextY !== contextMenuState.y) { + setContextMenuState({ ...contextMenuState, x: nextX, y: nextY }); + } + }, [contextMenuState]); + + useEffect(() => { + if (!contextMenuState) return; + const handleDocumentPointerDown = (event: PointerEvent) => { + if (contextMenuRef.current?.contains(event.target as Node)) return; + closeContextMenu(); + }; + const handleDocumentKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") closeContextMenu(); + }; + document.addEventListener("pointerdown", handleDocumentPointerDown); + document.addEventListener("keydown", handleDocumentKeyDown); + window.addEventListener("scroll", closeContextMenu, true); + return () => { + document.removeEventListener("pointerdown", handleDocumentPointerDown); + document.removeEventListener("keydown", handleDocumentKeyDown); + window.removeEventListener("scroll", closeContextMenu, true); + }; + }, [closeContextMenu, contextMenuState]); + + useEffect(() => { + const cancelLongPress = () => clearLongPressTimer(); + window.addEventListener("scroll", cancelLongPress, true); + return () => { + window.removeEventListener("scroll", cancelLongPress, true); + clearLongPressTimer(); + }; + }, [clearLongPressTimer]); + const handleRowClick = useCallback( (task: Task) => { + if (suppressNextRowClickRef.current) { + suppressNextRowClickRef.current = false; + return; + } + closeContextMenu(); if (isMobile) { onOpenDetail(task, { origin: "list-mobile" }); return; @@ -1384,7 +1776,7 @@ export function ListView({ setSelectedTaskId(task.id); setSelectedTaskSnapshot(task); }, - [isMobile, onOpenDetail] + [closeContextMenu, isMobile, onOpenDetail] ); // Debounce detail fetches so rapid keyboard/mouse navigation through a @@ -1857,6 +2249,32 @@ export function ListView({ return (
+ {contextMenuState && hasContextMenuActions && createPortal( +
event.stopPropagation()} + onContextMenu={(event) => event.preventDefault()} + > + +
, + document.body, + )} + {prCreateState && ( + setPrCreateState(null)} + onCreated={(prInfo) => handleListPrCreated(prCreateState.task, prInfo)} + addToast={addToast} + /> + )} {isMobile && ( <>
@@ -2021,7 +2439,15 @@ export function ListView({ key={task.id} className={`list-card${isAgentActive ? " agent-active" : ""}${isSelectionMode ? " list-card--selectable" : ""}`} onClick={() => handleRowClick(task)} + onContextMenu={(event) => handleListContextMenu(event, task)} + onPointerDown={(event) => handleListPointerDown(event, task)} + onPointerMove={handleListPointerMove} + onPointerUp={handleListPointerUpOrCancel} + onPointerCancel={handleListPointerUpOrCancel} + onKeyDown={(event) => handleListKeyDown(event, task)} data-id={task.id} + tabIndex={0} + aria-haspopup="menu" > {isSelectionMode && (
@@ -4473,15 +4418,15 @@ export function TaskDetailContent({ {showMoveMenu && hasSecondaryMoveOptions && (
- {secondaryMoveTransitions.map((col) => ( + {secondaryMoveTransitions.map((moveAction) => ( ))}
diff --git a/packages/dashboard/app/components/WorktreeGroup.tsx b/packages/dashboard/app/components/WorktreeGroup.tsx index 40f37ea460..7cdc6b1336 100644 --- a/packages/dashboard/app/components/WorktreeGroup.tsx +++ b/packages/dashboard/app/components/WorktreeGroup.tsx @@ -1,11 +1,12 @@ import { memo } from "react"; import { useTranslation } from "react-i18next"; -import type { Task, TaskDetail } from "@fusion/core"; +import type { Task, TaskDetail, MergeResult, GithubIssueAction, ColumnId } from "@fusion/core"; import { isNearDuplicateCanonicalInactive } from "../../../core/src/near-duplicate-canonical"; import { ClipboardList, GitBranch } from "lucide-react"; import { TaskCard } from "./TaskCard"; import type { ToastType } from "../hooks/useToast"; import type { BlockerFanoutEntry } from "../hooks/useBlockerFanout"; +import type { TaskContextMenuColumnMetadata } from "./TaskContextMenu"; interface WorktreeGroupProps { label: string; @@ -14,13 +15,26 @@ interface WorktreeGroupProps { allTasks?: Task[]; projectId?: string; onOpenDetail: (task: Task | TaskDetail) => void; + onMoveTask?: (id: string, column: ColumnId, optionsOrPosition?: { preserveProgress?: boolean } | number) => Promise; addToast: (message: string, type?: ToastType) => void; globalPaused?: boolean; onUpdateTask?: ( id: string, updates: { title?: string; description?: string; dependencies?: string[] } ) => Promise; + onPauseTask?: (id: string) => Promise; onRetryTask?: (id: string) => Promise; + onUnpauseTask?: (id: string) => Promise; + onResetTask?: (id: string) => Promise; + onDuplicateTask?: (id: string) => Promise; + onMergeTask?: (id: string) => Promise; + onArchiveTask?: (id: string, options?: { removeLineageReferences?: boolean }) => Promise; + onUnarchiveTask?: (id: string) => Promise; + onDeleteTask?: (id: string, options?: { + removeDependencyReferences?: boolean; + removeLineageReferences?: boolean; + githubIssueAction?: GithubIssueAction; + }) => Promise; onOpenDetailWithTab?: (task: Task | TaskDetail, initialTab: "changes" | "retries" | "workflow") => void; /** Project-level stuck task timeout in milliseconds (undefined = disabled) */ taskStuckTimeoutMs?: number; @@ -38,6 +52,12 @@ interface WorktreeGroupProps { prAuthAvailable?: boolean; /** Whether project-level auto-merge is enabled, which hides manual Create PR card actions. */ autoMergeEnabled?: boolean; + /** Project merge strategy for Task Detail-equivalent card context actions. */ + mergeStrategy?: string; + /** Ordered workflow columns for deriving context-menu move targets in workflow mode. */ + workflowContextMenuColumns?: readonly TaskContextMenuColumnMetadata[]; + /** Per-task workflow columns for aggregate Board cards whose tasks come from different workflows. */ + taskContextMenuColumnsByTaskId?: ReadonlyMap; } function WorktreeGroupComponent({ @@ -47,10 +67,19 @@ function WorktreeGroupComponent({ allTasks, projectId, onOpenDetail, + onMoveTask, addToast, globalPaused, onUpdateTask, + onPauseTask, onRetryTask, + onUnpauseTask, + onResetTask, + onDuplicateTask, + onMergeTask, + onArchiveTask, + onUnarchiveTask, + onDeleteTask, onOpenDetailWithTab, taskStuckTimeoutMs, onOpenMission, @@ -60,6 +89,9 @@ function WorktreeGroupComponent({ blockerFanoutMap, prAuthAvailable, autoMergeEnabled, + mergeStrategy = "direct", + workflowContextMenuColumns, + taskContextMenuColumnsByTaskId, }: WorktreeGroupProps) { const { t } = useTranslation("app"); const upNextLabel = t("worktree.upNext", "Up Next"); @@ -69,6 +101,8 @@ function WorktreeGroupComponent({ if (typeof nearDuplicateOf !== "string" || !allTasks) return undefined; return isNearDuplicateCanonicalInactive(allTasks.find((candidate) => candidate.id === nearDuplicateOf)); }; + const getTaskContextMenuColumns = (task: Task) => taskContextMenuColumnsByTaskId?.get(task.id) ?? workflowContextMenuColumns; + const getTaskColumnFlags = (task: Task) => getTaskContextMenuColumns(task)?.find((candidate) => candidate.id === task.column)?.flags; return (
@@ -84,10 +118,21 @@ function WorktreeGroupComponent({ task={task} projectId={projectId} onOpenDetail={onOpenDetail} + onMoveTask={onMoveTask} + taskColumnFlags={getTaskColumnFlags(task)} + taskMoveColumns={getTaskContextMenuColumns(task)} addToast={addToast} globalPaused={globalPaused} onUpdateTask={onUpdateTask} + onPauseTask={onPauseTask} onRetryTask={onRetryTask} + onUnpauseTask={onUnpauseTask} + onResetTask={onResetTask} + onDuplicateTask={onDuplicateTask} + onMergeTask={onMergeTask} + onArchiveTask={onArchiveTask} + onUnarchiveTask={onUnarchiveTask} + onDeleteTask={onDeleteTask} onOpenDetailWithTab={onOpenDetailWithTab} taskStuckTimeoutMs={taskStuckTimeoutMs} onOpenMission={onOpenMission} @@ -97,6 +142,7 @@ function WorktreeGroupComponent({ fanout={blockerFanoutMap?.get(task.id)} prAuthAvailable={prAuthAvailable} autoMergeEnabled={autoMergeEnabled} + mergeStrategy={mergeStrategy} nearDuplicateCanonicalInactive={resolveNearDuplicateCanonicalInactive(task)} /> ))} @@ -107,10 +153,21 @@ function WorktreeGroupComponent({ projectId={projectId} queued onOpenDetail={onOpenDetail} + onMoveTask={onMoveTask} + taskColumnFlags={getTaskColumnFlags(task)} + taskMoveColumns={getTaskContextMenuColumns(task)} addToast={addToast} globalPaused={globalPaused} onUpdateTask={onUpdateTask} + onPauseTask={onPauseTask} onRetryTask={onRetryTask} + onUnpauseTask={onUnpauseTask} + onResetTask={onResetTask} + onDuplicateTask={onDuplicateTask} + onMergeTask={onMergeTask} + onArchiveTask={onArchiveTask} + onUnarchiveTask={onUnarchiveTask} + onDeleteTask={onDeleteTask} onOpenDetailWithTab={onOpenDetailWithTab} taskStuckTimeoutMs={taskStuckTimeoutMs} onOpenMission={onOpenMission} @@ -120,6 +177,7 @@ function WorktreeGroupComponent({ fanout={blockerFanoutMap?.get(task.id)} prAuthAvailable={prAuthAvailable} autoMergeEnabled={autoMergeEnabled} + mergeStrategy={mergeStrategy} nearDuplicateCanonicalInactive={resolveNearDuplicateCanonicalInactive(task)} /> ))} diff --git a/packages/dashboard/app/components/__tests__/ListView.test.tsx b/packages/dashboard/app/components/__tests__/ListView.test.tsx index 87393a4b92..af1839a104 100644 --- a/packages/dashboard/app/components/__tests__/ListView.test.tsx +++ b/packages/dashboard/app/components/__tests__/ListView.test.tsx @@ -27,6 +27,8 @@ vi.mock("../../api", () => ({ batchUpdateTaskModels: vi.fn(), fetchNodes: vi.fn(() => new Promise(() => {})), fetchBoardWorkflows: vi.fn(() => new Promise(() => {})), + rebuildTaskSpec: vi.fn().mockResolvedValue({}), + refreshPrStatus: vi.fn().mockResolvedValue({}), api: vi.fn().mockResolvedValue({ sessions: [] }), })); @@ -175,7 +177,7 @@ vi.mock("../TaskDetailModal", () => ({ ), })); -import { fetchTaskDetail, batchUpdateTaskModels, fetchBoardWorkflows, fetchNodes } from "../../api"; +import { fetchTaskDetail, batchUpdateTaskModels, fetchBoardWorkflows, fetchNodes, refreshPrStatus } from "../../api"; const mockConfirm = vi.fn(); const mockConfirmWithChoice = vi.fn(); @@ -363,6 +365,7 @@ describe("ListView", () => { ...createMockTask(), prompt: "# Detail", } as TaskDetail); + vi.mocked(refreshPrStatus).mockResolvedValue({} as any); mockConfirm.mockReset(); mockConfirmWithChoice.mockReset(); subscribeSseMock.mockClear(); @@ -614,6 +617,160 @@ describe("ListView", () => { viewportSpy.mockRestore(); }); + it("opens the task context menu from desktop row right-click without selecting or opening detail", async () => { + const viewportSpy = mockDesktopViewport(); + const onOpenDetail = vi.fn(); + const onPauseTask = vi.fn(async () => createMockTask()); + const onUnpauseTask = vi.fn(async () => createMockTask()); + const onRetryTask = vi.fn(async () => createMockTask()); + const onArchiveTask = vi.fn(async () => createMockTask()); + const onMoveTask = vi.fn(async () => createMockTask()); + const tasks = [ + createMockTask({ id: "FN-001", title: "Failed retryable", column: "todo", status: "failed" }), + createMockTask({ id: "FN-002", title: "Paused task", column: "todo", paused: true }), + createMockTask({ id: "FN-003", title: "Review task", column: "in-review" }), + createMockTask({ id: "FN-004", title: "Done task", column: "done", status: "done" }), + createMockTask({ id: "FN-005", title: "Archived task", column: "archived", status: "done" }), + createMockTask({ id: "FN-006", title: "PR review", column: "in-review", prInfo: { number: 6, url: "https://example.test/pr/6", status: "open" } as any }), + createMockTask({ id: "FN-007", title: "Progress move", column: "in-progress", steps: [{ id: "s1", title: "done", status: "done" } as any] }), + ]; + + renderListView({ tasks, onOpenDetail, onPauseTask, onUnpauseTask, onRetryTask, onArchiveTask, onMoveTask }); + + const failedRow = document.querySelector('.list-row[data-id="FN-001"]') as HTMLElement; + fireEvent.contextMenu(failedRow, { clientX: 40, clientY: 50 }); + + expect(screen.getByRole("menu")).toBeInTheDocument(); + expect(screen.getByRole("menuitem", { name: "Retry" })).toBeInTheDocument(); + expect(screen.getByRole("menuitem", { name: "Pause" })).toBeInTheDocument(); + expect(screen.getByRole("menuitem", { name: "Move to In Progress" })).toBeInTheDocument(); + expect(failedRow).not.toHaveClass("list-row--selected"); + expect(onOpenDetail).not.toHaveBeenCalled(); + expect(fetchTaskDetail).not.toHaveBeenCalled(); + + fireEvent.contextMenu(document.querySelector('.list-row[data-id="FN-002"]') as HTMLElement, { clientX: 40, clientY: 50 }); + expect(screen.getByRole("menuitem", { name: "Unpause" })).toBeInTheDocument(); + + fireEvent.contextMenu(document.querySelector('.list-row[data-id="FN-003"]') as HTMLElement, { clientX: 40, clientY: 50 }); + expect(screen.getByRole("menuitem", { name: "Merge & Close" })).toBeInTheDocument(); + expect(screen.queryByRole("menuitem", { name: "Refine" })).not.toBeInTheDocument(); + expect(screen.getByRole("menuitem", { name: "Back to In Progress" })).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(); + + fireEvent.contextMenu(document.querySelector('.list-row[data-id="FN-004"]') as HTMLElement, { clientX: 40, clientY: 50 }); + expect(screen.queryByRole("menuitem", { name: "Refine" })).not.toBeInTheDocument(); + expect(onOpenDetail).not.toHaveBeenCalled(); + + fireEvent.contextMenu(document.querySelector('.list-row[data-id="FN-004"]') as HTMLElement, { clientX: 40, clientY: 50 }); + expect(screen.getByRole("menuitem", { name: "Archive" })).toBeInTheDocument(); + + fireEvent.contextMenu(document.querySelector('.list-row[data-id="FN-005"]') as HTMLElement, { clientX: 40, clientY: 50 }); + expect(screen.getByRole("menuitem", { name: "Move to Done" })).toBeInTheDocument(); + + fireEvent.keyDown(document, { key: "Escape" }); + expect(screen.queryByRole("menu")).not.toBeInTheDocument(); + + const reviewRow = document.querySelector('.list-row[data-id="FN-003"]') as HTMLElement; + reviewRow.focus(); + fireEvent.keyDown(reviewRow, { key: "ContextMenu" }); + expect(screen.getByRole("menuitem", { name: "Merge & Close" })).toBeInTheDocument(); + + 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 Todo" })); + await waitFor(() => expect(onMoveTask).toHaveBeenCalledWith("FN-007", "todo", { preserveProgress: true })); + + fireEvent.contextMenu(document.querySelector('.list-row[data-id="FN-005"]') as HTMLElement, { clientX: 40, clientY: 50 }); + fireEvent.click(screen.getByRole("menuitem", { name: "Move to Done" })); + expect(onPauseTask).not.toHaveBeenCalled(); + expect(onRetryTask).not.toHaveBeenCalled(); + expect(onArchiveTask).not.toHaveBeenCalled(); + viewportSpy.mockRestore(); + }); + + it("matches detail PR review labels from list context menus before and during PR automation", () => { + const viewportSpy = mockDesktopViewport(); + const tasks = [ + createMockTask({ id: "FN-008", title: "Manual PR", column: "in-review" }), + createMockTask({ id: "FN-009", title: "Creating PR", column: "in-review", status: "creating-pr" }), + createMockTask({ id: "FN-010", title: "Open PR", column: "in-review", prInfo: { number: 10, url: "https://example.test/pr/10", status: "open" } as any }), + ]; + + renderListView({ tasks, autoMerge: false, mergeStrategy: "pull-request" }); + + fireEvent.contextMenu(document.querySelector('.list-row[data-id="FN-008"]') as HTMLElement, { clientX: 40, clientY: 50 }); + expect(screen.getByRole("menuitem", { name: "Start PR Review" })).toBeInTheDocument(); + expect(screen.queryByRole("menuitem", { name: "Merge & Close" })).not.toBeInTheDocument(); + + fireEvent.contextMenu(document.querySelector('.list-row[data-id="FN-009"]') as HTMLElement, { clientX: 40, clientY: 50 }); + expect(screen.getByRole("menuitem", { name: "Creating PR…" })).toBeDisabled(); + expect(screen.queryByRole("menuitem", { name: "Merge & Close" })).not.toBeInTheDocument(); + + fireEvent.contextMenu(document.querySelector('.list-row[data-id="FN-010"]') as HTMLElement, { clientX: 40, clientY: 50 }); + fireEvent.click(screen.getByRole("menuitem", { name: "Check PR Status" })); + expect(refreshPrStatus).toHaveBeenCalledWith("FN-010", TEST_PROJECT_ID); + viewportSpy.mockRestore(); + }); + + it("does not attach context menus to headers, empty sections, or bulk-edit checkboxes", () => { + const viewportSpy = mockDesktopViewport(); + const tasks = [createMockTask({ id: "FN-001", title: "Selectable", column: "todo" })]; + renderListView({ tasks }); + enterBulkEditMode(); + + const checkbox = screen.getByRole("checkbox", { name: "Select FN-001" }); + expect(checkbox).not.toBeChecked(); + fireEvent.contextMenu(checkbox, { clientX: 20, clientY: 20 }); + expect(screen.queryByRole("menu")).not.toBeInTheDocument(); + expect(checkbox).not.toBeChecked(); + + const selectedRow = document.querySelector('.list-row[data-id="FN-001"]') as HTMLElement; + fireEvent.contextMenu(selectedRow, { clientX: 40, clientY: 50 }); + expect(screen.getByRole("menu")).toBeInTheDocument(); + expect(checkbox).not.toBeChecked(); + + fireEvent.pointerDown(document.body); + const planningHeader = screen.getAllByRole("row").find((row) => row.className.includes("list-section-header") && row.textContent?.includes("Planning")) as HTMLElement; + fireEvent.contextMenu(planningHeader, { clientX: 20, clientY: 20 }); + expect(screen.queryByRole("menu")).not.toBeInTheDocument(); + + const doneHeader = screen.getAllByRole("row").find((row) => row.className.includes("list-section-header") && row.textContent?.includes("Done")) as HTMLElement; + fireEvent.click(doneHeader); + fireEvent.contextMenu(doneHeader, { clientX: 20, clientY: 20 }); + expect(screen.queryByRole("menu")).not.toBeInTheDocument(); + expect(document.querySelector('.list-row[data-id="FN-001"]')).toBeInTheDocument(); + viewportSpy.mockRestore(); + }); + + it("opens the task context menu from mobile card long-press without ordinary tap-to-open", () => { + vi.useFakeTimers(); + const viewportSpy = mockMobileViewport(); + const onOpenDetail = vi.fn(); + const onPauseTask = vi.fn(async () => createMockTask()); + const tasks = [createMockTask({ id: "FN-001", title: "Mobile menu", column: "todo" })]; + + renderListView({ tasks, onOpenDetail, onPauseTask }); + + const card = document.querySelector('.list-card[data-id="FN-001"]') as HTMLElement; + fireEvent.pointerDown(card, { pointerType: "touch", pointerId: 1, clientX: 24, clientY: 32 }); + act(() => { + vi.advanceTimersByTime(550); + }); + fireEvent.pointerUp(card, { pointerType: "touch", pointerId: 1, clientX: 24, clientY: 32 }); + fireEvent.click(card); + + expect(screen.getByRole("menu")).toBeInTheDocument(); + expect(screen.getByRole("menuitem", { name: "Pause" })).toBeInTheDocument(); + expect(onOpenDetail).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole("menuitem", { name: "Pause" })); + expect(onPauseTask).toHaveBeenCalledWith("FN-001"); + viewportSpy.mockRestore(); + vi.useRealTimers(); + }); + it("exposes view options controls on mobile", () => { const viewportSpy = mockMobileViewport(); localStorage.setItem(scopedStorageKey("kb-dashboard-hide-done"), "false"); diff --git a/packages/dashboard/app/components/__tests__/TaskCard.cli-states.test.tsx b/packages/dashboard/app/components/__tests__/TaskCard.cli-states.test.tsx index 0a36e15492..b1f16d1b79 100644 --- a/packages/dashboard/app/components/__tests__/TaskCard.cli-states.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskCard.cli-states.test.tsx @@ -39,6 +39,7 @@ vi.mock("../../api", () => ({ fetchMission: vi.fn(), fetchAgent: vi.fn(), fetchAgents: vi.fn(), + rebuildTaskSpec: vi.fn(), })); vi.mock("../../hooks/useConfirm", () => ({ diff --git a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx index f55f38c22b..01b0a351a8 100644 --- a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx @@ -82,6 +82,8 @@ vi.mock("../../api", () => ({ fetchMission: vi.fn(), fetchAgent: vi.fn(), fetchAgents: vi.fn(), + rebuildTaskSpec: vi.fn(), + refreshPrStatus: vi.fn(), })); const mockConfirm = vi.fn<(options: ConfirmOptions) => Promise>(); @@ -90,7 +92,7 @@ vi.mock("../../hooks/useConfirm", () => ({ useConfirm: () => ({ confirm: mockConfirm, confirmWithChoice: mockConfirmWithChoice }), })); -import { addressPrFeedback, uploadAttachment, fetchMission, fetchAgent, fetchAgents } from "../../api"; +import { addressPrFeedback, uploadAttachment, fetchMission, fetchAgent, fetchAgents, refreshPrStatus } from "../../api"; import { loadAllAppCss, loadAllAppCssBaseOnly } from "../../test/cssFixture"; import { writeCache, SWR_CACHE_KEYS } from "../../utils/swrCache"; @@ -169,6 +171,7 @@ afterEach(() => { mockConfirm.mockReset(); mockConfirmWithChoice.mockReset(); vi.mocked(addressPrFeedback).mockReset(); + vi.mocked(refreshPrStatus).mockReset(); }); describe("TaskCard", () => { @@ -189,6 +192,169 @@ describe("TaskCard", () => { expect(onOpenDetailWithTab.mock.calls[0][1]).toBe("workflow"); }); + it("opens the board card context menu on right-click without opening detail", async () => { + const onOpenDetail = vi.fn(); + const onPauseTask = vi.fn(async () => makeTask({ paused: true })); + render( + , + ); + + fireEvent.contextMenu(document.querySelector(".card")!, { clientX: 24, clientY: 28 }); + expect(screen.getByRole("menu")).toBeInTheDocument(); + expect(onOpenDetail).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole("menuitem", { name: "Pause" })); + await waitFor(() => expect(onPauseTask).toHaveBeenCalledWith("FN-001")); + expect(onOpenDetail).not.toHaveBeenCalled(); + }); + + it("opens the board card context menu from keyboard without opening detail", () => { + const onOpenDetail = vi.fn(); + render( + , + ); + + const card = document.querySelector(".card") as HTMLElement; + card.focus(); + fireEvent.keyDown(card, { key: "F10", shiftKey: true }); + + expect(screen.getByRole("menu")).toBeInTheDocument(); + expect(screen.getByRole("menuitem", { name: "Archive" })).toBeInTheDocument(); + expect(onOpenDetail).not.toHaveBeenCalled(); + }); + + it("confirms preserving progress before moving from the board context menu", async () => { + const onMoveTask = vi.fn(async () => makeTask({ column: "todo" })); + mockConfirm.mockResolvedValueOnce(true); + render( + , + ); + + fireEvent.contextMenu(document.querySelector(".card")!, { clientX: 24, clientY: 28 }); + fireEvent.click(screen.getByRole("menuitem", { name: "Move to Todo" })); + + await waitFor(() => expect(onMoveTask).toHaveBeenCalledWith("FN-001", "todo", { preserveProgress: true })); + expect(mockConfirm).toHaveBeenCalledWith(expect.objectContaining({ title: "Preserve Progress?" })); + }); + + it("omits refine without a real modal callback and offers PR status actions from the board context menu", async () => { + const onOpenDetail = vi.fn(); + vi.mocked(refreshPrStatus).mockResolvedValueOnce({} as any); + render( + , + ); + + fireEvent.contextMenu(document.querySelector(".card")!, { clientX: 24, clientY: 28 }); + expect(screen.queryByRole("menuitem", { name: "Refine" })).not.toBeInTheDocument(); + expect(onOpenDetail).not.toHaveBeenCalled(); + + fireEvent.contextMenu(document.querySelector(".card")!, { clientX: 24, clientY: 28 }); + fireEvent.click(screen.getByRole("menuitem", { name: "Check PR Status" })); + await waitFor(() => expect(refreshPrStatus).toHaveBeenCalledWith("FN-001", "project-1")); + }); + + it("matches detail PR review labels before and during PR automation", () => { + const onMergeTask = vi.fn(async () => ({ merged: false })); + const { rerender } = render( + , + ); + + fireEvent.contextMenu(document.querySelector(".card")!, { clientX: 24, clientY: 28 }); + expect(screen.getByRole("menuitem", { name: "Start PR Review" })).toBeInTheDocument(); + expect(screen.queryByRole("menuitem", { name: "Merge & Close" })).not.toBeInTheDocument(); + + rerender( + , + ); + + fireEvent.contextMenu(document.querySelector(".card")!, { clientX: 24, clientY: 28 }); + expect(screen.getByRole("menuitem", { name: "Creating PR…" })).toBeDisabled(); + expect(screen.queryByRole("menuitem", { name: "Merge & Close" })).not.toBeInTheDocument(); + }); + + it("opens the board card context menu on touch long-press and suppresses detail click", () => { + vi.useFakeTimers(); + const onOpenDetail = vi.fn(); + render( + makeTask())} + />, + ); + + const card = document.querySelector(".card") as HTMLElement; + fireEvent.pointerDown(card, { pointerType: "touch", pointerId: 1, clientX: 16, clientY: 16 }); + act(() => vi.advanceTimersByTime(550)); + + expect(screen.getByRole("menu")).toBeInTheDocument(); + fireEvent.pointerUp(card, { pointerType: "touch", pointerId: 1, clientX: 16, clientY: 16 }); + fireEvent.click(card); + expect(onOpenDetail).not.toHaveBeenCalled(); + }); + + it("cancels board card long-press when touch moves before the delay", () => { + vi.useFakeTimers(); + render( + , + ); + + const card = document.querySelector(".card") as HTMLElement; + fireEvent.pointerDown(card, { pointerType: "touch", pointerId: 1, clientX: 16, clientY: 16 }); + fireEvent.pointerMove(card, { pointerType: "touch", pointerId: 1, clientX: 40, clientY: 16 }); + act(() => vi.advanceTimersByTime(550)); + + expect(screen.queryByRole("menu")).toBeNull(); + }); + it("does not show the Answer-questions button when not awaiting input", () => { render( { ).toBe(false); }); + it("returns false when board context-menu action handlers change", () => { + const task = makeTask(); + const actionHandler = vi.fn(); + + expect( + __test_areTaskCardPropsEqual( + { task, onOpenDetail: noop, addToast: noop, onPauseTask: actionHandler } as any, + { task, onOpenDetail: noop, addToast: noop, onUnpauseTask: actionHandler } as any, + ), + ).toBe(false); + }); + it("returns false when sourceMetadata.agentName changes", () => { const previousTask = makeTask({ sourceType: "automation", sourceMetadata: { agentName: "Agent One" } }); const nextTask = makeTask({ sourceType: "automation", sourceMetadata: { agentName: "Agent Two" } }); diff --git a/packages/dashboard/app/components/__tests__/TaskContextMenu.test.tsx b/packages/dashboard/app/components/__tests__/TaskContextMenu.test.tsx new file mode 100644 index 0000000000..cc09cd959b --- /dev/null +++ b/packages/dashboard/app/components/__tests__/TaskContextMenu.test.tsx @@ -0,0 +1,205 @@ +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"; + +const t = ((key: string, fallback: string, vars?: Record) => { + if (!vars) return fallback; + return fallback.replace(/{{(\w+)}}/g, (_, name: string) => vars[name] ?? ""); +}) as any; +const columnLabel = (column: string) => column; + +function makeTask(overrides: Partial = {}): Task { + return { + id: "FN-7255", + title: "Context menu task", + column: "in-progress", + status: undefined as any, + steps: [], + dependencies: [], + description: "", + ...overrides, + } as Task; +} + +function actionIds(task: Task, overrides: Partial[0]> = {}): string[] { + return buildTaskActionMenuModel({ task, t, columnLabel: columnLabel as any, ...overrides }).actions.map((action) => action.id); +} + +describe("TaskContextMenu shared task action model", () => { + it("mirrors detail Actions menu availability across lifecycle states", () => { + expect(actionIds(makeTask({ column: "triage" }))).toEqual(["delete", "respecify", "pause"]); + expect(buildTaskActionMenuModel({ task: makeTask({ column: "triage" }), t, columnLabel: columnLabel as any }).shouldShowActionsMenu).toBe(false); + + expect(actionIds(makeTask({ column: "triage", status: "failed" as any }), { canRetryTask: true, hasRetryHandler: true })).toContain("retry"); + expect(buildTaskActionMenuModel({ task: makeTask({ column: "triage", status: "failed" as any }), t, columnLabel: columnLabel as any, canRetryTask: true, hasRetryHandler: true }).shouldShowActionsMenu).toBe(true); + + expect(actionIds(makeTask({ column: "in-review" }), { hasDuplicateHandler: true, hasResetHandler: true, onOpenRefine: vi.fn() })).toEqual([ + "delete", + "duplicate", + "refine", + "respecify", + "reset", + "pause", + ]); + expect(actionIds(makeTask({ column: "done" }), { hasResetHandler: true, onOpenRefine: vi.fn() })).toEqual(["delete", "refine", "respecify"]); + expect(actionIds(makeTask({ column: "done" }), { hasResetHandler: true })).toEqual(["delete", "respecify"]); + expect(actionIds(makeTask({ column: "archived" }), { hasResetHandler: true })).toEqual(["delete", "respecify"]); + }); + + it("exposes pause, unpause, and paused-by-agent note with detail labels", () => { + const active = buildTaskActionMenuModel({ task: makeTask(), t, columnLabel: columnLabel as any }); + expect(active.actions.find((action) => action.id === "pause")?.label).toBe("Pause"); + + const paused = buildTaskActionMenuModel({ + task: makeTask({ paused: true, pausedByAgentId: "agent-1" } as Partial), + t, + columnLabel: columnLabel as any, + }); + expect(paused.actions.map((action) => [action.id, action.label, action.tone])).toContainEqual([ + "unpause", + "Unpause", + undefined, + ]); + expect(paused.actions.map((action) => [action.id, action.label, action.tone])).toContainEqual([ + "paused-by-agent", + "Paused by agent", + "note", + ]); + }); + + it("uses VALID_TRANSITIONS and in-review back-to-progress labels for move actions", () => { + const todoMoves = buildTaskActionMenuModel({ task: makeTask({ column: "todo" }), t, columnLabel: columnLabel as any }).moveTransitions; + expect(todoMoves.map((action) => action.column)).toEqual(["in-progress", "triage", "archived"]); + expect(todoMoves.map((action) => action.label)).toEqual(["Move to in-progress", "Move to triage", "Move to archived"]); + + const reviewMoves = buildTaskActionMenuModel({ task: makeTask({ column: "in-review" }), t, columnLabel: columnLabel as any }).moveTransitions; + expect(reviewMoves.map((action) => [action.column, action.label])).toEqual([ + ["todo", "Move to todo"], + ["in-progress", "Back to In Progress"], + ]); + }); + + it("derives custom workflow moves and terminal action availability from column metadata", () => { + const workflowMoveColumns = [ + { id: "intake", label: "Intake", flags: { intake: true } }, + { id: "build", label: "Build", flags: { countsTowardWip: true } }, + { id: "qa", label: "QA", flags: { humanReview: true } }, + { id: "complete", label: "Complete", flags: { complete: true } }, + { id: "cold-storage", label: "Cold Storage", flags: { archived: true } }, + ]; + + const buildModel = buildTaskActionMenuModel({ + task: makeTask({ column: "build" }), + t, + columnLabel: columnLabel as any, + currentColumnFlags: workflowMoveColumns[1].flags, + workflowMoveColumns, + hasResetHandler: true, + }); + expect(buildModel.moveTransitions.map((action) => [action.column, action.label])).toEqual([ + ["intake", "Move to Intake"], + ["qa", "Move to QA"], + ]); + expect(buildModel.actions.map((action) => action.id)).toContain("reset"); + expect(buildModel.actions.map((action) => action.id)).toContain("pause"); + + const completeModel = buildTaskActionMenuModel({ + task: makeTask({ column: "complete" }), + t, + columnLabel: columnLabel as any, + currentColumnFlags: workflowMoveColumns[3].flags, + workflowMoveColumns, + hasResetHandler: true, + onOpenRefine: vi.fn(), + }); + expect(completeModel.actions.map((action) => action.id)).toEqual(["delete", "refine", "respecify"]); + expect(completeModel.moveTransitions.map((action) => action.column)).toEqual(["qa", "cold-storage"]); + + const archivedModel = buildTaskActionMenuModel({ + task: makeTask({ column: "cold-storage" }), + t, + columnLabel: columnLabel as any, + currentColumnFlags: workflowMoveColumns[4].flags, + workflowMoveColumns, + hasResetHandler: true, + }); + expect(archivedModel.actions.map((action) => action.id)).toEqual(["delete", "respecify"]); + }); + + it("mirrors in-review merge and manual PR status actions", () => { + expect(buildTaskActionMenuModel({ task: makeTask({ column: "in-review" }), t, columnLabel: columnLabel as any }).reviewAction).toMatchObject({ + id: "merge", + label: "Merge & Close", + }); + + const onMerge = vi.fn(); + const onStartPrReview = vi.fn(); + const startPrReviewAction = buildTaskActionMenuModel({ + task: makeTask({ column: "in-review" }), + t, + columnLabel: columnLabel as any, + mergeStrategy: "pull-request", + autoMergeEnabled: false, + onMerge, + onStartPrReview, + }).reviewAction; + expect(startPrReviewAction).toMatchObject({ id: "start-pr-review", label: "Start PR Review" }); + startPrReviewAction?.onSelect?.(); + expect(onStartPrReview).toHaveBeenCalledTimes(1); + expect(onMerge).not.toHaveBeenCalled(); + + expect(buildTaskActionMenuModel({ + task: makeTask({ column: "in-review", prInfo: { status: "open" } as any }), + t, + columnLabel: columnLabel as any, + mergeStrategy: "pull-request", + autoMergeEnabled: false, + isCheckingPrStatus: true, + }).reviewAction).toMatchObject({ id: "check-pr-status", label: "Check PR Status", disabled: true }); + + expect(buildTaskActionMenuModel({ + task: makeTask({ column: "in-review", status: "merging-pr" as any }), + t, + columnLabel: columnLabel as any, + prAutomationLabel: "Merging PR…", + }).reviewAction).toMatchObject({ id: "pr-automation", label: "Merging PR…", disabled: true }); + }); + + it("renders descriptors and delegates selection to injected host handlers", () => { + const onDelete = vi.fn(); + const onActionSelect = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole("menuitem", { name: "Delete" })); + expect(onActionSelect).toHaveBeenCalledWith(expect.objectContaining({ id: "delete" })); + expect(onDelete).toHaveBeenCalledTimes(1); + }); + + it("focuses the first enabled action and supports arrow-key roving", () => { + render( + , + ); + + const pause = screen.getByRole("menuitem", { name: "Pause" }); + const del = screen.getByRole("menuitem", { name: "Delete" }); + expect(pause).toHaveFocus(); + + fireEvent.keyDown(screen.getByRole("menu"), { key: "ArrowDown" }); + expect(del).toHaveFocus(); + fireEvent.keyDown(screen.getByRole("menu"), { key: "ArrowDown" }); + expect(pause).toHaveFocus(); + }); +}); diff --git a/packages/dashboard/app/components/__tests__/board-mobile.test.tsx b/packages/dashboard/app/components/__tests__/board-mobile.test.tsx index e90cde0bbf..dc83ba7eb4 100644 --- a/packages/dashboard/app/components/__tests__/board-mobile.test.tsx +++ b/packages/dashboard/app/components/__tests__/board-mobile.test.tsx @@ -26,6 +26,7 @@ vi.mock("../../api", () => ({ } satisfies Partial), updateGlobalSettings: vi.fn(), fetchAgents: vi.fn().mockResolvedValue([]), + rebuildTaskSpec: vi.fn(), // InlineCreateCard renders WorkflowSelector, which loads these on mount. fetchWorkflows: vi.fn().mockResolvedValue([]), fetchWorkflowOptionalSteps: vi.fn().mockResolvedValue([]), diff --git a/packages/dashboard/app/components/dashboard/MainContent.tsx b/packages/dashboard/app/components/dashboard/MainContent.tsx index a0806cb14d..56690bf837 100644 --- a/packages/dashboard/app/components/dashboard/MainContent.tsx +++ b/packages/dashboard/app/components/dashboard/MainContent.tsx @@ -71,6 +71,7 @@ export function MainContent({ openFileInBrowser, prAuthAvailable, autoMerge, + mergeStrategy, settingsLoaded, skillsEnabled, experimentalFeatures, @@ -694,10 +695,15 @@ export function MainContent({ onPlanningMode={openPlanningWithInitialPlanWithNav} onSubtaskBreakdown={subtaskBreakdownEnabled ? openSubtaskBreakdownWithNav : undefined} autoMerge={autoMerge} + mergeStrategy={mergeStrategy} onToggleAutoMerge={toggleAutoMerge} globalPaused={globalPaused} onUpdateTask={updateTask} onRetryTask={retryTask} + onUnpauseTask={unpauseTask} + onResetTask={resetTask} + onDuplicateTask={duplicateTask} + onMergeTask={mergeTask} onArchiveTask={archiveTask} onUnarchiveTask={unarchiveTask} onDeleteTask={deleteTask} @@ -790,10 +796,15 @@ export function MainContent({ onPlanningMode={openPlanningWithInitialPlanWithNav} onSubtaskBreakdown={subtaskBreakdownEnabled ? openSubtaskBreakdownWithNav : undefined} autoMerge={autoMerge} + mergeStrategy={mergeStrategy} onToggleAutoMerge={toggleAutoMerge} globalPaused={globalPaused} onUpdateTask={updateTask} onRetryTask={retryTask} + onUnpauseTask={unpauseTask} + onResetTask={resetTask} + onDuplicateTask={duplicateTask} + onMergeTask={mergeTask} onArchiveTask={archiveTask} onUnarchiveTask={unarchiveTask} onDeleteTask={deleteTask} @@ -854,6 +865,7 @@ export function MainContent({ lastFetchTimeMs={lastFetchTimeMs} prAuthAvailable={prAuthAvailable} autoMerge={autoMerge} + mergeStrategy={mergeStrategy} onOpenWorkflowEditor={openWorkflowEditorWithNav} onCreateWorkflow={openCreateWorkflowWithNav} workflowColumnsEnabled diff --git a/packages/dashboard/app/components/dashboard/__tests__/MainContent.graph-popout.test.tsx b/packages/dashboard/app/components/dashboard/__tests__/MainContent.graph-popout.test.tsx index bda2c76805..5514531502 100644 --- a/packages/dashboard/app/components/dashboard/__tests__/MainContent.graph-popout.test.tsx +++ b/packages/dashboard/app/components/dashboard/__tests__/MainContent.graph-popout.test.tsx @@ -106,6 +106,7 @@ function mainContentProps(overrides: Partial = {}): MainConten workflowStepNameLookup: new Map(), prAuthAvailable: false, autoMerge: true, + mergeStrategy: "direct", settingsLoaded: true, skillsEnabled: true, experimentalFeatures: {}, diff --git a/packages/dashboard/app/components/dashboard/types.ts b/packages/dashboard/app/components/dashboard/types.ts index a2ff9a09a2..66d610725b 100644 --- a/packages/dashboard/app/components/dashboard/types.ts +++ b/packages/dashboard/app/components/dashboard/types.ts @@ -111,6 +111,7 @@ export interface MainContentProps { openFileInBrowser: (path: string, opts?: { workspace?: string; line?: number; col?: number }) => void; prAuthAvailable: boolean; autoMerge: boolean; + mergeStrategy: string; settingsLoaded: boolean; skillsEnabled: boolean; experimentalFeatures: Record; diff --git a/packages/dashboard/app/hooks/useAppSettings.ts b/packages/dashboard/app/hooks/useAppSettings.ts index 62ab7b400a..d9854a3c7b 100644 --- a/packages/dashboard/app/hooks/useAppSettings.ts +++ b/packages/dashboard/app/hooks/useAppSettings.ts @@ -11,6 +11,7 @@ export interface UseAppSettingsResult { maxConcurrent: number; rootDir: string; autoMerge: boolean; + mergeStrategy: string; showWorktreeGrouping: boolean; testMode: boolean; isTestMode: boolean; @@ -53,6 +54,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult { const [maxConcurrent, setMaxConcurrent] = useState(2); const [rootDir, setRootDir] = useState("."); const [autoMerge, setAutoMerge] = useState(true); + const [mergeStrategy, setMergeStrategy] = useState("direct"); const [showWorktreeGrouping, setShowWorktreeGrouping] = useState(false); const [testMode, setTestMode] = useState(false); const [isTestMode, setIsTestMode] = useState(false); @@ -98,6 +100,11 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult { if (settingsResult.status === "fulfilled") { const settings = settingsResult.value; setAutoMerge(Boolean(settings.autoMerge)); + /* + FNXC:BoardCardActions 2026-06-30-00:42: + Board and List context menus need the project merge strategy before PR creation so manual PR projects can show Start PR Review with the same availability as Task Detail. + */ + setMergeStrategy(typeof settings.mergeStrategy === "string" ? settings.mergeStrategy : "direct"); setShowWorktreeGrouping(settings.showWorktreeGrouping === true); const nextTestMode = settings.testMode === true; const nextIsTestMode = nextTestMode || settings.defaultProvider?.trim().toLowerCase() === "mock"; @@ -245,6 +252,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult { maxConcurrent, rootDir, autoMerge, + mergeStrategy, showWorktreeGrouping, testMode, isTestMode,