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>
209 lines
8.8 KiB
TypeScript
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";
|