FN-7255: add task card context menus
Add shared task action menus across board and list task surfaces. - Add reusable task context menu UI with open, copy, archive, delete, pause, duplicate, retry, refine, and dependency actions. - Wire board cards, swimlanes, worktree groups, and list rows to expose consistent menu behavior. - Reuse task-detail action handlers and cover menu interactions with dashboard tests. - Document the new task context menu behavior and add a patch changeset. Files changed: .changeset/fn-7255-card-context-menu.md | 7 + docs/dashboard-guide.md | 6 + packages/dashboard/app/App.tsx | 2 + packages/dashboard/app/components/Board.tsx | 61 ++- packages/dashboard/app/components/Column.tsx | 43 +- packages/dashboard/app/components/Lane.tsx | 11 +- packages/dashboard/app/components/ListView.css | 25 ++ packages/dashboard/app/components/ListView.tsx | 438 ++++++++++++++++++- packages/dashboard/app/components/TaskCard.css | 12 + packages/dashboard/app/components/TaskCard.tsx | 472 ++++++++++++++++++++- .../dashboard/app/components/TaskContextMenu.css | 57 +++ .../dashboard/app/components/TaskContextMenu.tsx | 346 +++++++++++++++ .../dashboard/app/components/TaskDetailModal.tsx | 235 ++++------ .../dashboard/app/components/WorktreeGroup.tsx | 60 ++- .../app/components/__tests__/ListView.test.tsx | 159 ++++++- .../__tests__/TaskCard.cli-states.test.tsx | 1 + .../app/components/__tests__/TaskCard.test.tsx | 180 +++++++- .../components/__tests__/TaskContextMenu.test.tsx | 205 +++++++++ .../app/components/__tests__/board-mobile.test.tsx | 1 + .../app/components/dashboard/MainContent.tsx | 12 + .../__tests__/MainContent.graph-popout.test.tsx | 1 + .../dashboard/app/components/dashboard/types.ts | 1 + packages/dashboard/app/hooks/useAppSettings.ts | 8 + 23 files changed, 2178 insertions(+), 165 deletions(-) Fusion-Task-Id: FN-7255 Fusion-Task-Lineage: 5b5714a9-3eb5-4df1-a466-0d2f839b6bd9 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7255-card-context-menu.md
Normal file
7
.changeset/fn-7255-card-context-menu.md
Normal file
@@ -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.
|
||||
@@ -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.
|
||||
<!-- FNXC:BoardCardActions 2026-06-29-00:00: Board card context menus are documented as alternate entry points only; normal click still opens task detail, and mobile long-press must not trigger detail behind the menu.
|
||||
FNXC:BoardCardActions 2026-06-30-13:12: Card context menus must not label an action as Refine unless the card surface can open the real task-detail refinement feedback modal. Keep refinement documented as detail-only until a proper modal/deep-link callback is wired. -->
|
||||
<!-- FNXC:WorkflowBadges 2026-06-30-09:10: Task cards and task detail need workflow-name badges wherever mixed-workflow board contexts can hide the selected lane, especially the Board-only All workflows aggregate. -->
|
||||
<!-- FNXC:BoardDoneSorting 2026-06-29-00:00: The Done board column exposes a local descending sort selector so operators can review either latest completions or highest task IDs without changing other lifecycle columns. -->
|
||||
<!-- FNXC:BoardDoneSorting 2026-06-29-20:28: Document both Done sort modes as descending-only and Done-column-only so legacy Done and workflow complete-lane operators understand the selector does not change other lifecycle columns. -->
|
||||
@@ -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.
|
||||
<!-- FNXC:ListContextMenu 2026-06-29-00:00: List context menus are alternate action entry points only; desktop left-click still selects the split-pane detail and mobile tap still opens detail while long-press suppresses the follow-up tap.
|
||||
FNXC:ListContextMenu 2026-06-30-00:20: Keyboard access is part of the Board/List context-menu contract, so docs must include the context-menu key and Shift+F10 alongside pointer and touch entry points. -->
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Task>;
|
||||
onMoveTask: (id: string, column: ColumnId) => Promise<Task>;
|
||||
onPauseTask?: (id: string) => Promise<Task>;
|
||||
onUnpauseTask?: (id: string) => Promise<Task>;
|
||||
onResetTask?: (id: string) => Promise<Task>;
|
||||
onDuplicateTask?: (id: string) => Promise<Task>;
|
||||
onMergeTask?: (id: string) => Promise<MergeResult>;
|
||||
onOpenDetail: (task: Task | TaskDetail) => void;
|
||||
onOpenGroupModal?: (groupId: string) => void;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
onQuickCreate?: (input: TaskCreateInput) => Promise<Task | void>;
|
||||
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<string, readonly TaskContextMenuColumnMetadata[]>();
|
||||
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<string, readonly TaskContextMenuColumnMetadata[]>();
|
||||
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<string, Task[]> = {};
|
||||
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 } : {})}
|
||||
|
||||
@@ -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<Task>;
|
||||
onMoveTask: (id: string, column: ColumnId, optionsOrPosition?: { preserveProgress?: boolean } | number) => Promise<Task>;
|
||||
onPauseTask?: (id: string) => Promise<Task>;
|
||||
onUnpauseTask?: (id: string) => Promise<Task>;
|
||||
onResetTask?: (id: string) => Promise<Task>;
|
||||
onDuplicateTask?: (id: string) => Promise<Task>;
|
||||
onMergeTask?: (id: string) => Promise<MergeResult>;
|
||||
onOpenDetail: (task: Task | TaskDetail) => void;
|
||||
onOpenGroupModal?: (groupId: string) => void;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
onQuickCreate?: (input: TaskCreateInput) => Promise<Task | void>;
|
||||
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<string, readonly TaskContextMenuColumnMetadata[]>;
|
||||
/** Manually promote a held card out of this hold column (workflow mode). */
|
||||
onPromote?: (taskId: string) => Promise<void>;
|
||||
/**
|
||||
@@ -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)}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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<Task>;
|
||||
onMoveTask: (id: string, column: ColumnId, optionsOrPosition?: { preserveProgress?: boolean } | number) => Promise<Task>;
|
||||
onPromote: (taskId: string) => Promise<void>;
|
||||
/** Drag pre-check: null = allowed, else an i18n messageKey (R17). */
|
||||
canDropTask: (taskId: string, targetColumnId: string, workflowId: string) => string | null;
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<Column, string> = {
|
||||
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<string | null>(null);
|
||||
const [dragOverColumn, setDragOverColumn] = useState<ColumnId | null>(null);
|
||||
const [selectedColumn, setSelectedColumn] = useState<ColumnId | null>(null);
|
||||
const [contextMenuState, setContextMenuState] = useState<ListContextMenuState>(null);
|
||||
const [prCreateState, setPrCreateState] = useState<ListPrCreateState>(null);
|
||||
const contextMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const longPressTimerRef = useRef<ReturnType<typeof setTimeout> | 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<readonly TaskContextMenuColumnMetadata[] | undefined>(() => {
|
||||
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 (
|
||||
<div className="list-view">
|
||||
{contextMenuState && hasContextMenuActions && createPortal(
|
||||
<div
|
||||
ref={contextMenuRef}
|
||||
className="list-context-menu-popover"
|
||||
style={{ left: contextMenuState.x, top: contextMenuState.y }}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
>
|
||||
<TaskContextMenu
|
||||
actions={contextMenuActions}
|
||||
className="task-context-menu list-context-menu"
|
||||
onActionSelect={closeContextMenu}
|
||||
/>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
{prCreateState && (
|
||||
<PrCreateModal
|
||||
open={true}
|
||||
taskId={prCreateState.task.id}
|
||||
projectId={projectId}
|
||||
onClose={() => setPrCreateState(null)}
|
||||
onCreated={(prInfo) => handleListPrCreated(prCreateState.task, prInfo)}
|
||||
addToast={addToast}
|
||||
/>
|
||||
)}
|
||||
{isMobile && (
|
||||
<>
|
||||
<div className="list-toolbar">
|
||||
@@ -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 && (
|
||||
<label className="list-card-checkbox" onClick={(e) => e.stopPropagation()}>
|
||||
@@ -2214,10 +2640,14 @@ export function ListView({
|
||||
isDragging ? " dragging" : ""
|
||||
}${selectedTaskId === task.id ? " list-row--selected" : ""}`}
|
||||
onClick={() => handleRowClick(task)}
|
||||
onContextMenu={(event) => handleListContextMenu(event, task)}
|
||||
onKeyDown={(event) => handleListKeyDown(event, task)}
|
||||
draggable={!isPaused}
|
||||
onDragStart={(e) => handleDragStart(e, task)}
|
||||
onDragEnd={handleDragEnd}
|
||||
data-id={task.id}
|
||||
tabIndex={0}
|
||||
aria-haspopup="menu"
|
||||
>
|
||||
{bulkEditEnabled && (
|
||||
<td className="list-cell list-cell-checkbox">
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
border-color var(--transition-fast),
|
||||
transform var(--transition-fast),
|
||||
opacity var(--transition-normal);
|
||||
position: relative;
|
||||
user-select: none;
|
||||
touch-action: pan-x pan-y;
|
||||
container-type: inline-size;
|
||||
@@ -36,6 +37,17 @@
|
||||
background: color-mix(in srgb, var(--todo) 8%, transparent);
|
||||
}
|
||||
|
||||
.task-card-context-menu-popover {
|
||||
position: absolute;
|
||||
left: var(--task-card-context-menu-x);
|
||||
top: var(--task-card-context-menu-y);
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
.task-card-context-menu-popover .task-context-menu {
|
||||
position: static;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:TaskCardMobilePan 2026-06-13-19:51:
|
||||
Mobile board cards must allow horizontal kanban panning from every visible card surface, not only from gaps or progress-count text.
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import "./TaskCard.css";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { memo, useCallback, useState, useRef, useEffect, useMemo, type ReactElement } from "react";
|
||||
import { memo, useCallback, useState, useRef, useEffect, useLayoutEffect, useMemo, type CSSProperties, type ReactElement } from "react";
|
||||
import { Link, Clock, Layers, Pencil, ChevronDown, Folder, Target, Bot, Trash2, RotateCw, Zap, GitBranch, GitPullRequest, AlertTriangle, ArrowUpRight } from "lucide-react";
|
||||
import type { Task, TaskDetail, Column, ColumnId, PrInfo, IssueInfo, TaskPriority, GithubIssueAction } from "@fusion/core";
|
||||
import type { Task, TaskDetail, Column, ColumnId, PrInfo, IssueInfo, TaskPriority, GithubIssueAction, MergeResult } from "@fusion/core";
|
||||
import {
|
||||
DEFAULT_TASK_PRIORITY,
|
||||
HIGH_FANOUT_BLOCKER_TODO_THRESHOLD,
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
getErrorMessage,
|
||||
} from "@fusion/core";
|
||||
import { resolveEffectiveAutoMerge } from "../../../core/src/task-merge";
|
||||
import { addressPrFeedback, fetchTaskDetail, uploadAttachment, fetchMission, fetchAgent, type WorkflowFieldDefinition } from "../api";
|
||||
import { addressPrFeedback, fetchTaskDetail, uploadAttachment, fetchMission, fetchAgent, rebuildTaskSpec, refreshPrStatus, type WorkflowFieldDefinition } from "../api";
|
||||
import { GitHubBadge } from "./GitHubBadge";
|
||||
import { PrCreateModal } from "./PrCreateModal";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
@@ -38,6 +38,7 @@ import { MAX_AUTO_MERGE_RETRIES, type BlockerFanoutEntry } from "../hooks/useBlo
|
||||
import { useRetryWarning } from "../context/RetryWarningContext";
|
||||
import { useColumnLabel } from "../i18n/labels";
|
||||
import { WorkspaceWorktreesSummary, isWorkspaceTask } from "./WorkspaceWorktreesSummary";
|
||||
import { TaskContextMenu, buildTaskActionMenuModel, getTaskPrAutomationLabel, type TaskContextMenuColumnFlags, type TaskContextMenuColumnMetadata, type TaskMenuActionDescriptor } from "./TaskContextMenu";
|
||||
|
||||
/** Per-branch progress snapshot (U13). Surfaced as an optional additive field
|
||||
* on the task payload for the parallel-window badge (U9). */
|
||||
@@ -389,14 +390,23 @@ interface TaskCardProps {
|
||||
removeLineageReferences?: boolean;
|
||||
githubIssueAction?: GithubIssueAction;
|
||||
}) => Promise<Task>;
|
||||
onPauseTask?: (id: string) => Promise<Task>;
|
||||
onRetryTask?: (id: string) => Promise<Task>;
|
||||
onUnpauseTask?: (id: string) => Promise<Task>;
|
||||
onResetTask?: (id: string) => Promise<Task>;
|
||||
onDuplicateTask?: (id: string) => Promise<Task>;
|
||||
onMergeTask?: (id: string) => Promise<MergeResult>;
|
||||
onOpenDetailWithTab?: (task: Task | TaskDetail, initialTab: "changes" | "retries" | "workflow") => void;
|
||||
/** Project-level stuck task timeout in milliseconds (undefined = disabled) */
|
||||
taskStuckTimeoutMs?: number;
|
||||
/** Called when user clicks the mission badge on a task card. */
|
||||
onOpenMission?: (missionId: string) => void;
|
||||
/** Called when user moves a task to a different column from the card. */
|
||||
onMoveTask?: (id: string, column: Column, optionsOrPosition?: { preserveProgress?: boolean } | number) => Promise<Task>;
|
||||
onMoveTask?: (id: string, column: ColumnId, optionsOrPosition?: { preserveProgress?: boolean } | number) => Promise<Task>;
|
||||
/** Workflow-column flags for this task's current column, used for detail-equivalent card action availability. */
|
||||
taskColumnFlags?: TaskContextMenuColumnFlags;
|
||||
/** Ordered workflow columns that define card move targets in workflow-column mode. */
|
||||
taskMoveColumns?: readonly TaskContextMenuColumnMetadata[];
|
||||
/** Called when user promotes a held task out of a hold column. */
|
||||
onPromote?: (taskId: string) => Promise<void>;
|
||||
/** True while this task's promote action is in flight. */
|
||||
@@ -411,6 +421,8 @@ interface TaskCardProps {
|
||||
prAuthAvailable?: boolean;
|
||||
/** Project default auto-merge setting; per-task overrides are applied via resolveEffectiveAutoMerge. */
|
||||
autoMergeEnabled?: boolean;
|
||||
/** Project merge strategy so manual PR tasks match Task Detail before a PR exists. */
|
||||
mergeStrategy?: string;
|
||||
/** Card-placed custom field definitions for this task's workflow (U13/KTD-14).
|
||||
* Empty/undefined → no field badges render (card byte-identical to today). */
|
||||
cardFieldDefs?: WorkflowFieldDefinition[];
|
||||
@@ -572,6 +584,7 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
|
||||
previous.taskStuckTimeoutMs === next.taskStuckTimeoutMs &&
|
||||
previous.prAuthAvailable === next.prAuthAvailable &&
|
||||
previous.autoMergeEnabled === next.autoMergeEnabled &&
|
||||
previous.mergeStrategy === next.mergeStrategy &&
|
||||
previous.onOpenPullRequest === next.onOpenPullRequest &&
|
||||
previous.prNode?.id === next.prNode?.id &&
|
||||
previous.prNode?.state === next.prNode?.state &&
|
||||
@@ -580,6 +593,8 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
|
||||
previous.nearDuplicateCanonicalInactive === next.nearDuplicateCanonicalInactive &&
|
||||
previous.workflowBadge?.workflowId === next.workflowBadge?.workflowId &&
|
||||
previous.workflowBadge?.workflowName === next.workflowBadge?.workflowName &&
|
||||
previous.taskColumnFlags === next.taskColumnFlags &&
|
||||
previous.taskMoveColumns === next.taskMoveColumns &&
|
||||
previous.cardFieldDefs === next.cardFieldDefs &&
|
||||
(previous.cardFieldDefs == null && next.cardFieldDefs == null
|
||||
? true
|
||||
@@ -591,7 +606,12 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
|
||||
previous.onArchiveTask === next.onArchiveTask &&
|
||||
previous.onUnarchiveTask === next.onUnarchiveTask &&
|
||||
previous.onDeleteTask === next.onDeleteTask &&
|
||||
previous.onPauseTask === next.onPauseTask &&
|
||||
previous.onRetryTask === next.onRetryTask &&
|
||||
previous.onUnpauseTask === next.onUnpauseTask &&
|
||||
previous.onResetTask === next.onResetTask &&
|
||||
previous.onDuplicateTask === next.onDuplicateTask &&
|
||||
previous.onMergeTask === next.onMergeTask &&
|
||||
previous.onOpenDetailWithTab === next.onOpenDetailWithTab &&
|
||||
previous.onOpenMission === next.onOpenMission &&
|
||||
previous.onMoveTask === next.onMoveTask &&
|
||||
@@ -702,11 +722,18 @@ function TaskCardComponent({
|
||||
onArchiveTask,
|
||||
onUnarchiveTask,
|
||||
onDeleteTask,
|
||||
onPauseTask,
|
||||
onRetryTask,
|
||||
onUnpauseTask,
|
||||
onResetTask,
|
||||
onDuplicateTask,
|
||||
onMergeTask,
|
||||
onOpenDetailWithTab,
|
||||
taskStuckTimeoutMs,
|
||||
onOpenMission,
|
||||
onMoveTask,
|
||||
taskColumnFlags,
|
||||
taskMoveColumns,
|
||||
onPromote,
|
||||
isPromoting = false,
|
||||
lastFetchTimeMs,
|
||||
@@ -714,6 +741,7 @@ function TaskCardComponent({
|
||||
fanout,
|
||||
prAuthAvailable,
|
||||
autoMergeEnabled = false,
|
||||
mergeStrategy = "direct",
|
||||
cardFieldDefs,
|
||||
workflowBadge,
|
||||
prNode,
|
||||
@@ -735,6 +763,7 @@ function TaskCardComponent({
|
||||
const [missionTitle, setMissionTitle] = useState<string | null>(null);
|
||||
const [agentName, setAgentName] = useState<string | null>(null);
|
||||
const [showSendBackMenu, setShowSendBackMenu] = useState(false);
|
||||
const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(null);
|
||||
const [isRetrying, setIsRetrying] = useState(false);
|
||||
const [isPrCreateOpen, setIsPrCreateOpen] = useState(false);
|
||||
const [isAddressingPrFeedback, setIsAddressingPrFeedback] = useState(false);
|
||||
@@ -743,6 +772,7 @@ function TaskCardComponent({
|
||||
const descTextareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const touchOpenHandledRef = useRef(false);
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const contextMenuRef = useRef<HTMLDivElement>(null);
|
||||
const sendBackRef = useRef<HTMLDivElement>(null);
|
||||
const [isInViewport, setIsInViewport] = useState(false);
|
||||
const { badgeUpdates, subscribeToBadge, unsubscribeFromBadge } = useBadgeWebSocket(projectId);
|
||||
@@ -753,6 +783,9 @@ function TaskCardComponent({
|
||||
// Touch gesture detection refs
|
||||
const touchStartPosRef = useRef<{ x: number; y: number; time: number } | null>(null);
|
||||
const hasTouchMovedRef = useRef(false);
|
||||
const longPressTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const longPressStartRef = useRef<{ x: number; y: number; pointerId: number } | null>(null);
|
||||
const suppressNextCardClickRef = useRef(false);
|
||||
|
||||
const isInteractiveTarget = useCallback((target: EventTarget | null): boolean => {
|
||||
if (!(target instanceof Element)) return false;
|
||||
@@ -860,6 +893,11 @@ function TaskCardComponent({
|
||||
}, [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);
|
||||
@@ -915,6 +953,10 @@ function TaskCardComponent({
|
||||
touchOpenHandledRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (suppressNextCardClickRef.current) {
|
||||
suppressNextCardClickRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (isInteractiveTarget(e.target)) return;
|
||||
void handleClick();
|
||||
}, [handleClick, isInteractiveTarget]);
|
||||
@@ -939,10 +981,26 @@ function TaskCardComponent({
|
||||
// If moved beyond threshold, mark as moved (scrolling/dragging)
|
||||
if (dx > TOUCH_MOVE_THRESHOLD || dy > TOUCH_MOVE_THRESHOLD) {
|
||||
hasTouchMovedRef.current = true;
|
||||
if (longPressTimerRef.current) {
|
||||
clearTimeout(longPressTimerRef.current);
|
||||
longPressTimerRef.current = null;
|
||||
longPressStartRef.current = null;
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleTouchEnd = useCallback((e: React.TouchEvent) => {
|
||||
if (longPressTimerRef.current) {
|
||||
clearTimeout(longPressTimerRef.current);
|
||||
longPressTimerRef.current = null;
|
||||
longPressStartRef.current = null;
|
||||
}
|
||||
if (contextMenuPosition) {
|
||||
e.preventDefault();
|
||||
touchStartPosRef.current = null;
|
||||
hasTouchMovedRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (isInteractiveTarget(e.target)) return;
|
||||
|
||||
// Check if this was a valid tap (not a scroll)
|
||||
@@ -964,7 +1022,7 @@ function TaskCardComponent({
|
||||
// Reset touch tracking
|
||||
touchStartPosRef.current = null;
|
||||
hasTouchMovedRef.current = false;
|
||||
}, [handleClick, isInteractiveTarget]);
|
||||
}, [contextMenuPosition, handleClick, isInteractiveTarget]);
|
||||
|
||||
const handleDepClick = useCallback(async (e: React.MouseEvent, depId: string) => {
|
||||
e.stopPropagation(); // Prevent card click
|
||||
@@ -979,6 +1037,14 @@ function TaskCardComponent({
|
||||
const isDoneColumn = task.column === "done";
|
||||
const visualStatus = isDoneColumn ? "done" : task.status;
|
||||
const isFailed = !isDoneColumn && task.status === "failed";
|
||||
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 isPaused = !isDoneColumn && (task.paused === true || task.userPaused === true);
|
||||
const pausedByAgent = Boolean(!isDoneColumn && task.paused && task.pausedByAgentId);
|
||||
const normalizedPriority = normalizeTaskPriorityValue(task.priority);
|
||||
@@ -1652,6 +1718,376 @@ function TaskCardComponent({
|
||||
}
|
||||
}, [addToast, confirm, onDeleteTask, t, task.githubTracking?.enabled, task.githubTracking?.issue, task.id, task.issueInfo?.url, task.sourceIssue, task.sourceMetadata]);
|
||||
|
||||
const handleTaskActionArchive = useCallback(() => {
|
||||
handleArchiveClick({ stopPropagation() {} } as React.MouseEvent<HTMLButtonElement>);
|
||||
}, [handleArchiveClick]);
|
||||
|
||||
const handleTaskActionDelete = useCallback(() => {
|
||||
void handleDeleteClick({ stopPropagation() {} } as React.MouseEvent<HTMLButtonElement>);
|
||||
}, [handleDeleteClick]);
|
||||
|
||||
const handleTaskActionUnarchive = useCallback(() => {
|
||||
handleUnarchiveClick({ stopPropagation() {} } as React.MouseEvent<HTMLButtonElement>);
|
||||
}, [handleUnarchiveClick]);
|
||||
|
||||
const handleTaskActionRetry = useCallback(async () => {
|
||||
if (!onRetryTask || isRetrying) return;
|
||||
setIsRetrying(true);
|
||||
try {
|
||||
await onRetryTask(task.id);
|
||||
} catch (err) {
|
||||
addToast(t("tasks.retryFailed", "Failed to retry {{taskId}}: {{error}}", { taskId: task.id, error: getErrorMessage(err) }), "error");
|
||||
} finally {
|
||||
setIsRetrying(false);
|
||||
}
|
||||
}, [addToast, isRetrying, onRetryTask, task.id, t]);
|
||||
|
||||
const handleTaskActionTogglePause = useCallback(async () => {
|
||||
try {
|
||||
if (isPaused) {
|
||||
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");
|
||||
}
|
||||
}, [addToast, isPaused, onPauseTask, onUnpauseTask, task.id, t]);
|
||||
|
||||
const handleTaskActionReset = useCallback(() => {
|
||||
if (!onResetTask) return;
|
||||
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"));
|
||||
}, [addToast, onResetTask, task.id, t]);
|
||||
|
||||
const handleTaskActionDuplicate = useCallback(async () => {
|
||||
if (!onDuplicateTask) return;
|
||||
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");
|
||||
}
|
||||
}, [addToast, confirm, onDuplicateTask, task.id, t]);
|
||||
|
||||
const handleTaskActionMerge = useCallback(async () => {
|
||||
if (!onMergeTask) return;
|
||||
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) => {
|
||||
const message = 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") });
|
||||
addToast(message, "success");
|
||||
})
|
||||
.catch((err) => addToast(getErrorMessage(err), "error"));
|
||||
}, [addToast, confirm, onMergeTask, task.id, t]);
|
||||
|
||||
const handleTaskActionRespecify = useCallback(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");
|
||||
}
|
||||
}, [addToast, confirm, projectId, task.id, t]);
|
||||
|
||||
const handleTaskActionMove = useCallback(async (column: ColumnId) => {
|
||||
if (!onMoveTask) return;
|
||||
try {
|
||||
const hasStepProgress = task.steps.some((step) => step.status !== "pending");
|
||||
const shouldPrompt = (column === "todo" || column === "triage") && hasStepProgress;
|
||||
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: columnLabel(column) }), "success");
|
||||
} catch (err) {
|
||||
addToast(getErrorMessage(err), "error");
|
||||
}
|
||||
}, [addToast, columnLabel, confirm, onMoveTask, task.id, task.steps, t]);
|
||||
|
||||
const handleTaskActionCheckPrStatus = useCallback(async () => {
|
||||
try {
|
||||
await refreshPrStatus(task.id, projectId);
|
||||
addToast(t("taskDetail.pr.statusRefreshed", "PR status refreshed"), "success");
|
||||
} catch (err) {
|
||||
addToast(getErrorMessage(err), "error");
|
||||
}
|
||||
}, [addToast, projectId, task.id, t]);
|
||||
|
||||
/*
|
||||
FNXC:BoardCardActions 2026-06-29-00:00:
|
||||
Board cards expose the same lifecycle actions as Task Detail from right-click, keyboard context menu, and touch long-press so operators can act without opening detail. Dock/plugin TaskCard users stay unchanged because the menu only mounts when Board/List owners pass action handlers.
|
||||
|
||||
FNXC:BoardCardActions 2026-06-30-00:30:
|
||||
Context-menu moves reuse the Task Detail preserve/reset progress confirmation path before moving back to Todo or Triage, because those transitions can reset completed steps. Refine stays hidden outside Task Detail until card surfaces can open the actual refine modal, while manual PR entries open the existing PR flows instead of silently dropping unavailable actions.
|
||||
|
||||
FNXC:BoardCardActions 2026-06-30-00:42:
|
||||
Board context menus must receive the project merge strategy, not infer pull-request mode from existing PR data, so manual PR projects show Start PR Review before the PR entity is created.
|
||||
|
||||
FNXC:BoardCardActions 2026-06-30-12:42:
|
||||
Workflow-column card menus must use the task's workflow column flags and ordered column list instead of legacy column literals. Custom complete or archived lanes are terminal for Reset/Pause, while custom active lanes still expose neighbor move targets.
|
||||
|
||||
FNXC:BoardCardActions 2026-06-30-13:02:
|
||||
Manual pull-request projects need a distinct Start PR Review callback from direct Merge & Close so context menus open PrCreateModal instead of calling the merge endpoint.
|
||||
*/
|
||||
const taskActionColumnLabel = useCallback((column: ColumnId) => {
|
||||
return taskMoveColumns?.find((candidate) => candidate.id === column)?.label ?? columnLabel(column);
|
||||
}, [columnLabel, taskMoveColumns]);
|
||||
|
||||
const taskActionMenuModel = useMemo(() => buildTaskActionMenuModel({
|
||||
task,
|
||||
t,
|
||||
columnLabel: taskActionColumnLabel,
|
||||
currentColumnFlags: taskColumnFlags,
|
||||
workflowMoveColumns: taskMoveColumns,
|
||||
canRetryTask,
|
||||
hasDuplicateHandler: Boolean(onDuplicateTask),
|
||||
hasRetryHandler: Boolean(onRetryTask),
|
||||
hasResetHandler: Boolean(onResetTask),
|
||||
hasAssignedAgent: Boolean(task.assignedAgentId),
|
||||
autoMergeEnabled: effectiveAutoMerge,
|
||||
mergeStrategy,
|
||||
prAutomationLabel: getTaskPrAutomationLabel(t, task.status),
|
||||
onDelete: onDeleteTask ? handleTaskActionDelete : undefined,
|
||||
onDuplicate: onDuplicateTask ? handleTaskActionDuplicate : undefined,
|
||||
onOpenRefine: undefined,
|
||||
onRespecify: handleTaskActionRespecify,
|
||||
onRetry: onRetryTask ? handleTaskActionRetry : undefined,
|
||||
onReset: onResetTask ? handleTaskActionReset : undefined,
|
||||
onTogglePause: (isPaused ? onUnpauseTask : onPauseTask) ? handleTaskActionTogglePause : undefined,
|
||||
onMerge: onMergeTask ? handleTaskActionMerge : undefined,
|
||||
onStartPrReview: () => setIsPrCreateOpen(true),
|
||||
onCheckPrStatus: task.prInfo ? handleTaskActionCheckPrStatus : undefined,
|
||||
}), [
|
||||
task,
|
||||
t,
|
||||
taskActionColumnLabel,
|
||||
taskColumnFlags,
|
||||
taskMoveColumns,
|
||||
canRetryTask,
|
||||
onDuplicateTask,
|
||||
onRetryTask,
|
||||
onResetTask,
|
||||
effectiveAutoMerge,
|
||||
mergeStrategy,
|
||||
handleTaskActionArchive,
|
||||
handleTaskActionCheckPrStatus,
|
||||
handleTaskActionDelete,
|
||||
handleTaskActionDuplicate,
|
||||
handleTaskActionMerge,
|
||||
handleTaskActionReset,
|
||||
handleTaskActionRespecify,
|
||||
handleTaskActionRetry,
|
||||
handleTaskActionTogglePause,
|
||||
handleTaskActionUnarchive,
|
||||
isPaused,
|
||||
onDeleteTask,
|
||||
onMergeTask,
|
||||
onOpenDetail,
|
||||
onPauseTask,
|
||||
onUnpauseTask,
|
||||
task,
|
||||
task.assignedAgentId,
|
||||
task.column,
|
||||
task.prInfo,
|
||||
]);
|
||||
const contextMenuActions = useMemo<TaskMenuActionDescriptor[]>(() => {
|
||||
if (!onDeleteTask && !onArchiveTask && !onUnarchiveTask && !onDuplicateTask && !onRetryTask && !onResetTask && !onPauseTask && !onUnpauseTask && !onMergeTask && !onMoveTask) {
|
||||
return [];
|
||||
}
|
||||
const actions = [...taskActionMenuModel.actions];
|
||||
if (task.column === "done" && onArchiveTask) {
|
||||
actions.push({ id: "archive", label: t("tasks.archive", "Archive"), onSelect: handleTaskActionArchive });
|
||||
}
|
||||
if (task.column === "archived" && onUnarchiveTask) {
|
||||
actions.push({ id: "unarchive", label: t("tasks.unarchive", "Unarchive"), onSelect: handleTaskActionUnarchive });
|
||||
}
|
||||
if (taskActionMenuModel.reviewAction) {
|
||||
actions.push({ id: taskActionMenuModel.reviewAction.id, label: taskActionMenuModel.reviewAction.label, disabled: taskActionMenuModel.reviewAction.disabled, onSelect: taskActionMenuModel.reviewAction.onSelect });
|
||||
}
|
||||
if (onMoveTask) {
|
||||
for (const transition of taskActionMenuModel.moveTransitions) {
|
||||
actions.push({
|
||||
id: `move-${transition.column}`,
|
||||
label: transition.label,
|
||||
onSelect: () => handleTaskActionMove(transition.column),
|
||||
});
|
||||
}
|
||||
}
|
||||
return actions.filter((action) => action.tone === "note" || action.disabled === true || Boolean(action.onSelect));
|
||||
}, [handleTaskActionArchive, handleTaskActionMove, handleTaskActionUnarchive, onArchiveTask, onDeleteTask, onDuplicateTask, onMergeTask, onMoveTask, onPauseTask, onResetTask, onRetryTask, onUnarchiveTask, onUnpauseTask, t, task.column, taskActionMenuModel.actions, taskActionMenuModel.moveTransitions, taskActionMenuModel.reviewAction]);
|
||||
const hasContextMenuActions = contextMenuActions.length > 0;
|
||||
|
||||
const closeContextMenu = useCallback(() => {
|
||||
setContextMenuPosition(null);
|
||||
}, []);
|
||||
|
||||
const clearLongPressTimer = useCallback(() => {
|
||||
if (longPressTimerRef.current) {
|
||||
clearTimeout(longPressTimerRef.current);
|
||||
longPressTimerRef.current = null;
|
||||
}
|
||||
longPressStartRef.current = null;
|
||||
}, []);
|
||||
|
||||
const openContextMenuAt = useCallback((clientX: number, clientY: number) => {
|
||||
if (!hasContextMenuActions || isEditing) return;
|
||||
const rect = cardRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
setShowSendBackMenu(false);
|
||||
setContextMenuPosition({
|
||||
x: Math.max(CONTEXT_MENU_VIEWPORT_MARGIN, Math.min(clientX - rect.left, rect.width - CONTEXT_MENU_VIEWPORT_MARGIN)),
|
||||
y: Math.max(CONTEXT_MENU_VIEWPORT_MARGIN, Math.min(clientY - rect.top, rect.height - CONTEXT_MENU_VIEWPORT_MARGIN)),
|
||||
});
|
||||
}, [hasContextMenuActions, isEditing]);
|
||||
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent) => {
|
||||
if (!hasContextMenuActions || isInteractiveTarget(e.target)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
suppressNextCardClickRef.current = true;
|
||||
openContextMenuAt(e.clientX, e.clientY);
|
||||
}, [hasContextMenuActions, isInteractiveTarget, openContextMenuAt]);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (!hasContextMenuActions) return;
|
||||
if (e.key !== "ContextMenu" && !(e.shiftKey && e.key === "F10")) return;
|
||||
if (isInteractiveTarget(e.target)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const rect = cardRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
suppressNextCardClickRef.current = true;
|
||||
openContextMenuAt(
|
||||
rect.left + Math.min(rect.width - CONTEXT_MENU_VIEWPORT_MARGIN, KEYBOARD_CONTEXT_MENU_OFFSET),
|
||||
rect.top + Math.min(rect.height - CONTEXT_MENU_VIEWPORT_MARGIN, KEYBOARD_CONTEXT_MENU_OFFSET),
|
||||
);
|
||||
}, [hasContextMenuActions, isInteractiveTarget, openContextMenuAt]);
|
||||
|
||||
const handlePointerDown = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!hasContextMenuActions || e.pointerType === "mouse" || isInteractiveTarget(e.target)) return;
|
||||
clearLongPressTimer();
|
||||
longPressStartRef.current = { x: e.clientX, y: e.clientY, pointerId: e.pointerId };
|
||||
longPressTimerRef.current = setTimeout(() => {
|
||||
longPressTimerRef.current = null;
|
||||
suppressNextCardClickRef.current = true;
|
||||
touchOpenHandledRef.current = true;
|
||||
openContextMenuAt(e.clientX, e.clientY);
|
||||
}, TOUCH_CONTEXT_MENU_DELAY_MS);
|
||||
}, [clearLongPressTimer, hasContextMenuActions, isInteractiveTarget, openContextMenuAt]);
|
||||
|
||||
const handlePointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
||||
const start = longPressStartRef.current;
|
||||
if (!start || start.pointerId !== e.pointerId) return;
|
||||
if (Math.abs(e.clientX - start.x) > TOUCH_MOVE_THRESHOLD || Math.abs(e.clientY - start.y) > TOUCH_MOVE_THRESHOLD) {
|
||||
clearLongPressTimer();
|
||||
}
|
||||
}, [clearLongPressTimer]);
|
||||
|
||||
const handlePointerUpOrCancel = useCallback(() => {
|
||||
clearLongPressTimer();
|
||||
}, [clearLongPressTimer]);
|
||||
|
||||
/*
|
||||
FNXC:TaskContextMenu 2026-06-30-00:15:
|
||||
Board card context menus open from pointer and keyboard coordinates, so clamp after render using the measured menu size. This keeps long action lists inside the viewport without changing normal card click or drag behavior.
|
||||
*/
|
||||
useLayoutEffect(() => {
|
||||
if (!contextMenuPosition) return;
|
||||
const menu = contextMenuRef.current;
|
||||
const card = cardRef.current;
|
||||
if (!menu || !card) return;
|
||||
const menuRect = menu.getBoundingClientRect();
|
||||
const cardRect = card.getBoundingClientRect();
|
||||
const maxX = Math.max(
|
||||
CONTEXT_MENU_VIEWPORT_MARGIN,
|
||||
Math.min(cardRect.width - CONTEXT_MENU_VIEWPORT_MARGIN, window.innerWidth - cardRect.left - menuRect.width - CONTEXT_MENU_VIEWPORT_MARGIN),
|
||||
);
|
||||
const maxY = Math.max(
|
||||
CONTEXT_MENU_VIEWPORT_MARGIN,
|
||||
Math.min(cardRect.height - CONTEXT_MENU_VIEWPORT_MARGIN, window.innerHeight - cardRect.top - menuRect.height - CONTEXT_MENU_VIEWPORT_MARGIN),
|
||||
);
|
||||
const nextPosition = {
|
||||
x: Math.max(CONTEXT_MENU_VIEWPORT_MARGIN, Math.min(contextMenuPosition.x, maxX)),
|
||||
y: Math.max(CONTEXT_MENU_VIEWPORT_MARGIN, Math.min(contextMenuPosition.y, maxY)),
|
||||
};
|
||||
if (nextPosition.x !== contextMenuPosition.x || nextPosition.y !== contextMenuPosition.y) {
|
||||
setContextMenuPosition(nextPosition);
|
||||
}
|
||||
}, [contextMenuPosition]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!contextMenuPosition) 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, contextMenuPosition]);
|
||||
|
||||
useEffect(() => {
|
||||
const cancelLongPress = () => clearLongPressTimer();
|
||||
window.addEventListener("scroll", cancelLongPress, true);
|
||||
return () => {
|
||||
window.removeEventListener("scroll", cancelLongPress, true);
|
||||
clearLongPressTimer();
|
||||
};
|
||||
}, [clearLongPressTimer]);
|
||||
|
||||
const handleOpenFiles = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onOpenDetailWithTab?.(task, "changes");
|
||||
@@ -1897,11 +2333,34 @@ function TaskCardComponent({
|
||||
onDragLeave={handleFileDragLeave}
|
||||
onDrop={handleFileDrop}
|
||||
onClick={handleCardClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUpOrCancel}
|
||||
onPointerCancel={handlePointerUpOrCancel}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onTouchCancel={handlePointerUpOrCancel}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
tabIndex={hasContextMenuActions ? 0 : undefined}
|
||||
aria-haspopup={hasContextMenuActions ? "menu" : undefined}
|
||||
>
|
||||
{contextMenuPosition && hasContextMenuActions && (
|
||||
<div
|
||||
ref={contextMenuRef}
|
||||
className="task-card-context-menu-popover"
|
||||
style={{ "--task-card-context-menu-x": `${contextMenuPosition.x}px`, "--task-card-context-menu-y": `${contextMenuPosition.y}px` } as CSSProperties}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
>
|
||||
<TaskContextMenu
|
||||
actions={contextMenuActions}
|
||||
onActionSelect={closeContextMenu}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="card-header">
|
||||
<span className="card-id">{task.id}</span>
|
||||
{isPaused && (
|
||||
@@ -2606,6 +3065,9 @@ function TaskCardComponent({
|
||||
|
||||
const TOUCH_MOVE_THRESHOLD = 10; // pixels
|
||||
const TOUCH_TAP_MAX_DURATION = 300; // milliseconds
|
||||
const TOUCH_CONTEXT_MENU_DELAY_MS = 550; // milliseconds
|
||||
const CONTEXT_MENU_VIEWPORT_MARGIN = 8;
|
||||
const KEYBOARD_CONTEXT_MENU_OFFSET = 32;
|
||||
const MAX_TITLE_LENGTH = 140;
|
||||
|
||||
function truncate(s: string | undefined, max: number): string {
|
||||
|
||||
57
packages/dashboard/app/components/TaskContextMenu.css
Normal file
57
packages/dashboard/app/components/TaskContextMenu.css
Normal file
@@ -0,0 +1,57 @@
|
||||
.task-context-menu {
|
||||
--task-context-menu-min-width: calc(var(--space-xl) * 6 + var(--space-lg));
|
||||
--task-context-menu-max-width: min(calc(var(--space-xl) * 9 + var(--space-md)), calc(100vw - var(--space-xl)));
|
||||
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
min-width: var(--task-context-menu-min-width);
|
||||
max-width: var(--task-context-menu-max-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__item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.task-context-menu__item:hover,
|
||||
.task-context-menu__item:focus {
|
||||
outline: none;
|
||||
background: var(--surface-hover, color-mix(in srgb, var(--text) 6%, transparent));
|
||||
}
|
||||
|
||||
.task-context-menu__item--danger {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.task-context-menu__item--danger:hover,
|
||||
.task-context-menu__item--danger:focus {
|
||||
background: color-mix(in srgb, var(--color-error) 8%, transparent);
|
||||
}
|
||||
|
||||
.task-context-menu__item--note {
|
||||
color: var(--text-muted);
|
||||
font-size: calc(var(--space-sm) + var(--space-xs) * 0.75);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.task-context-menu {
|
||||
--task-context-menu-min-width: min(calc(100vw - var(--space-xl)), calc(var(--space-xl) * 6));
|
||||
}
|
||||
|
||||
.task-context-menu__item {
|
||||
padding: var(--space-md);
|
||||
}
|
||||
}
|
||||
346
packages/dashboard/app/components/TaskContextMenu.tsx
Normal file
346
packages/dashboard/app/components/TaskContextMenu.tsx
Normal file
@@ -0,0 +1,346 @@
|
||||
import "./TaskContextMenu.css";
|
||||
import type { KeyboardEvent, ReactNode } from "react";
|
||||
import { Fragment, useEffect, useRef } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import type { ColumnId, Task, TaskDetail } from "@fusion/core";
|
||||
import { COLUMNS, VALID_TRANSITIONS, isColumn } from "@fusion/core";
|
||||
|
||||
export type TaskMenuActionTone = "default" | "danger" | "note";
|
||||
|
||||
export interface TaskMenuActionDescriptor {
|
||||
id: string;
|
||||
label: string;
|
||||
tone?: TaskMenuActionTone;
|
||||
disabled?: boolean;
|
||||
onSelect?: () => void;
|
||||
}
|
||||
|
||||
export interface TaskMoveActionDescriptor {
|
||||
column: ColumnId;
|
||||
label: string;
|
||||
primaryLabel: string;
|
||||
}
|
||||
|
||||
export interface TaskContextMenuColumnFlags {
|
||||
complete?: boolean;
|
||||
archived?: boolean;
|
||||
hiddenFromBoard?: boolean;
|
||||
hold?: boolean;
|
||||
intake?: boolean;
|
||||
mergeBlocker?: boolean;
|
||||
humanReview?: boolean;
|
||||
}
|
||||
|
||||
export interface TaskContextMenuColumnMetadata {
|
||||
id: ColumnId;
|
||||
label: string;
|
||||
flags?: TaskContextMenuColumnFlags;
|
||||
}
|
||||
|
||||
export interface TaskReviewActionDescriptor {
|
||||
id: "merge" | "start-pr-review" | "check-pr-status" | "pr-automation";
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
onSelect?: () => void;
|
||||
}
|
||||
|
||||
export interface TaskActionMenuModel {
|
||||
actions: TaskMenuActionDescriptor[];
|
||||
moveTransitions: TaskMoveActionDescriptor[];
|
||||
reviewAction?: TaskReviewActionDescriptor;
|
||||
shouldShowActionsMenu: boolean;
|
||||
isTaskPaused: boolean;
|
||||
}
|
||||
|
||||
export interface BuildTaskActionMenuModelOptions {
|
||||
task: Task | TaskDetail;
|
||||
t: TFunction<"app">;
|
||||
columnLabel: (column: ColumnId) => string;
|
||||
currentColumnFlags?: TaskContextMenuColumnFlags;
|
||||
workflowMoveColumns?: readonly TaskContextMenuColumnMetadata[];
|
||||
canRetryTask?: boolean;
|
||||
hasDuplicateHandler?: boolean;
|
||||
hasRetryHandler?: boolean;
|
||||
hasResetHandler?: boolean;
|
||||
hasAssignedAgent?: boolean;
|
||||
mergeStrategy?: string;
|
||||
autoMergeEnabled?: boolean;
|
||||
prAutomationLabel?: string;
|
||||
isCheckingPrStatus?: boolean;
|
||||
onDelete?: () => void;
|
||||
onDuplicate?: () => void;
|
||||
onOpenRefine?: () => void;
|
||||
onRespecify?: () => void;
|
||||
onRetry?: () => void;
|
||||
onReset?: () => void;
|
||||
onTogglePause?: () => void;
|
||||
onMerge?: () => void;
|
||||
onStartPrReview?: () => void;
|
||||
onCheckPrStatus?: () => void;
|
||||
}
|
||||
|
||||
export function getTaskPrAutomationLabel(t: TFunction<"app">, status?: string): string | undefined {
|
||||
if (!status) return undefined;
|
||||
const prAutomationStatusLabels: Record<string, string> = {
|
||||
"creating-pr": t("taskDetail.pr.creatingPr", "Creating PR…"),
|
||||
"awaiting-pr-checks": t("taskDetail.pr.awaitingChecks", "Awaiting PR checks"),
|
||||
"merging-pr": t("taskDetail.pr.mergingPr", "Merging PR…"),
|
||||
"merging-fix": t("taskDetail.pr.mergingFixes", "Merging fixes…"),
|
||||
};
|
||||
return prAutomationStatusLabels[status];
|
||||
}
|
||||
|
||||
function isReviewColumn(column: string, flags?: TaskContextMenuColumnFlags): boolean {
|
||||
return column === "in-review" || flags?.mergeBlocker === true || flags?.humanReview === true;
|
||||
}
|
||||
|
||||
function isDoneOrReview(column: string, flags?: TaskContextMenuColumnFlags): boolean {
|
||||
return column === "done" || isReviewColumn(column, flags) || (flags?.complete === true && flags?.archived !== true);
|
||||
}
|
||||
|
||||
function isMutableLiveColumn(column: string, flags?: TaskContextMenuColumnFlags): boolean {
|
||||
if (flags) return flags.complete !== true && flags.archived !== true;
|
||||
return column !== "done" && column !== "archived";
|
||||
}
|
||||
|
||||
function isDefaultWorkflowColumnSet(columns: readonly TaskContextMenuColumnMetadata[]): boolean {
|
||||
if (columns.length !== COLUMNS.length) return false;
|
||||
const ids = new Set(columns.map((column) => column.id));
|
||||
return COLUMNS.every((column) => ids.has(column));
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:TaskContextMenu 2026-06-30-12:42:
|
||||
Workflow-column Board/List menus derive move targets from the task's workflow metadata instead of legacy VALID_TRANSITIONS. Built-in/default workflows keep exact legacy parity; custom workflows use visible neighbor columns and trait flags so custom complete/archived lanes are not treated as mutable live work.
|
||||
|
||||
FNXC:TaskContextMenu 2026-06-30-13:02:
|
||||
Manual pull-request review has two separate operator intents: Start PR Review opens PR creation, while Merge & Close calls the merge endpoint. Keep distinct callbacks so card/list context menus cannot merge a task when the user asked to create a PR.
|
||||
*/
|
||||
function getWorkflowMoveTargets(task: Task | TaskDetail, columns: readonly TaskContextMenuColumnMetadata[]): ColumnId[] {
|
||||
const visibleColumns = columns.filter((column) => column.flags?.hiddenFromBoard !== true);
|
||||
if (isDefaultWorkflowColumnSet(visibleColumns) && isColumn(task.column)) {
|
||||
return task.column === "in-review" ? ["todo", "in-progress"] : [...VALID_TRANSITIONS[task.column]];
|
||||
}
|
||||
|
||||
const currentIndex = visibleColumns.findIndex((column) => column.id === task.column);
|
||||
if (currentIndex < 0) return [];
|
||||
const targets: ColumnId[] = [];
|
||||
const previous = visibleColumns[currentIndex - 1]?.id;
|
||||
const next = visibleColumns[currentIndex + 1]?.id;
|
||||
if (previous) targets.push(previous);
|
||||
if (next) targets.push(next);
|
||||
return targets;
|
||||
}
|
||||
|
||||
export function getTaskMoveTransitions(
|
||||
task: Task | TaskDetail,
|
||||
t: TFunction<"app">,
|
||||
columnLabel: (column: ColumnId) => string,
|
||||
workflowMoveColumns?: readonly TaskContextMenuColumnMetadata[],
|
||||
): TaskMoveActionDescriptor[] {
|
||||
const moveTransitions: ColumnId[] = workflowMoveColumns
|
||||
? getWorkflowMoveTargets(task, workflowMoveColumns)
|
||||
: isColumn(task.column)
|
||||
? (task.column === "in-review" ? ["todo", "in-progress"] : [...VALID_TRANSITIONS[task.column]])
|
||||
: [];
|
||||
const workflowLabelById = new Map((workflowMoveColumns ?? []).map((column) => [column.id, column.label]));
|
||||
|
||||
return moveTransitions.map((column) => {
|
||||
const label = workflowLabelById.get(column) ?? columnLabel(column);
|
||||
return {
|
||||
column,
|
||||
label: column === "in-progress" && task.column === "in-review"
|
||||
? t("taskDetail.move.backToInProgress", "Back to In Progress")
|
||||
: t("taskDetail.move.moveTo", "Move to {{column}}", { column: label }),
|
||||
primaryLabel: t("taskDetail.move.moveTo", "Move to {{column}}", { column: label }),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function getTaskReviewAction(
|
||||
task: Task | TaskDetail,
|
||||
options: Pick<BuildTaskActionMenuModelOptions, "t" | "currentColumnFlags" | "mergeStrategy" | "autoMergeEnabled" | "prAutomationLabel" | "isCheckingPrStatus" | "onMerge" | "onStartPrReview" | "onCheckPrStatus">,
|
||||
): TaskReviewActionDescriptor | undefined {
|
||||
const currentColumnFlags = options.currentColumnFlags;
|
||||
if (!isReviewColumn(task.column, currentColumnFlags)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (options.prAutomationLabel) {
|
||||
return { id: "pr-automation", label: options.prAutomationLabel, disabled: true };
|
||||
}
|
||||
|
||||
const isManualPrFlow = options.mergeStrategy === "pull-request" && !options.autoMergeEnabled;
|
||||
const prStatus = task.prInfo?.status;
|
||||
|
||||
if (isManualPrFlow) {
|
||||
if (!task.prInfo) {
|
||||
return { id: "start-pr-review", label: options.t("taskDetail.pr.startPrReview", "Start PR Review"), onSelect: options.onStartPrReview };
|
||||
}
|
||||
if (prStatus === "open") {
|
||||
return {
|
||||
id: "check-pr-status",
|
||||
label: options.t("taskDetail.pr.checkPrStatus", "Check PR Status"),
|
||||
disabled: options.isCheckingPrStatus,
|
||||
onSelect: options.onCheckPrStatus,
|
||||
};
|
||||
}
|
||||
if (prStatus === "merged") {
|
||||
return { id: "merge", label: options.t("taskDetail.pr.finishAndClose", "Finish & Close"), onSelect: options.onMerge };
|
||||
}
|
||||
}
|
||||
|
||||
return { id: "merge", label: options.t("taskDetail.pr.mergeAndClose", "Merge & Close"), onSelect: options.onMerge };
|
||||
}
|
||||
|
||||
export function buildTaskActionMenuModel(options: BuildTaskActionMenuModelOptions): TaskActionMenuModel {
|
||||
const {
|
||||
task,
|
||||
t,
|
||||
columnLabel,
|
||||
currentColumnFlags,
|
||||
workflowMoveColumns,
|
||||
canRetryTask = false,
|
||||
hasDuplicateHandler = Boolean(options.onDuplicate),
|
||||
hasRetryHandler = Boolean(options.onRetry),
|
||||
hasResetHandler = Boolean(options.onReset),
|
||||
hasAssignedAgent = Boolean(task.assignedAgentId),
|
||||
} = options;
|
||||
const isTaskPaused = Boolean(task.paused || task.userPaused);
|
||||
const actions: TaskMenuActionDescriptor[] = [
|
||||
{
|
||||
id: "delete",
|
||||
label: t("taskDetail.delete.btn", "Delete"),
|
||||
tone: "danger",
|
||||
onSelect: options.onDelete,
|
||||
},
|
||||
];
|
||||
|
||||
if (hasDuplicateHandler) {
|
||||
actions.push({ id: "duplicate", label: t("taskDetail.duplicate.btn", "Duplicate"), onSelect: options.onDuplicate });
|
||||
}
|
||||
|
||||
if (isDoneOrReview(task.column, currentColumnFlags) && options.onOpenRefine) {
|
||||
actions.push({ id: "refine", label: t("taskDetail.refine.btn", "Refine"), onSelect: options.onOpenRefine });
|
||||
}
|
||||
|
||||
actions.push({ id: "respecify", label: t("taskDetail.respecify.btn", "Respecify"), onSelect: options.onRespecify });
|
||||
|
||||
if (canRetryTask && hasRetryHandler) {
|
||||
actions.push({ id: "retry", label: t("taskDetail.retry.btn", "Retry"), onSelect: options.onRetry });
|
||||
}
|
||||
|
||||
if (hasResetHandler && isMutableLiveColumn(task.column, currentColumnFlags)) {
|
||||
actions.push({ id: "reset", label: t("taskDetail.reset.btn", "Reset"), tone: "danger", onSelect: options.onReset });
|
||||
}
|
||||
|
||||
if (isMutableLiveColumn(task.column, currentColumnFlags)) {
|
||||
actions.push({
|
||||
id: isTaskPaused ? "unpause" : "pause",
|
||||
label: isTaskPaused ? t("taskDetail.pause.unpauseBtn", "Unpause") : t("taskDetail.pause.pauseBtn", "Pause"),
|
||||
onSelect: options.onTogglePause,
|
||||
});
|
||||
}
|
||||
|
||||
if (isMutableLiveColumn(task.column, currentColumnFlags) && task.paused && task.pausedByAgentId) {
|
||||
actions.push({ id: "paused-by-agent", label: t("taskDetail.pause.pausedByAgent", "Paused by agent"), tone: "note", disabled: true });
|
||||
}
|
||||
|
||||
return {
|
||||
actions,
|
||||
moveTransitions: getTaskMoveTransitions(task, t, columnLabel, workflowMoveColumns),
|
||||
reviewAction: getTaskReviewAction(task, options),
|
||||
shouldShowActionsMenu:
|
||||
task.column !== "triage" ||
|
||||
task.status === "awaiting-approval" ||
|
||||
canRetryTask ||
|
||||
isTaskPaused ||
|
||||
hasAssignedAgent,
|
||||
isTaskPaused,
|
||||
};
|
||||
}
|
||||
|
||||
export interface TaskContextMenuProps {
|
||||
actions: TaskMenuActionDescriptor[];
|
||||
role?: "menu" | "list";
|
||||
className?: string;
|
||||
itemClassName?: string;
|
||||
dangerItemClassName?: string;
|
||||
noteItemClassName?: string;
|
||||
onActionSelect?: (action: TaskMenuActionDescriptor) => void;
|
||||
renderAction?: (action: TaskMenuActionDescriptor, defaultNode: ReactNode) => ReactNode;
|
||||
autoFocusFirstItem?: boolean;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:TaskContextMenu 2026-06-29-00:00:
|
||||
Card, list, and detail task menus must share one action descriptor model so labels and lifecycle availability do not drift between surfaces. Keep destructive handlers injected by the host so existing confirmations, toasts, and API calls remain the source of truth.
|
||||
*/
|
||||
export function TaskContextMenu({
|
||||
actions,
|
||||
role = "menu",
|
||||
className = "task-context-menu",
|
||||
itemClassName = "task-context-menu__item",
|
||||
dangerItemClassName = "task-context-menu__item--danger",
|
||||
noteItemClassName = "task-context-menu__item--note",
|
||||
onActionSelect,
|
||||
renderAction,
|
||||
autoFocusFirstItem = true,
|
||||
}: TaskContextMenuProps) {
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoFocusFirstItem) return;
|
||||
const firstItem = menuRef.current?.querySelector<HTMLButtonElement>("button:not(:disabled)");
|
||||
firstItem?.focus();
|
||||
}, [actions, autoFocusFirstItem]);
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key !== "ArrowDown" && event.key !== "ArrowUp" && event.key !== "Home" && event.key !== "End") return;
|
||||
const items = Array.from(menuRef.current?.querySelectorAll<HTMLButtonElement>("button:not(:disabled)") ?? []);
|
||||
if (items.length === 0) return;
|
||||
event.preventDefault();
|
||||
const activeIndex = items.indexOf(document.activeElement as HTMLButtonElement);
|
||||
const lastIndex = items.length - 1;
|
||||
const nextIndex = event.key === "Home"
|
||||
? 0
|
||||
: event.key === "End"
|
||||
? lastIndex
|
||||
: event.key === "ArrowUp"
|
||||
? (activeIndex <= 0 ? lastIndex : activeIndex - 1)
|
||||
: (activeIndex >= lastIndex ? 0 : activeIndex + 1);
|
||||
items[nextIndex]?.focus();
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={menuRef} className={className} role={role} onKeyDown={handleKeyDown}>
|
||||
{actions.map((action) => {
|
||||
const classes = [itemClassName];
|
||||
if (action.tone === "danger") classes.push(dangerItemClassName);
|
||||
if (action.tone === "note") classes.push(noteItemClassName);
|
||||
|
||||
const defaultNode = action.tone === "note" ? (
|
||||
<span key={action.id} className={classes.join(" ")} role="note">
|
||||
{action.label}
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
key={action.id}
|
||||
type="button"
|
||||
className={classes.join(" ")}
|
||||
role={role === "menu" ? "menuitem" : undefined}
|
||||
disabled={action.disabled}
|
||||
onClick={() => {
|
||||
onActionSelect?.(action);
|
||||
action.onSelect?.();
|
||||
}}
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
);
|
||||
|
||||
return <Fragment key={action.id}>{renderAction ? renderAction(action, defaultNode) : defaultNode}</Fragment>;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,8 +15,6 @@ import {
|
||||
DEFAULT_TASK_PRIORITY,
|
||||
REPO_OVERRIDE_RE,
|
||||
TASK_PRIORITIES,
|
||||
VALID_TRANSITIONS,
|
||||
isColumn,
|
||||
getErrorMessage,
|
||||
} from "@fusion/core";
|
||||
import { isNearDuplicateCanonicalInactive } from "../../../core/src/near-duplicate-canonical";
|
||||
@@ -66,6 +64,7 @@ import { findInReviewStallLogEntry, IN_REVIEW_STALL_LOG_REGEX } from "../utils/f
|
||||
import { getTaskLogEntryAction, getTaskLogEntryOutcome } from "../utils/taskLogEntryDisplay";
|
||||
import { getRelativeTimeBucket } from "../utils/relativeTimeAgo";
|
||||
import { ACTIVE_STATUSES, resolveEffectiveExecutor, resolveEffectivePlanning, resolveEffectiveValidator, type ModelSelection } from "./effective-model-resolution";
|
||||
import { TaskContextMenu, buildTaskActionMenuModel, getTaskPrAutomationLabel } from "./TaskContextMenu";
|
||||
|
||||
const STALE_PAUSED_REVIEW_LOG_REGEX = /^Stale paused review surfaced \[([^\]]+)\]/;
|
||||
const EMPTY_MARKDOWN_CHILD_SEPARATOR = "";
|
||||
@@ -2192,16 +2191,16 @@ export function TaskDetailContent({
|
||||
handleMove(column);
|
||||
}, [closeMenus]);
|
||||
|
||||
const handleActionsMenuItemClick = useCallback((action: () => void) => {
|
||||
closeMenus();
|
||||
action();
|
||||
}, [closeMenus]);
|
||||
|
||||
const handleMergeMenuItemClick = useCallback(() => {
|
||||
closeMenus();
|
||||
void handleMerge();
|
||||
}, [closeMenus, handleMerge]);
|
||||
|
||||
const handleStartPrReviewMenuItemClick = useCallback(() => {
|
||||
closeMenus();
|
||||
setPrCreateOpen(true);
|
||||
}, [closeMenus]);
|
||||
|
||||
const handleCheckPrStatus = useCallback(async () => {
|
||||
if (isCheckingPrStatus) return;
|
||||
closeMenus();
|
||||
@@ -2583,14 +2582,63 @@ export function TaskDetailContent({
|
||||
return providers;
|
||||
}, [workingTask.modelProvider, workingTask.validatorModelProvider, workingTask.planningModelProvider]);
|
||||
|
||||
// #1403: legacy transitions only exist for legacy columns; a custom column id
|
||||
// has no VALID_TRANSITIONS row, so the move menu shows no legacy targets.
|
||||
const transitions: Column[] = isColumn(task.column) ? [...VALID_TRANSITIONS[task.column]] : [];
|
||||
const inReviewMoveTransitions: Column[] = ["todo", "in-progress"];
|
||||
const moveTransitions = task.column === "in-review" ? inReviewMoveTransitions : transitions;
|
||||
const primaryMoveTransition = moveTransitions[0];
|
||||
const secondaryMoveTransitions = moveTransitions.slice(1);
|
||||
|
||||
const prAutomationLabel = getTaskPrAutomationLabel(t, task.status);
|
||||
const mergeStrategy = settings?.mergeStrategy ?? "direct";
|
||||
const autoMergeEnabled = autoMergeEnabledProp ?? (settings?.autoMerge ?? false);
|
||||
const effectiveAutoMerge = resolveEffectiveAutoMerge({ autoMerge: task.autoMerge }, { autoMerge: autoMergeEnabled });
|
||||
const isManualPrFlow = mergeStrategy === "pull-request" && !effectiveAutoMerge;
|
||||
const isChatExpanded = chatExpanded && activeTab === "chat" && !isEditing;
|
||||
|
||||
const taskActionMenuModel = useMemo(() => buildTaskActionMenuModel({
|
||||
task,
|
||||
t,
|
||||
columnLabel,
|
||||
canRetryTask,
|
||||
hasDuplicateHandler: Boolean(onDuplicateTask),
|
||||
hasRetryHandler: Boolean(onRetryTask),
|
||||
hasResetHandler: Boolean(onResetTask),
|
||||
mergeStrategy,
|
||||
autoMergeEnabled: effectiveAutoMerge,
|
||||
prAutomationLabel,
|
||||
isCheckingPrStatus,
|
||||
onDelete: handleDelete,
|
||||
onDuplicate: handleDuplicate,
|
||||
onOpenRefine: handleOpenRefineModal,
|
||||
onRespecify: handleRespecify,
|
||||
onRetry: handleRetry,
|
||||
onReset: handleReset,
|
||||
onTogglePause: handleTogglePause,
|
||||
onMerge: handleMergeMenuItemClick,
|
||||
onStartPrReview: handleStartPrReviewMenuItemClick,
|
||||
onCheckPrStatus: handleCheckPrStatus,
|
||||
}), [
|
||||
task,
|
||||
t,
|
||||
columnLabel,
|
||||
canRetryTask,
|
||||
onDuplicateTask,
|
||||
onRetryTask,
|
||||
onResetTask,
|
||||
mergeStrategy,
|
||||
effectiveAutoMerge,
|
||||
prAutomationLabel,
|
||||
isCheckingPrStatus,
|
||||
handleDelete,
|
||||
handleDuplicate,
|
||||
handleOpenRefineModal,
|
||||
handleRespecify,
|
||||
handleRetry,
|
||||
handleReset,
|
||||
handleTogglePause,
|
||||
handleMergeMenuItemClick,
|
||||
handleStartPrReviewMenuItemClick,
|
||||
handleCheckPrStatus,
|
||||
]);
|
||||
const primaryMoveTransition = taskActionMenuModel.moveTransitions[0]?.column;
|
||||
const secondaryMoveTransitions = taskActionMenuModel.moveTransitions.slice(1);
|
||||
const hasSecondaryMoveOptions = secondaryMoveTransitions.length > 0;
|
||||
const reviewAction = taskActionMenuModel.reviewAction;
|
||||
|
||||
const closeMoveMenuAndFocusTrigger = useCallback(() => {
|
||||
setShowMoveMenu(false);
|
||||
@@ -2600,7 +2648,7 @@ export function TaskDetailContent({
|
||||
const handleMoveButtonClick = useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (!hasSecondaryMoveOptions) {
|
||||
if (primaryMoveTransition) {
|
||||
void handleMoveMenuItemClick(primaryMoveTransition);
|
||||
void handleMoveMenuItemClick(primaryMoveTransition as Column);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -2618,7 +2666,7 @@ export function TaskDetailContent({
|
||||
}
|
||||
|
||||
if (primaryMoveTransition) {
|
||||
void handleMoveMenuItemClick(primaryMoveTransition);
|
||||
void handleMoveMenuItemClick(primaryMoveTransition as Column);
|
||||
}
|
||||
}, [hasSecondaryMoveOptions, primaryMoveTransition, handleMoveMenuItemClick]);
|
||||
|
||||
@@ -2656,31 +2704,6 @@ export function TaskDetailContent({
|
||||
firstMenuItem?.focus();
|
||||
}, [showMoveMenu]);
|
||||
|
||||
const prAutomationStatusLabels: Record<string, string> = {
|
||||
"creating-pr": t("taskDetail.pr.creatingPr", "Creating PR…"),
|
||||
"awaiting-pr-checks": t("taskDetail.pr.awaitingChecks", "Awaiting PR checks"),
|
||||
"merging-pr": t("taskDetail.pr.mergingPr", "Merging PR…"),
|
||||
"merging-fix": t("taskDetail.pr.mergingFixes", "Merging fixes…"),
|
||||
};
|
||||
const prAutomationLabel = task.status ? prAutomationStatusLabels[task.status] : undefined;
|
||||
const mergeStrategy = settings?.mergeStrategy ?? "direct";
|
||||
const autoMergeEnabled = autoMergeEnabledProp ?? (settings?.autoMerge ?? false);
|
||||
const effectiveAutoMerge = resolveEffectiveAutoMerge({ autoMerge: task.autoMerge }, { autoMerge: autoMergeEnabled });
|
||||
const isManualPrFlow = mergeStrategy === "pull-request" && !autoMergeEnabled;
|
||||
const isChatExpanded = chatExpanded && activeTab === "chat" && !isEditing;
|
||||
|
||||
const isCheckPrStatusAction = isManualPrFlow && !prAutomationLabel && task.prInfo?.status === "open";
|
||||
let manualReviewActionLabel = t("taskDetail.pr.mergeAndClose", "Merge & Close");
|
||||
if (isManualPrFlow && !prAutomationLabel) {
|
||||
if (!task.prInfo) {
|
||||
manualReviewActionLabel = t("taskDetail.pr.startPrReview", "Start PR Review");
|
||||
} else if (task.prInfo.status === "open") {
|
||||
manualReviewActionLabel = t("taskDetail.pr.checkPrStatus", "Check PR Status");
|
||||
} else if (task.prInfo.status === "merged") {
|
||||
manualReviewActionLabel = t("taskDetail.pr.finishAndClose", "Finish & Close");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`task-detail-content${embedded ? " task-detail-content--embedded" : ""}${isChatExpanded ? " task-detail-content--chat-expanded" : ""}`}
|
||||
@@ -4284,13 +4307,7 @@ export function TaskDetailContent({
|
||||
)}
|
||||
|
||||
{/* Actions dropdown — less common operations */}
|
||||
{(
|
||||
task.column !== "triage"
|
||||
|| task.status === "awaiting-approval"
|
||||
|| canRetryTask
|
||||
|| isTaskPaused
|
||||
|| Boolean(task.assignedAgentId)
|
||||
) && (
|
||||
{taskActionMenuModel.shouldShowActionsMenu && (
|
||||
<div className="detail-actions-dropdown" ref={actionsMenuRef}>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
@@ -4305,91 +4322,23 @@ export function TaskDetailContent({
|
||||
<ChevronDown size={12} />
|
||||
</button>
|
||||
{showActionsMenu && (
|
||||
<div className="detail-actions-menu" role="menu">
|
||||
{/* Delete — destructive, always first */}
|
||||
<button
|
||||
className="detail-actions-menu-item detail-actions-menu-item-danger"
|
||||
role="menuitem"
|
||||
onClick={() => handleActionsMenuItemClick(handleDelete)}
|
||||
>
|
||||
{t("taskDetail.delete.btn", "Delete")}
|
||||
</button>
|
||||
|
||||
{/* Duplicate */}
|
||||
{onDuplicateTask && (
|
||||
<button
|
||||
className="detail-actions-menu-item"
|
||||
role="menuitem"
|
||||
onClick={() => handleActionsMenuItemClick(handleDuplicate)}
|
||||
>
|
||||
{t("taskDetail.duplicate.btn", "Duplicate")}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Refine */}
|
||||
{(task.column === "done" || task.column === "in-review") && (
|
||||
<button
|
||||
className="detail-actions-menu-item"
|
||||
role="menuitem"
|
||||
onClick={() => handleActionsMenuItemClick(handleOpenRefineModal)}
|
||||
>
|
||||
{t("taskDetail.refine.btn", "Refine")}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Respecify */}
|
||||
<button
|
||||
className="detail-actions-menu-item"
|
||||
role="menuitem"
|
||||
onClick={() => handleActionsMenuItemClick(handleRespecify)}
|
||||
>
|
||||
{t("taskDetail.respecify.btn", "Respecify")}
|
||||
</button>
|
||||
|
||||
{/* Retry */}
|
||||
{canRetryTask && onRetryTask && (
|
||||
<button
|
||||
className="detail-actions-menu-item"
|
||||
role="menuitem"
|
||||
onClick={() => handleActionsMenuItemClick(handleRetry)}
|
||||
>
|
||||
{t("taskDetail.retry.btn", "Retry")}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Reset (nuclear) — wipes all progress and reallocates worktree */}
|
||||
{onResetTask && task.column !== "done" && task.column !== "archived" && (
|
||||
<button
|
||||
className="detail-actions-menu-item detail-actions-menu-item-danger"
|
||||
role="menuitem"
|
||||
onClick={() => handleActionsMenuItemClick(handleReset)}
|
||||
>
|
||||
{t("taskDetail.reset.btn", "Reset")}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<>
|
||||
{/*
|
||||
FNXC:TaskPauseControls 2026-06-21-00:00:
|
||||
Users may pause or unpause agent-assigned and agent-paused tasks at any time from the detail Actions menu. The Paused by agent note remains informational context, not a substitute for the actionable unpause control.
|
||||
*/}
|
||||
{task.column !== "done" && task.column !== "archived" && (
|
||||
<button
|
||||
className="detail-actions-menu-item"
|
||||
role="menuitem"
|
||||
onClick={() => handleActionsMenuItemClick(handleTogglePause)}
|
||||
>
|
||||
{isTaskPaused ? t("taskDetail.pause.unpauseBtn", "Unpause") : t("taskDetail.pause.pauseBtn", "Pause")}
|
||||
</button>
|
||||
)}
|
||||
{task.column !== "done" && task.column !== "archived" && task.paused && task.pausedByAgentId && (
|
||||
<span
|
||||
className="detail-actions-menu-item detail-actions-menu-note"
|
||||
role="note"
|
||||
>
|
||||
{t("taskDetail.pause.pausedByAgent", "Paused by agent")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<TaskContextMenu
|
||||
actions={taskActionMenuModel.actions}
|
||||
className="detail-actions-menu"
|
||||
itemClassName="detail-actions-menu-item"
|
||||
dangerItemClassName="detail-actions-menu-item-danger"
|
||||
noteItemClassName="detail-actions-menu-note"
|
||||
onActionSelect={(action) => {
|
||||
closeMenus();
|
||||
if (action.tone === "note") return;
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -4422,31 +4371,27 @@ export function TaskDetailContent({
|
||||
</button>
|
||||
{showMoveMenu && hasSecondaryMoveOptions && (
|
||||
<div className="detail-move-menu" role="menu" onKeyDown={handleMoveMenuKeyDown}>
|
||||
{secondaryMoveTransitions.map((col) => (
|
||||
{secondaryMoveTransitions.map((moveAction) => (
|
||||
<button
|
||||
key={col}
|
||||
key={moveAction.column}
|
||||
className="detail-move-menu-item"
|
||||
role="menuitem"
|
||||
onClick={() => handleMoveMenuItemClick(col)}
|
||||
onClick={() => handleMoveMenuItemClick(moveAction.column as Column)}
|
||||
onKeyDown={handleMoveMenuKeyDown}
|
||||
>
|
||||
{col === "in-progress" ? t("taskDetail.move.backToInProgress", "Back to In Progress") : t("taskDetail.move.moveTo", "Move to {{column}}", { column: columnLabel(col) })}
|
||||
{moveAction.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{prAutomationLabel ? (
|
||||
<button className="btn btn-primary btn-sm" disabled>
|
||||
{prAutomationLabel}
|
||||
</button>
|
||||
) : (
|
||||
{reviewAction && (
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={isCheckPrStatusAction ? handleCheckPrStatus : handleMergeMenuItemClick}
|
||||
disabled={isCheckPrStatusAction && isCheckingPrStatus}
|
||||
onClick={reviewAction.onSelect}
|
||||
disabled={reviewAction.disabled}
|
||||
>
|
||||
{manualReviewActionLabel}
|
||||
{reviewAction.label}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -4473,15 +4418,15 @@ export function TaskDetailContent({
|
||||
</button>
|
||||
{showMoveMenu && hasSecondaryMoveOptions && (
|
||||
<div className="detail-move-menu" role="menu" onKeyDown={handleMoveMenuKeyDown}>
|
||||
{secondaryMoveTransitions.map((col) => (
|
||||
{secondaryMoveTransitions.map((moveAction) => (
|
||||
<button
|
||||
key={col}
|
||||
key={moveAction.column}
|
||||
className="detail-move-menu-item"
|
||||
role="menuitem"
|
||||
onClick={() => handleMoveMenuItemClick(col)}
|
||||
onClick={() => handleMoveMenuItemClick(moveAction.column as Column)}
|
||||
onKeyDown={handleMoveMenuKeyDown}
|
||||
>
|
||||
{t("taskDetail.move.moveTo", "Move to {{column}}", { column: columnLabel(col) })}
|
||||
{moveAction.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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<Task>;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
globalPaused?: boolean;
|
||||
onUpdateTask?: (
|
||||
id: string,
|
||||
updates: { title?: string; description?: string; dependencies?: string[] }
|
||||
) => Promise<Task>;
|
||||
onPauseTask?: (id: string) => Promise<Task>;
|
||||
onRetryTask?: (id: string) => Promise<Task>;
|
||||
onUnpauseTask?: (id: string) => Promise<Task>;
|
||||
onResetTask?: (id: string) => Promise<Task>;
|
||||
onDuplicateTask?: (id: string) => Promise<Task>;
|
||||
onMergeTask?: (id: string) => Promise<MergeResult>;
|
||||
onArchiveTask?: (id: string, options?: { removeLineageReferences?: boolean }) => Promise<Task>;
|
||||
onUnarchiveTask?: (id: string) => Promise<Task>;
|
||||
onDeleteTask?: (id: string, options?: {
|
||||
removeDependencyReferences?: boolean;
|
||||
removeLineageReferences?: boolean;
|
||||
githubIssueAction?: GithubIssueAction;
|
||||
}) => Promise<Task>;
|
||||
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<string, readonly TaskContextMenuColumnMetadata[]>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="worktree-group">
|
||||
@@ -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)}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -39,6 +39,7 @@ vi.mock("../../api", () => ({
|
||||
fetchMission: vi.fn(),
|
||||
fetchAgent: vi.fn(),
|
||||
fetchAgents: vi.fn(),
|
||||
rebuildTaskSpec: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useConfirm", () => ({
|
||||
|
||||
@@ -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<boolean>>();
|
||||
@@ -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(
|
||||
<TaskCard
|
||||
task={makeTask({ column: "in-progress", status: "executing" as any })}
|
||||
onOpenDetail={onOpenDetail}
|
||||
addToast={noop}
|
||||
onPauseTask={onPauseTask}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<TaskCard
|
||||
task={makeTask({ column: "done", status: "done" as any })}
|
||||
onOpenDetail={onOpenDetail}
|
||||
addToast={noop}
|
||||
onArchiveTask={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<TaskCard
|
||||
task={makeTask({
|
||||
column: "in-progress",
|
||||
steps: [{ id: "s1", title: "done", status: "done" } as any],
|
||||
})}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
onMoveTask={onMoveTask}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<TaskCard
|
||||
task={makeTask({
|
||||
column: "in-review",
|
||||
prInfo: { number: 12, url: "https://example.test/pr/12", status: "open" } as any,
|
||||
})}
|
||||
projectId="project-1"
|
||||
onOpenDetail={onOpenDetail}
|
||||
addToast={noop}
|
||||
onMergeTask={vi.fn()}
|
||||
mergeStrategy="pull-request"
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<TaskCard
|
||||
task={makeTask({ column: "in-review" })}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
onMergeTask={onMergeTask}
|
||||
mergeStrategy="pull-request"
|
||||
autoMergeEnabled={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<TaskCard
|
||||
task={makeTask({ column: "in-review", status: "creating-pr" as any })}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
onMergeTask={onMergeTask}
|
||||
mergeStrategy="pull-request"
|
||||
autoMergeEnabled={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<TaskCard
|
||||
task={makeTask({ paused: true, userPaused: true })}
|
||||
onOpenDetail={onOpenDetail}
|
||||
addToast={noop}
|
||||
onUnpauseTask={vi.fn(async () => 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(
|
||||
<TaskCard
|
||||
task={makeTask()}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noop}
|
||||
onPauseTask={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<TaskCard
|
||||
@@ -4674,6 +4840,18 @@ describe("TaskCard memo comparator provenance behavior", () => {
|
||||
).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" } });
|
||||
|
||||
@@ -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<string, string>) => {
|
||||
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> = {}): 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<Parameters<typeof buildTaskActionMenuModel>[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<Task>),
|
||||
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(
|
||||
<TaskContextMenu
|
||||
actions={[{ id: "delete", label: "Delete", tone: "danger", onSelect: onDelete }]}
|
||||
onActionSelect={onActionSelect}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<TaskContextMenu
|
||||
actions={[
|
||||
{ id: "disabled", label: "Disabled", disabled: true },
|
||||
{ id: "pause", label: "Pause" },
|
||||
{ id: "delete", label: "Delete", tone: "danger" },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,7 @@ vi.mock("../../api", () => ({
|
||||
} satisfies Partial<Settings>),
|
||||
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([]),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -106,6 +106,7 @@ function mainContentProps(overrides: Partial<MainContentProps> = {}): MainConten
|
||||
workflowStepNameLookup: new Map(),
|
||||
prAuthAvailable: false,
|
||||
autoMerge: true,
|
||||
mergeStrategy: "direct",
|
||||
settingsLoaded: true,
|
||||
skillsEnabled: true,
|
||||
experimentalFeatures: {},
|
||||
|
||||
@@ -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<string, boolean>;
|
||||
|
||||
@@ -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<string>(".");
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user