Files
fusion/packages/dashboard/app/components/WorktreeGroup.tsx
gsxdsm 4e7e013d6f FN-7947: add Plan action to context menu for pre-execution task cards
Adds a Plan action to Board/List task context menus so triage/hold/intake cards can jump straight into Planning Mode without duplicating a task.

- Add `onPlan` handler and `isPreExecutionHoldColumn` gate to `TaskContextMenu` so Plan only appears for pre-execution (triage/intake/hold) columns, and only when a host wires the handler
- Wire the Plan action through `Board.tsx`, `Column.tsx`, `ListView.tsx`, and `WorktreeGroup.tsx` so both board and list views expose the new menu item
- Surface the Plan entry point on `TaskCard.tsx`
- Add test coverage in `TaskContextMenu.test.tsx`, `TaskCard.test.tsx`, and `ListView.test.tsx` for the new gating/wiring behavior
- Document the new action in `docs/dashboard-guide.md`
- Add a minor changeset for `@runfusion/fusion`

Files changed:
 .changeset/fn-7947-plan-context-menu-action.md     |  7 ++
 docs/dashboard-guide.md                            | 10 ++-
 packages/dashboard/app/components/Board.tsx        | 10 ++-
 packages/dashboard/app/components/Column.tsx       |  4 +
 packages/dashboard/app/components/ListView.tsx     | 15 +++-
 packages/dashboard/app/components/TaskCard.tsx     | 24 +++++-
 packages/dashboard/app/components/TaskContextMenu.tsx   | 18 ++++
 packages/dashboard/app/components/WorktreeGroup.tsx     |  9 ++
 packages/dashboard/app/components/__tests__/ListView.test.tsx     | 21 +++++
 packages/dashboard/app/components/__tests__/TaskCard.test.tsx     | 96 ++++++++++++++++++++++
 packages/dashboard/app/components/__tests__/TaskContextMenu.test.tsx  | 32 ++++++++
 11 files changed, 236 insertions(+), 10 deletions(-)

Fusion-Task-Id: FN-7947

Fusion-Task-Lineage: 41c759a2-e76b-4771-9421-c9805c4596e5

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-13 10:14:22 -07:00

209 lines
8.8 KiB
TypeScript

