From 4e7e013d6f832d4fab1119fe2f6eb86f76167ad6 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 13 Jul 2026 10:14:22 -0700 Subject: [PATCH] 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) --- .../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 + .../dashboard/app/components/ListView.tsx | 15 ++- .../dashboard/app/components/TaskCard.tsx | 24 ++++- .../app/components/TaskContextMenu.tsx | 18 ++++ .../app/components/WorktreeGroup.tsx | 9 ++ .../components/__tests__/ListView.test.tsx | 21 ++++ .../components/__tests__/TaskCard.test.tsx | 96 +++++++++++++++++++ .../__tests__/TaskContextMenu.test.tsx | 32 +++++++ 11 files changed, 236 insertions(+), 10 deletions(-) create mode 100644 .changeset/fn-7947-plan-context-menu-action.md diff --git a/.changeset/fn-7947-plan-context-menu-action.md b/.changeset/fn-7947-plan-context-menu-action.md new file mode 100644 index 0000000000..11d2b02b4e --- /dev/null +++ b/.changeset/fn-7947-plan-context-menu-action.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add a Plan action to planning/ideas/hold task cards that opens Planning Mode from the card. +category: feature +dev: Board and List task context menus now gate Plan on pre-execution hold/intake columns and wired planning handlers. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 977e7894e6..d500be7782 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -230,13 +230,14 @@ FNXC:PlannerOversight 2026-07-05-00:00: FN-7604 removed that desktop-inline bran - The task detail modal's **Activity** tab view dropdown (Live/Feed/Raw) gains a fourth **Interventions** option, shown only when planner oversight is active for the task (same gate as the former inline mount: `(hasTaskOversightOverride || workflowOversightResolved) && !oversightIsOff`). Selecting it renders the **Intervention Timeline** (`data-testid="planner-intervention-timeline"`) inside the Activity panel, listing every recorded planner-overseer intervention for the task, newest-first: watched stage, reason, action taken, outcome (with a `.status-dot` indicator using semantic outcome tokens), an attempt count/limit badge (only when both are present), and source links (agent log / review comment / failed check / merge error / PR state / generic URL). It renders a calm "No planner interventions yet" empty state rather than an empty shell when there are none. When oversight is off or unresolved, the Interventions option is absent from the dropdown entirely (no leftover empty segment), and if it was previously selected the view falls back to Live rather than leaving a blank panel. Entries are read via `GET /tasks/:id/overseer/interventions`, which assembles them from the existing run-audit store under the `overseer:intervention` mutation type (`recordPlannerIntervention`/`getPlannerInterventionTimeline` in `@fusion/core`). This is a pure read surface — FN-7520 wires the actual intervention-producing call-sites. - Task detail surfaces show the selected/effective workflow identity 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. The menu opens as an independent overlay so it stays visible beyond the card or column edge while remaining clamped to the viewport. On mobile, long-press opens that menu without selecting card text or showing native copy/paste callouts. Selecting an action applies that exact action once and dismisses the menu. Completed card context menus include **Refine**, which opens the existing task-detail refinement feedback modal for the same task and keeps it open until the operator uses an explicit close path or an enabled backdrop dismissal. +- Board task cards support a context menu from right-click, keyboard context menu / Shift+F10, the visible ⋯ button, or touch long-press for detail-aligned lifecycle actions without changing normal card clicks. The menu opens as an independent overlay so it stays visible beyond the card or column edge while remaining clamped to the viewport. On mobile, long-press opens that menu without selecting card text or showing native copy/paste callouts. Selecting an action applies that exact action once and dismisses the menu. Cards that are still in Planning/ideas/hold columns include **Plan**, which opens Planning Mode seeded from the card and creates a new planned task when completed; completed card context menus include **Refine**, which opens the existing task-detail refinement feedback modal for the same task and keeps it open until the operator uses an explicit close path or an enabled backdrop dismissal. +FNXC:TaskCardMobileSelection 2026-07-01-00:00: Mobile Board long-press is a task-action gesture, not a text-selection gesture; document that the native selection/copy callout is suppressed while normal card clicks and edit textareas keep their behavior. +FNXC:TaskContextMenu 2026-07-13-00:00: Pre-execution Planning/ideas/hold cards expose Plan only on Board/List menu hosts that wire Planning Mode, and the handoff creates a new task rather than mutating the source card. --> @@ -278,13 +279,14 @@ Features: - 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. - Desktop List view keeps the two-pane table/detail split. Tablet-width and mobile viewports use the single-pane card layout so list controls and quick-add stay full-width; tapping a task opens detail instead of selecting an embedded split pane. -- List rows and tablet/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. Selecting an action applies that exact action once and dismisses the menu, including **Refine** for completed tasks; the opened refinement feedback modal stays open until the operator closes it intentionally. +- List rows and tablet/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. Selecting an action applies that exact action once and dismisses the menu, including **Plan** for tasks still in Planning/ideas/hold columns and **Refine** for completed tasks; the opened refinement feedback modal stays open until the operator closes it intentionally. +FNXC:TaskContextMenu 2026-07-01-00:00: Mobile List card long-press action taps must select and dismiss through the same shared TaskContextMenu invariant as Board and Task Detail surfaces. +FNXC:TaskContextMenu 2026-07-13-00:00: List row/card menus share the Board Plan gating: Planning/ideas/hold tasks can seed Planning Mode only when the List host has the planning route wired. --> ![List view](./screenshots/list-view.png) diff --git a/packages/dashboard/app/components/Board.tsx b/packages/dashboard/app/components/Board.tsx index 6760b598bc..7a589b0a4d 100644 --- a/packages/dashboard/app/components/Board.tsx +++ b/packages/dashboard/app/components/Board.tsx @@ -942,6 +942,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o onDuplicateTask={onDuplicateTask} onMergeTask={onMergeTask} onOpenDetail={onOpenDetail} + onPlanningMode={onPlanningMode} onOpenRefine={onOpenRefine} onOpenGroupModal={onOpenGroupModal} addToast={addToast} @@ -971,7 +972,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o mergeStrategy={mergeStrategy} // FNXC:PlanApproval 2026-07-07-00:00: FN-7653 — the plan auto-approve shortcut belongs only to the intake/planning column, never to hold (Todo-like) columns; the built-in Coding workflow's Todo column carries the hold trait and was wrongly receiving this prop pair. {...((columnDef.flags.intake && !columnDef.flags.archived && !columnDef.flags.complete && !columnDef.flags.countsTowardWip && !columnDef.flags.mergeBlocker && !columnDef.flags.humanReview) ? { planAutoApproveEnabled, onTogglePlanAutoApprove } : {})} - {...(isCreateColumn && aggregateQuickCreateTarget ? { workflowId: aggregateQuickCreateTarget.workflowId, workflowOptions, defaultWorkflowId: boardWorkflows?.defaultWorkflowId ?? null, onQuickCreate: handleAggregateWorkflowQuickCreate, onNewTask, onPlanningMode, onSubtaskBreakdown } : {})} + {...(isCreateColumn && aggregateQuickCreateTarget ? { workflowId: aggregateQuickCreateTarget.workflowId, workflowOptions, defaultWorkflowId: boardWorkflows?.defaultWorkflowId ?? null, onQuickCreate: handleAggregateWorkflowQuickCreate, onNewTask, onSubtaskBreakdown } : {})} {...(columnDef.flags.mergeBlocker || columnDef.flags.humanReview ? { onToggleAutoMerge: handleToggleAutoMerge } : {})} {...(columnDef.id === "done" ? { onArchiveAllDone } : {})} {...(isDoneLikeColumn ? { doneSortMode, onDoneSortModeChange: setDoneSortMode } : {})} @@ -1026,6 +1027,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o onDuplicateTask={onDuplicateTask} onMergeTask={onMergeTask} onOpenDetail={onOpenDetail} + onPlanningMode={onPlanningMode} onOpenRefine={onOpenRefine} onOpenGroupModal={onOpenGroupModal} addToast={addToast} @@ -1053,7 +1055,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o mergeStrategy={mergeStrategy} // FNXC:PlanApproval 2026-07-07-00:00: FN-7653 — the plan auto-approve shortcut belongs only to the intake/planning column, never to hold (Todo-like) columns; the built-in Coding workflow's Todo column carries the hold trait and was wrongly receiving this prop pair. {...((columnDef.flags.intake && !columnDef.flags.archived && !columnDef.flags.complete && !columnDef.flags.countsTowardWip && !columnDef.flags.mergeBlocker && !columnDef.flags.humanReview) ? { planAutoApproveEnabled, onTogglePlanAutoApprove } : {})} - {...(isCreateColumn ? { workflowOptions, defaultWorkflowId: selectedWorkflow.id, onQuickCreate: handleWorkflowQuickCreate, onNewTask, onPlanningMode, onSubtaskBreakdown } : {})} + {...(isCreateColumn ? { workflowOptions, defaultWorkflowId: selectedWorkflow.id, onQuickCreate: handleWorkflowQuickCreate, onNewTask, onSubtaskBreakdown } : {})} {...(columnDef.flags.mergeBlocker || columnDef.flags.humanReview ? { onToggleAutoMerge: handleToggleAutoMerge } : {})} {...(columnDef.id === "done" ? { onArchiveAllDone } : {})} {...(isWorkflowDoneLikeColumn ? { doneSortMode, onDoneSortModeChange: setDoneSortMode } : {})} @@ -1084,6 +1086,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o onDuplicateTask={onDuplicateTask} onMergeTask={onMergeTask} onOpenDetail={onOpenDetail} + onPlanningMode={onPlanningMode} onOpenRefine={onOpenRefine} onOpenGroupModal={onOpenGroupModal} addToast={addToast} @@ -1139,6 +1142,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o onDuplicateTask={onDuplicateTask} onMergeTask={onMergeTask} onOpenDetail={onOpenDetail} + onPlanningMode={onPlanningMode} onOpenRefine={onOpenRefine} onOpenGroupModal={onOpenGroupModal} addToast={addToast} @@ -1166,7 +1170,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o autoMerge={autoMerge} mergeStrategy={mergeStrategy} {...(col === "triage" ? { planAutoApproveEnabled, onTogglePlanAutoApprove } : {})} - {...(col === "triage" ? { onQuickCreate, onNewTask, onPlanningMode, onSubtaskBreakdown } : {})} + {...(col === "triage" ? { onQuickCreate, onNewTask, onSubtaskBreakdown } : {})} {...(col === "in-review" ? { onToggleAutoMerge: handleToggleAutoMerge } : {})} {...(col === "done" ? { onArchiveAllDone, doneSortMode, onDoneSortModeChange: setDoneSortMode } : {})} {...(col === "archived" ? { collapsed: archivedCollapsed, onToggleCollapse: handleToggleArchivedCollapse, archivedHasMore, archivedLoadingMore, onLoadMoreArchived: onLoadMoreArchivedTasks } : {})} diff --git a/packages/dashboard/app/components/Column.tsx b/packages/dashboard/app/components/Column.tsx index 4a4a304e97..de77df3bfd 100644 --- a/packages/dashboard/app/components/Column.tsx +++ b/packages/dashboard/app/components/Column.tsx @@ -867,6 +867,8 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktree projectId={projectId} onOpenDetail={onOpenDetail} onOpenRefine={onOpenRefine} + onPlanningMode={onPlanningMode} + workflowId={workflowMode ? workflowId : undefined} onMoveTask={onMoveTask} addToast={addToast} globalPaused={globalPaused} @@ -907,6 +909,8 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktree task={task} projectId={projectId} onOpenDetail={onOpenDetail} + onPlanningMode={onPlanningMode} + planningWorkflowId={workflowMode ? workflowId : taskWorkflowBadges?.get(task.id)?.workflowId ?? null} onOpenRefine={onOpenRefine} onOpenGroupModal={onOpenGroupModal} addToast={addToast} diff --git a/packages/dashboard/app/components/ListView.tsx b/packages/dashboard/app/components/ListView.tsx index ffe2d29729..ac47d73204 100644 --- a/packages/dashboard/app/components/ListView.tsx +++ b/packages/dashboard/app/components/ListView.tsx @@ -682,6 +682,15 @@ export function ListView({ return listColumns.map((column) => ({ id: column.id, label: column.name, flags: column.flags })); }, [listColumns, workflowMode]); + const getTaskPlanningWorkflowId = useCallback((task: Task): string | null => { + const taskWorkflowId = (task as Task & { workflowId?: string | null }).workflowId; + if (taskWorkflowId) return taskWorkflowId; + if (workflowMode && boardWorkflows) { + return boardWorkflows.taskWorkflowIds[task.id] ?? boardWorkflows.defaultWorkflowId ?? null; + } + return null; + }, [boardWorkflows, workflowMode]); + const isArchivedColumn = useCallback((column: ColumnId): boolean => { return workflowMode ? Boolean(columnFlagsById.get(column)?.archived) : column === "archived"; }, [columnFlagsById, workflowMode]); @@ -1714,6 +1723,10 @@ export function ListView({ mergeStrategy, prAutomationLabel: getTaskPrAutomationLabel(t, task.status), onDelete: () => void handleListTaskDelete(task), + onPlan: onPlanningMode ? () => { + const seed = (task.description ?? "").trim() || task.title || task.id; + onPlanningMode(seed, getTaskPlanningWorkflowId(task)); + } : undefined, onDuplicate: onDuplicateTask ? async () => { const shouldDuplicate = await confirm({ title: t("taskDetail.duplicate.title", "Duplicate Task"), @@ -1817,7 +1830,7 @@ export function ListView({ 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, handleListContextEnableGithubTracking, handleListContextMove, handleListTaskArchive, handleListTaskDelete, handleListTaskRevert, isMobile, listContextMenuColumns, mergeStrategy, onDuplicateTask, onMergeTask, onOpenDetail, onPauseTask, onResetTask, onRetryTask, onUnpauseTask, onArchiveTask, onRevertTask, onTasksUpdated, projectId, t, useSinglePaneList]); + }, [addToast, autoMerge, columnFlagsById, confirm, getListColumnLabel, getTaskPlanningWorkflowId, handleListContextCheckPrStatus, handleListContextEnableGithubTracking, handleListContextMove, handleListTaskArchive, handleListTaskDelete, handleListTaskRevert, isMobile, listContextMenuColumns, mergeStrategy, onDuplicateTask, onMergeTask, onOpenDetail, onPlanningMode, onPauseTask, onResetTask, onRetryTask, onUnpauseTask, onArchiveTask, onRevertTask, onTasksUpdated, projectId, t, useSinglePaneList]); const contextMenuActions = useMemo( () => (contextMenuState ? buildListContextMenuActions(contextMenuState.task) : []), diff --git a/packages/dashboard/app/components/TaskCard.tsx b/packages/dashboard/app/components/TaskCard.tsx index 1b19385ad1..c0e14ffde0 100644 --- a/packages/dashboard/app/components/TaskCard.tsx +++ b/packages/dashboard/app/components/TaskCard.tsx @@ -483,6 +483,13 @@ interface TaskCardProps { projectId?: string; queued?: boolean; onOpenDetail: (task: Task | TaskDetail) => void; + /** + * FNXC:TaskCardPlanning 2026-07-13-00:00: + * Board/List cards in pre-execution hold columns can seed Planning Mode from their own task description/title. The callback is optional so read-only/dock hosts omit the Plan menu item instead of rendering a dead shell. + */ + onPlanningMode?: (initialPlan: string, workflowId?: string | null) => void; + /** Workflow selection to preserve when Planning Mode is launched from workflow-aware board cards. */ + planningWorkflowId?: string | null; onOpenRefine?: (task: Task | TaskDetail) => void; onOpenGroupModal?: (groupId: string) => void; addToast: (message: string, type?: ToastType) => void; @@ -710,6 +717,7 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo previous.workflowBadge?.workflowId === next.workflowBadge?.workflowId && previous.workflowBadge?.workflowName === next.workflowBadge?.workflowName && previous.workflowBadge?.workflowIcon === next.workflowBadge?.workflowIcon && + previous.planningWorkflowId === next.planningWorkflowId && previous.taskColumnFlags === next.taskColumnFlags && previous.taskMoveColumns === next.taskMoveColumns && previous.cardFieldDefs === next.cardFieldDefs && @@ -717,6 +725,7 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo ? true : JSON.stringify(previousTask.customFields ?? null) === JSON.stringify(nextTask.customFields ?? null)) && previous.onOpenDetail === next.onOpenDetail && + previous.onPlanningMode === next.onPlanningMode && previous.onOpenGroupModal === next.onOpenGroupModal && previous.addToast === next.addToast && previous.onUpdateTask === next.onUpdateTask && @@ -857,6 +866,8 @@ function TaskCardComponent({ projectId, queued, onOpenDetail, + onPlanningMode, + planningWorkflowId, onOpenRefine, onOpenGroupModal, addToast, @@ -2213,6 +2224,12 @@ function TaskCardComponent({ .catch((err) => addToast(getErrorMessage(err), "error")); }, [addToast, confirm, onMergeTask, task.id, t]); + const handleTaskActionPlan = useCallback(() => { + const seed = (task.description ?? "").trim() || task.title || task.id; + const taskWorkflowId = (task as Task & { workflowId?: string | null }).workflowId; + onPlanningMode?.(seed, taskWorkflowId ?? planningWorkflowId ?? null); + }, [onPlanningMode, planningWorkflowId, task, task.description, task.id, task.title]); + const handleTaskActionRespecify = useCallback(async () => { const shouldRebuild = await confirm({ title: t("taskDetail.plan.rebuildTitle", "Rebuild Plan"), @@ -2320,6 +2337,7 @@ function TaskCardComponent({ prAutomationLabel: getTaskPrAutomationLabel(t, task.status), onDelete: onDeleteTask ? handleTaskActionDelete : undefined, onDuplicate: onDuplicateTask ? handleTaskActionDuplicate : undefined, + onPlan: onPlanningMode ? handleTaskActionPlan : undefined, onOpenRefine: onOpenRefine ? () => onOpenRefine(task) : undefined, onRespecify: handleTaskActionRespecify, onRetry: onRetryTask ? handleTaskActionRetry : undefined, @@ -2347,6 +2365,7 @@ function TaskCardComponent({ handleTaskActionEnableGithubTracking, handleTaskActionDuplicate, handleTaskActionMerge, + handleTaskActionPlan, handleTaskActionReset, handleTaskActionRespecify, handleTaskActionRetry, @@ -2357,6 +2376,7 @@ function TaskCardComponent({ onMergeTask, onUpdateTask, onOpenDetail, + onPlanningMode, onOpenRefine, onPauseTask, onUnpauseTask, @@ -2366,7 +2386,7 @@ function TaskCardComponent({ task.prInfo, ]); const contextMenuActions = useMemo(() => { - if (!onDeleteTask && !onArchiveTask && !onUnarchiveTask && !onRevertTask && !onDuplicateTask && !onRetryTask && !onResetTask && !onPauseTask && !onUnpauseTask && !onMergeTask && !onMoveTask && !onOpenRefine && !onUpdateTask) { + if (!onDeleteTask && !onArchiveTask && !onUnarchiveTask && !onRevertTask && !onDuplicateTask && !onRetryTask && !onResetTask && !onPauseTask && !onUnpauseTask && !onMergeTask && !onMoveTask && !onPlanningMode && !onOpenRefine && !onUpdateTask) { return []; } const actions = [...taskActionMenuModel.actions]; @@ -2404,7 +2424,7 @@ function TaskCardComponent({ } } return actions.filter((action) => action.tone === "note" || action.disabled === true || Boolean(action.onSelect)); - }, [handleTaskActionArchive, handleTaskActionMove, handleTaskActionRevert, handleTaskActionUnarchive, isRevertable, onArchiveTask, onDeleteTask, onDuplicateTask, onMergeTask, onMoveTask, onOpenRefine, onPauseTask, onResetTask, onRetryTask, onRevertTask, onUnarchiveTask, onUnpauseTask, onUpdateTask, t, task.column, taskActionMenuModel.actions, taskActionMenuModel.moveTransitions, taskActionMenuModel.reviewAction]); + }, [handleTaskActionArchive, handleTaskActionMove, handleTaskActionRevert, handleTaskActionUnarchive, isRevertable, onArchiveTask, onDeleteTask, onDuplicateTask, onMergeTask, onMoveTask, onPlanningMode, onOpenRefine, onPauseTask, onResetTask, onRetryTask, onRevertTask, onUnarchiveTask, onUnpauseTask, onUpdateTask, t, task.column, taskActionMenuModel.actions, taskActionMenuModel.moveTransitions, taskActionMenuModel.reviewAction]); const hasContextMenuActions = contextMenuActions.length > 0; const closeContextMenu = useCallback(() => { diff --git a/packages/dashboard/app/components/TaskContextMenu.tsx b/packages/dashboard/app/components/TaskContextMenu.tsx index cc39ad9675..a1176171d4 100644 --- a/packages/dashboard/app/components/TaskContextMenu.tsx +++ b/packages/dashboard/app/components/TaskContextMenu.tsx @@ -87,6 +87,11 @@ export interface BuildTaskActionMenuModelOptions { isCheckingPrStatus?: boolean; onDelete?: () => void; onDuplicate?: () => void; + /* + FNXC:TaskContextMenu 2026-07-13-00:00: + Pre-execution task cards can open the same Planning Mode handoff as inline create, but only hosts that wire a planning route should expose the action so dock/plugin/detail surfaces never render a dead Plan item. + */ + onPlan?: () => void; onOpenRefine?: () => void; onRespecify?: () => void; onRetry?: () => void; @@ -130,6 +135,11 @@ function isMutableLiveColumn(column: string, flags?: TaskContextMenuColumnFlags) return column !== "done" && column !== "archived"; } +export function isPreExecutionHoldColumn(column: string, flags?: TaskContextMenuColumnFlags): boolean { + if (flags?.complete === true || flags?.archived === true) return false; + return column === "triage" || flags?.intake === true || flags?.hold === true; +} + function isDefaultWorkflowColumnSet(columns: readonly TaskContextMenuColumnMetadata[]): boolean { if (columns.length !== COLUMNS.length) return false; const ids = new Set(columns.map((column) => column.id)); @@ -242,6 +252,14 @@ export function buildTaskActionMenuModel(options: BuildTaskActionMenuModelOption actions.push({ id: "duplicate", label: t("taskDetail.duplicate.btn", "Duplicate"), onSelect: options.onDuplicate }); } + /* + FNXC:TaskContextMenu 2026-07-13-00:00: + Plan belongs only to pre-execution hold/intake cards and reuses the inline-create Planning Mode handoff. Omit it entirely unless the host injects `onPlan`, because Planning Mode creates a new task and unwired menu hosts must not show a disabled shell. + */ + if (options.onPlan && isPreExecutionHoldColumn(task.column, currentColumnFlags)) { + actions.push({ id: "plan", label: t("taskDetail.plan.openPlanningBtn", "Plan"), onSelect: options.onPlan }); + } + if (isDoneOrReview(task.column, currentColumnFlags) && options.onOpenRefine) { actions.push({ id: "refine", label: t("taskDetail.refine.btn", "Refine"), onSelect: options.onOpenRefine }); } diff --git a/packages/dashboard/app/components/WorktreeGroup.tsx b/packages/dashboard/app/components/WorktreeGroup.tsx index 449b859248..aeea54f020 100644 --- a/packages/dashboard/app/components/WorktreeGroup.tsx +++ b/packages/dashboard/app/components/WorktreeGroup.tsx @@ -16,6 +16,8 @@ interface WorktreeGroupProps { 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; addToast: (message: string, type?: ToastType) => void; @@ -71,6 +73,8 @@ function WorktreeGroupComponent({ allTasks, projectId, onOpenDetail, + onPlanningMode, + workflowId, onOpenRefine, onMoveTask, addToast, @@ -109,6 +113,7 @@ function WorktreeGroupComponent({ }; 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 (
@@ -124,6 +129,8 @@ function WorktreeGroupComponent({ task={task} projectId={projectId} onOpenDetail={onOpenDetail} + onPlanningMode={onPlanningMode} + planningWorkflowId={getTaskPlanningWorkflowId(task)} onOpenRefine={onOpenRefine} onMoveTask={onMoveTask} taskColumnFlags={getTaskColumnFlags(task)} @@ -161,6 +168,8 @@ function WorktreeGroupComponent({ projectId={projectId} queued onOpenDetail={onOpenDetail} + onPlanningMode={onPlanningMode} + planningWorkflowId={getTaskPlanningWorkflowId(task)} onOpenRefine={onOpenRefine} onMoveTask={onMoveTask} taskColumnFlags={getTaskColumnFlags(task)} diff --git a/packages/dashboard/app/components/__tests__/ListView.test.tsx b/packages/dashboard/app/components/__tests__/ListView.test.tsx index 555e37e4db..84d51e82ba 100644 --- a/packages/dashboard/app/components/__tests__/ListView.test.tsx +++ b/packages/dashboard/app/components/__tests__/ListView.test.tsx @@ -809,6 +809,27 @@ describe("ListView", () => { viewportSpy.mockRestore(); }); + it("opens Planning Mode from eligible list row menus and omits it for executing rows", async () => { + const viewportSpy = mockDesktopViewport(); + const onPlanningMode = vi.fn(); + const onOpenDetail = vi.fn(); + const tasks = [ + createMockTask({ id: "FN-030", title: "Planning row", description: "Seed from list", column: "triage" }), + createMockTask({ id: "FN-031", title: "Executing row", description: "Do not plan", column: "in-progress", status: "executing" }), + ]; + + renderListView({ tasks, onOpenDetail, onPlanningMode }); + + fireEvent.contextMenu(document.querySelector('.list-row[data-id="FN-030"]') as HTMLElement, { clientX: 40, clientY: 50 }); + fireEvent.click(screen.getByRole("menuitem", { name: "Plan" })); + expect(onPlanningMode).toHaveBeenCalledWith("Seed from list", null); + expect(onOpenDetail).not.toHaveBeenCalled(); + + fireEvent.contextMenu(document.querySelector('.list-row[data-id="FN-031"]') as HTMLElement, { clientX: 40, clientY: 50 }); + expect(screen.queryByRole("menuitem", { name: "Plan" })).not.toBeInTheDocument(); + viewportSpy.mockRestore(); + }); + it("enables GitHub tracking from desktop and mobile list context menus without selecting rows", async () => { const desktopViewportSpy = mockDesktopViewport(); const onOpenDetail = vi.fn(); diff --git a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx index a288691bdd..c7d13beeb4 100644 --- a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx @@ -514,6 +514,102 @@ describe("TaskCard", () => { } }); + it("opens Planning Mode from eligible pre-execution card menus only when wired", async () => { + const cleanupGeometry = mockBoardContextMenuGeometry(); + const onPlanningMode = vi.fn(); + try { + const { rerender } = render( + , + ); + + fireEvent.click(screen.getByTestId("card-menu-btn-FN-001")); + await waitFor(() => expectBoardContextMenuPortaled()); + fireEvent.click(screen.getByRole("menuitem", { name: "Plan" })); + expect(onPlanningMode).toHaveBeenCalledWith("Plan from description", "WF-intake"); + expect(screen.queryByRole("menu")).not.toBeInTheDocument(); + + rerender( + , + ); + fireEvent.click(screen.getByTestId("card-menu-btn-FN-001")); + await waitFor(() => expectBoardContextMenuPortaled()); + fireEvent.click(screen.getByRole("menuitem", { name: "Plan" })); + expect(onPlanningMode).toHaveBeenLastCalledWith("Custom intake title", "WF-custom"); + + rerender( + , + ); + fireEvent.click(screen.getByTestId("card-menu-btn-FN-001")); + await waitFor(() => expectBoardContextMenuPortaled()); + expect(screen.queryByRole("menuitem", { name: "Plan" })).not.toBeInTheDocument(); + fireEvent.keyDown(document, { key: "Escape" }); + await waitFor(() => expect(screen.queryByRole("menu")).not.toBeInTheDocument()); + + rerender( + , + ); + fireEvent.contextMenu(document.querySelector(".card")!, { clientX: 24, clientY: 28 }); + expect(screen.queryByRole("menuitem", { name: "Plan" })).not.toBeInTheDocument(); + } finally { + cleanupGeometry(); + } + }); + + it("opens Planning Mode from the mobile/touch long-press menu for custom hold cards", async () => { + vi.useFakeTimers(); + const cleanupGeometry = mockBoardContextMenuGeometry(); + const onPlanningMode = vi.fn(); + try { + render( + , + ); + + const card = document.querySelector(".card") as HTMLElement; + fireEvent.pointerDown(card, { pointerType: "touch", pointerId: 1, clientX: 32, clientY: 36 }); + act(() => vi.advanceTimersByTime(550)); + + expectBoardContextMenuPortaled(); + fireEvent.pointerUp(screen.getByRole("menuitem", { name: "Plan" }), { pointerType: "touch", pointerId: 2 }); + await act(async () => { + await Promise.resolve(); + }); + expect(onPlanningMode).toHaveBeenCalledWith("Touch plan seed", "WF-hold"); + expect(screen.queryByRole("menu")).not.toBeInTheDocument(); + } finally { + cleanupGeometry(); + } + }); + it("enables GitHub tracking from the board card context menu and hides the action after refresh", async () => { const cleanupGeometry = mockBoardContextMenuGeometry(); const onOpenDetail = vi.fn(); diff --git a/packages/dashboard/app/components/__tests__/TaskContextMenu.test.tsx b/packages/dashboard/app/components/__tests__/TaskContextMenu.test.tsx index 55a86d1267..d8bdb977f6 100644 --- a/packages/dashboard/app/components/__tests__/TaskContextMenu.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskContextMenu.test.tsx @@ -48,6 +48,38 @@ describe("TaskContextMenu shared task action model", () => { expect(actionIds(makeTask({ column: "archived" }), { hasResetHandler: true })).toEqual(["respecify", "delete"]); }); + it("exposes Plan only for pre-execution hold columns with a host callback", () => { + const onPlan = vi.fn(); + const eligibleCases: Array<[string, Partial[0]>]> = [ + ["triage", {}], + ["custom intake", { currentColumnFlags: { intake: true } }], + ["custom hold", { currentColumnFlags: { hold: true } }], + ]; + + for (const [label, overrides] of eligibleCases) { + const column = label === "triage" ? "triage" : label; + const model = buildTaskActionMenuModel({ + task: makeTask({ column: column as any }), + t, + columnLabel: columnLabel as any, + onPlan, + ...overrides, + }); + expect(model.actions.map((action) => action.id), label).toContain("plan"); + expect(model.actions.find((action) => action.id === "plan")?.label).toBe("Plan"); + } + + for (const column of ["todo", "in-progress", "in-review", "done"] as const) { + expect(actionIds(makeTask({ column }), { onPlan })).not.toContain("plan"); + } + expect(actionIds(makeTask({ column: "complete" as any }), { onPlan, currentColumnFlags: { hold: true, complete: true } })).not.toContain("plan"); + expect(actionIds(makeTask({ column: "cold-storage" as any }), { onPlan, currentColumnFlags: { hold: true, archived: true } })).not.toContain("plan"); + expect(actionIds(makeTask({ column: "triage" }))).not.toContain("plan"); + + buildTaskActionMenuModel({ task: makeTask({ column: "triage" }), t, columnLabel: columnLabel as any, onPlan }).actions.find((action) => action.id === "plan")?.onSelect?.(); + expect(onPlan).toHaveBeenCalledTimes(1); + }); + it("exposes GitHub tracking enablement only for untracked tasks with a host callback", () => { const onEnableGithubTracking = vi.fn(); const untracked = buildTaskActionMenuModel({