import { memo } from "react";
import { useTranslation } from "react-i18next";
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 { RevertTaskOptions, RevertTaskResult } from "../api";
import type { BlockerFanoutEntry } from "../hooks/useBlockerFanout";
import type { TaskContextMenuColumnMetadata } from "./TaskContextMenu";
interface WorktreeGroupProps {
label: string;
activeTasks: Task[];
queuedTasks: Task[];
allTasks?: Task[];
projectId?: string;
onOpenDetail: (task: Task | TaskDetail) => void;
onPlanningMode?: (initialPlan: string, workflowId?: string | null) => void;
workflowId?: string | null;
onOpenRefine?: (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>;
/* FNXC:TaskRevert 2026-07-05-00:00 (FN-7525): threaded alongside onArchiveTask/onUnarchiveTask. */
onRevertTask?: (id: string, body?: RevertTaskOptions) => Promise<RevertTaskResult>;
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;
/** Called when user clicks a mission badge on a task card */
onOpenMission?: (missionId: string) => void;
/** Timestamp (ms) when task data was last confirmed fresh from the server. Used for freshness-aware stuck detection. */
lastFetchTimeMs?: number;
/** Per-task card-placed custom field definitions (U13/KTD-14). */
taskCardFieldDefs?: ReadonlyMap<string, import("../api").WorkflowFieldDefinition[]>;
/** Trusted aggregate-board workflow badges keyed by task id; omitted in per-workflow and non-board surfaces. */
taskWorkflowBadges?: ReadonlyMap<string, { workflowId: string; workflowName: string; workflowIcon?: string }>;
/** Precomputed blocker fanout keyed by blocker task ID. */
blockerFanoutMap?: ReadonlyMap<string, BlockerFanoutEntry>;
/** Whether GitHub CLI auth is available for creating PRs from task cards. */
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({
label,
activeTasks,
queuedTasks,
allTasks,
projectId,
onOpenDetail,
onPlanningMode,
workflowId,
onOpenRefine,
onMoveTask,
addToast,
globalPaused,
onUpdateTask,
onPauseTask,
onRetryTask,
onUnpauseTask,
onResetTask,
onDuplicateTask,
onMergeTask,
onArchiveTask,
onUnarchiveTask,
onRevertTask,
onDeleteTask,
onOpenDetailWithTab,
taskStuckTimeoutMs,
onOpenMission,
lastFetchTimeMs,
taskCardFieldDefs,
taskWorkflowBadges,
blockerFanoutMap,
prAuthAvailable,
autoMergeEnabled,
mergeStrategy = "direct",
workflowContextMenuColumns,
taskContextMenuColumnsByTaskId,
}: WorktreeGroupProps) {
const { t } = useTranslation("app");
const upNextLabel = t("worktree.upNext", "Up Next");
const unassignedLabel = t("worktree.unassigned", "Unassigned");
const resolveNearDuplicateCanonicalInactive = (task: Task): boolean | undefined => {
const nearDuplicateOf = task.sourceMetadata?.nearDuplicateOf;
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;
const getTaskPlanningWorkflowId = (task: Task) => (task as Task & { workflowId?: string | null }).workflowId ?? taskWorkflowBadges?.get(task.id)?.workflowId ?? workflowId ?? null;
return (
<div className="worktree-group">
<div className="worktree-group-header">
<span className="worktree-icon">
{label === upNextLabel || label === unassignedLabel ? <ClipboardList size={14} /> : <GitBranch size={14} />}
</span>
<span className="worktree-label">{label}</span>
</div>
{activeTasks.map((task) => (
<TaskCard
key={task.id}
task={task}
projectId={projectId}
onOpenDetail={onOpenDetail}
onPlanningMode={onPlanningMode}
planningWorkflowId={getTaskPlanningWorkflowId(task)}
onOpenRefine={onOpenRefine}
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}
onRevertTask={onRevertTask}
onDeleteTask={onDeleteTask}
onOpenDetailWithTab={onOpenDetailWithTab}
taskStuckTimeoutMs={taskStuckTimeoutMs}
onOpenMission={onOpenMission}
lastFetchTimeMs={lastFetchTimeMs}
cardFieldDefs={taskCardFieldDefs?.get(task.id)}
workflowBadge={taskWorkflowBadges?.get(task.id)}
fanout={blockerFanoutMap?.get(task.id)}
prAuthAvailable={prAuthAvailable}
autoMergeEnabled={autoMergeEnabled}
mergeStrategy={mergeStrategy}
nearDuplicateCanonicalInactive={resolveNearDuplicateCanonicalInactive(task)}
/>
))}
{queuedTasks.map((task) => (
<TaskCard
key={task.id}
task={task}
projectId={projectId}
queued
onOpenDetail={onOpenDetail}
onPlanningMode={onPlanningMode}
planningWorkflowId={getTaskPlanningWorkflowId(task)}
onOpenRefine={onOpenRefine}
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}
onRevertTask={onRevertTask}
onDeleteTask={onDeleteTask}
onOpenDetailWithTab={onOpenDetailWithTab}
taskStuckTimeoutMs={taskStuckTimeoutMs}
onOpenMission={onOpenMission}
lastFetchTimeMs={lastFetchTimeMs}
cardFieldDefs={taskCardFieldDefs?.get(task.id)}
workflowBadge={taskWorkflowBadges?.get(task.id)}
fanout={blockerFanoutMap?.get(task.id)}
prAuthAvailable={prAuthAvailable}
autoMergeEnabled={autoMergeEnabled}
mergeStrategy={mergeStrategy}
nearDuplicateCanonicalInactive={resolveNearDuplicateCanonicalInactive(task)}
/>
))}
</div>
);
}
export const WorktreeGroup = memo(WorktreeGroupComponent);
WorktreeGroup.displayName = "WorktreeGroup";