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>
This commit is contained in:
7
.changeset/fn-7947-plan-context-menu-action.md
Normal file
7
.changeset/fn-7947-plan-context-menu-action.md
Normal file
@@ -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.
|
||||||
@@ -230,13 +230,14 @@ FNXC:PlannerOversight 2026-07-05-00:00: FN-7604 removed that desktop-inline bran
|
|||||||
<!-- FNXC:PlannerOversight 2026-07-04-18:00: FN-7519 adds a read-only Intervention Timeline. FN-7571 (2026-07-04-19:00) relocates it from an inline mount below the FN-7517 controls into the task-detail Activity view dropdown as a fourth "Interventions" segment, alongside Live/Feed/Raw. -->
|
<!-- FNXC:PlannerOversight 2026-07-04-18:00: FN-7519 adds a read-only Intervention Timeline. FN-7571 (2026-07-04-19:00) relocates it from an inline mount below the FN-7517 controls into the task-detail Activity view dropdown as a fourth "Interventions" segment, alongside Live/Feed/Raw. -->
|
||||||
- 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.
|
- 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.
|
- 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: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-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:TaskDetailRefine 2026-07-12-00:00: The Refine feedback modal must not be dismissed by the same mouse/touch interaction that opened it; backdrop dismissal follows the global default-off modal-dismiss preference.
|
FNXC:TaskDetailRefine 2026-07-12-00:00: The Refine feedback modal must not be dismissed by the same mouse/touch interaction that opened it; backdrop dismissal follows the global default-off modal-dismiss preference.
|
||||||
FNXC:DoneTaskRefine 2026-07-01-00:00: Completed Board card context menus must label Refine only because they now route to the real task-detail refinement feedback modal instead of a dead row or direct API call.
|
FNXC:DoneTaskRefine 2026-07-01-00:00: Completed Board card context menus must label Refine only because they now route to the real task-detail refinement feedback modal instead of a dead row or direct API call.
|
||||||
FNXC:TaskContextMenu 2026-07-01-00:00: Board/List touch context-menu item taps must invoke the selected action exactly once and close the menu, matching desktop right-click and keyboard context-menu activation.
|
FNXC:TaskContextMenu 2026-07-01-00:00: Board/List touch context-menu item taps must invoke the selected action exactly once and close the menu, matching desktop right-click and keyboard context-menu activation.
|
||||||
FNXC:TaskContextMenu 2026-07-01-00:00: Board card context menus must behave like independent overlays because Board columns intentionally clip and scroll their bodies for kanban containment.
|
FNXC:TaskContextMenu 2026-07-01-00:00: Board card context menus must behave like independent overlays because Board columns intentionally clip and scroll their bodies for kanban containment.
|
||||||
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: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. -->
|
||||||
<!-- 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: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-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. -->
|
<!-- 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. -->
|
||||||
@@ -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 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.
|
- 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.
|
- 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:TaskDetailRefine 2026-07-12-00:00: List-originated Refine uses the same task-detail feedback modal as Board and must preserve the stay-open invariant across desktop and mobile activation.
|
<!-- FNXC:TaskDetailRefine 2026-07-12-00:00: List-originated Refine uses the same task-detail feedback modal as Board and must preserve the stay-open invariant across desktop and mobile activation.
|
||||||
FNXC:ListView 2026-07-10-00:00: FN-7809 makes tablet List view use the same single-pane card/detail route as mobile because the desktop split sidebar clipped the primary controls and quick-add composer at 769–1024px.
|
FNXC:ListView 2026-07-10-00:00: FN-7809 makes tablet List view use the same single-pane card/detail route as mobile because the desktop split sidebar clipped the primary controls and quick-add composer at 769–1024px.
|
||||||
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-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.
|
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.
|
||||||
FNXC:DoneTaskRefine 2026-07-01-00:00: Completed List row/card context menus route Refine to the existing task-detail feedback modal so desktop right-click and mobile long-press share the same refinement flow.
|
FNXC:DoneTaskRefine 2026-07-01-00:00: Completed List row/card context menus route Refine to the existing task-detail feedback modal so desktop right-click and mobile long-press share the same refinement flow.
|
||||||
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-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. -->
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|||||||
@@ -942,6 +942,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
|
|||||||
onDuplicateTask={onDuplicateTask}
|
onDuplicateTask={onDuplicateTask}
|
||||||
onMergeTask={onMergeTask}
|
onMergeTask={onMergeTask}
|
||||||
onOpenDetail={onOpenDetail}
|
onOpenDetail={onOpenDetail}
|
||||||
|
onPlanningMode={onPlanningMode}
|
||||||
onOpenRefine={onOpenRefine}
|
onOpenRefine={onOpenRefine}
|
||||||
onOpenGroupModal={onOpenGroupModal}
|
onOpenGroupModal={onOpenGroupModal}
|
||||||
addToast={addToast}
|
addToast={addToast}
|
||||||
@@ -971,7 +972,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
|
|||||||
mergeStrategy={mergeStrategy}
|
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.
|
// 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 } : {})}
|
{...((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.flags.mergeBlocker || columnDef.flags.humanReview ? { onToggleAutoMerge: handleToggleAutoMerge } : {})}
|
||||||
{...(columnDef.id === "done" ? { onArchiveAllDone } : {})}
|
{...(columnDef.id === "done" ? { onArchiveAllDone } : {})}
|
||||||
{...(isDoneLikeColumn ? { doneSortMode, onDoneSortModeChange: setDoneSortMode } : {})}
|
{...(isDoneLikeColumn ? { doneSortMode, onDoneSortModeChange: setDoneSortMode } : {})}
|
||||||
@@ -1026,6 +1027,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
|
|||||||
onDuplicateTask={onDuplicateTask}
|
onDuplicateTask={onDuplicateTask}
|
||||||
onMergeTask={onMergeTask}
|
onMergeTask={onMergeTask}
|
||||||
onOpenDetail={onOpenDetail}
|
onOpenDetail={onOpenDetail}
|
||||||
|
onPlanningMode={onPlanningMode}
|
||||||
onOpenRefine={onOpenRefine}
|
onOpenRefine={onOpenRefine}
|
||||||
onOpenGroupModal={onOpenGroupModal}
|
onOpenGroupModal={onOpenGroupModal}
|
||||||
addToast={addToast}
|
addToast={addToast}
|
||||||
@@ -1053,7 +1055,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
|
|||||||
mergeStrategy={mergeStrategy}
|
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.
|
// 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 } : {})}
|
{...((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.flags.mergeBlocker || columnDef.flags.humanReview ? { onToggleAutoMerge: handleToggleAutoMerge } : {})}
|
||||||
{...(columnDef.id === "done" ? { onArchiveAllDone } : {})}
|
{...(columnDef.id === "done" ? { onArchiveAllDone } : {})}
|
||||||
{...(isWorkflowDoneLikeColumn ? { doneSortMode, onDoneSortModeChange: setDoneSortMode } : {})}
|
{...(isWorkflowDoneLikeColumn ? { doneSortMode, onDoneSortModeChange: setDoneSortMode } : {})}
|
||||||
@@ -1084,6 +1086,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
|
|||||||
onDuplicateTask={onDuplicateTask}
|
onDuplicateTask={onDuplicateTask}
|
||||||
onMergeTask={onMergeTask}
|
onMergeTask={onMergeTask}
|
||||||
onOpenDetail={onOpenDetail}
|
onOpenDetail={onOpenDetail}
|
||||||
|
onPlanningMode={onPlanningMode}
|
||||||
onOpenRefine={onOpenRefine}
|
onOpenRefine={onOpenRefine}
|
||||||
onOpenGroupModal={onOpenGroupModal}
|
onOpenGroupModal={onOpenGroupModal}
|
||||||
addToast={addToast}
|
addToast={addToast}
|
||||||
@@ -1139,6 +1142,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
|
|||||||
onDuplicateTask={onDuplicateTask}
|
onDuplicateTask={onDuplicateTask}
|
||||||
onMergeTask={onMergeTask}
|
onMergeTask={onMergeTask}
|
||||||
onOpenDetail={onOpenDetail}
|
onOpenDetail={onOpenDetail}
|
||||||
|
onPlanningMode={onPlanningMode}
|
||||||
onOpenRefine={onOpenRefine}
|
onOpenRefine={onOpenRefine}
|
||||||
onOpenGroupModal={onOpenGroupModal}
|
onOpenGroupModal={onOpenGroupModal}
|
||||||
addToast={addToast}
|
addToast={addToast}
|
||||||
@@ -1166,7 +1170,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
|
|||||||
autoMerge={autoMerge}
|
autoMerge={autoMerge}
|
||||||
mergeStrategy={mergeStrategy}
|
mergeStrategy={mergeStrategy}
|
||||||
{...(col === "triage" ? { planAutoApproveEnabled, onTogglePlanAutoApprove } : {})}
|
{...(col === "triage" ? { planAutoApproveEnabled, onTogglePlanAutoApprove } : {})}
|
||||||
{...(col === "triage" ? { onQuickCreate, onNewTask, onPlanningMode, onSubtaskBreakdown } : {})}
|
{...(col === "triage" ? { onQuickCreate, onNewTask, onSubtaskBreakdown } : {})}
|
||||||
{...(col === "in-review" ? { onToggleAutoMerge: handleToggleAutoMerge } : {})}
|
{...(col === "in-review" ? { onToggleAutoMerge: handleToggleAutoMerge } : {})}
|
||||||
{...(col === "done" ? { onArchiveAllDone, doneSortMode, onDoneSortModeChange: setDoneSortMode } : {})}
|
{...(col === "done" ? { onArchiveAllDone, doneSortMode, onDoneSortModeChange: setDoneSortMode } : {})}
|
||||||
{...(col === "archived" ? { collapsed: archivedCollapsed, onToggleCollapse: handleToggleArchivedCollapse, archivedHasMore, archivedLoadingMore, onLoadMoreArchived: onLoadMoreArchivedTasks } : {})}
|
{...(col === "archived" ? { collapsed: archivedCollapsed, onToggleCollapse: handleToggleArchivedCollapse, archivedHasMore, archivedLoadingMore, onLoadMoreArchived: onLoadMoreArchivedTasks } : {})}
|
||||||
|
|||||||
@@ -867,6 +867,8 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktree
|
|||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
onOpenDetail={onOpenDetail}
|
onOpenDetail={onOpenDetail}
|
||||||
onOpenRefine={onOpenRefine}
|
onOpenRefine={onOpenRefine}
|
||||||
|
onPlanningMode={onPlanningMode}
|
||||||
|
workflowId={workflowMode ? workflowId : undefined}
|
||||||
onMoveTask={onMoveTask}
|
onMoveTask={onMoveTask}
|
||||||
addToast={addToast}
|
addToast={addToast}
|
||||||
globalPaused={globalPaused}
|
globalPaused={globalPaused}
|
||||||
@@ -907,6 +909,8 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktree
|
|||||||
task={task}
|
task={task}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
onOpenDetail={onOpenDetail}
|
onOpenDetail={onOpenDetail}
|
||||||
|
onPlanningMode={onPlanningMode}
|
||||||
|
planningWorkflowId={workflowMode ? workflowId : taskWorkflowBadges?.get(task.id)?.workflowId ?? null}
|
||||||
onOpenRefine={onOpenRefine}
|
onOpenRefine={onOpenRefine}
|
||||||
onOpenGroupModal={onOpenGroupModal}
|
onOpenGroupModal={onOpenGroupModal}
|
||||||
addToast={addToast}
|
addToast={addToast}
|
||||||
|
|||||||
@@ -682,6 +682,15 @@ export function ListView({
|
|||||||
return listColumns.map((column) => ({ id: column.id, label: column.name, flags: column.flags }));
|
return listColumns.map((column) => ({ id: column.id, label: column.name, flags: column.flags }));
|
||||||
}, [listColumns, workflowMode]);
|
}, [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 => {
|
const isArchivedColumn = useCallback((column: ColumnId): boolean => {
|
||||||
return workflowMode ? Boolean(columnFlagsById.get(column)?.archived) : column === "archived";
|
return workflowMode ? Boolean(columnFlagsById.get(column)?.archived) : column === "archived";
|
||||||
}, [columnFlagsById, workflowMode]);
|
}, [columnFlagsById, workflowMode]);
|
||||||
@@ -1714,6 +1723,10 @@ export function ListView({
|
|||||||
mergeStrategy,
|
mergeStrategy,
|
||||||
prAutomationLabel: getTaskPrAutomationLabel(t, task.status),
|
prAutomationLabel: getTaskPrAutomationLabel(t, task.status),
|
||||||
onDelete: () => void handleListTaskDelete(task),
|
onDelete: () => void handleListTaskDelete(task),
|
||||||
|
onPlan: onPlanningMode ? () => {
|
||||||
|
const seed = (task.description ?? "").trim() || task.title || task.id;
|
||||||
|
onPlanningMode(seed, getTaskPlanningWorkflowId(task));
|
||||||
|
} : undefined,
|
||||||
onDuplicate: onDuplicateTask ? async () => {
|
onDuplicate: onDuplicateTask ? async () => {
|
||||||
const shouldDuplicate = await confirm({
|
const shouldDuplicate = await confirm({
|
||||||
title: t("taskDetail.duplicate.title", "Duplicate Task"),
|
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 });
|
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));
|
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(
|
const contextMenuActions = useMemo(
|
||||||
() => (contextMenuState ? buildListContextMenuActions(contextMenuState.task) : []),
|
() => (contextMenuState ? buildListContextMenuActions(contextMenuState.task) : []),
|
||||||
|
|||||||
@@ -483,6 +483,13 @@ interface TaskCardProps {
|
|||||||
projectId?: string;
|
projectId?: string;
|
||||||
queued?: boolean;
|
queued?: boolean;
|
||||||
onOpenDetail: (task: Task | TaskDetail) => void;
|
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;
|
onOpenRefine?: (task: Task | TaskDetail) => void;
|
||||||
onOpenGroupModal?: (groupId: string) => void;
|
onOpenGroupModal?: (groupId: string) => void;
|
||||||
addToast: (message: string, type?: ToastType) => 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?.workflowId === next.workflowBadge?.workflowId &&
|
||||||
previous.workflowBadge?.workflowName === next.workflowBadge?.workflowName &&
|
previous.workflowBadge?.workflowName === next.workflowBadge?.workflowName &&
|
||||||
previous.workflowBadge?.workflowIcon === next.workflowBadge?.workflowIcon &&
|
previous.workflowBadge?.workflowIcon === next.workflowBadge?.workflowIcon &&
|
||||||
|
previous.planningWorkflowId === next.planningWorkflowId &&
|
||||||
previous.taskColumnFlags === next.taskColumnFlags &&
|
previous.taskColumnFlags === next.taskColumnFlags &&
|
||||||
previous.taskMoveColumns === next.taskMoveColumns &&
|
previous.taskMoveColumns === next.taskMoveColumns &&
|
||||||
previous.cardFieldDefs === next.cardFieldDefs &&
|
previous.cardFieldDefs === next.cardFieldDefs &&
|
||||||
@@ -717,6 +725,7 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
|
|||||||
? true
|
? true
|
||||||
: JSON.stringify(previousTask.customFields ?? null) === JSON.stringify(nextTask.customFields ?? null)) &&
|
: JSON.stringify(previousTask.customFields ?? null) === JSON.stringify(nextTask.customFields ?? null)) &&
|
||||||
previous.onOpenDetail === next.onOpenDetail &&
|
previous.onOpenDetail === next.onOpenDetail &&
|
||||||
|
previous.onPlanningMode === next.onPlanningMode &&
|
||||||
previous.onOpenGroupModal === next.onOpenGroupModal &&
|
previous.onOpenGroupModal === next.onOpenGroupModal &&
|
||||||
previous.addToast === next.addToast &&
|
previous.addToast === next.addToast &&
|
||||||
previous.onUpdateTask === next.onUpdateTask &&
|
previous.onUpdateTask === next.onUpdateTask &&
|
||||||
@@ -857,6 +866,8 @@ function TaskCardComponent({
|
|||||||
projectId,
|
projectId,
|
||||||
queued,
|
queued,
|
||||||
onOpenDetail,
|
onOpenDetail,
|
||||||
|
onPlanningMode,
|
||||||
|
planningWorkflowId,
|
||||||
onOpenRefine,
|
onOpenRefine,
|
||||||
onOpenGroupModal,
|
onOpenGroupModal,
|
||||||
addToast,
|
addToast,
|
||||||
@@ -2213,6 +2224,12 @@ function TaskCardComponent({
|
|||||||
.catch((err) => addToast(getErrorMessage(err), "error"));
|
.catch((err) => addToast(getErrorMessage(err), "error"));
|
||||||
}, [addToast, confirm, onMergeTask, task.id, t]);
|
}, [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 handleTaskActionRespecify = useCallback(async () => {
|
||||||
const shouldRebuild = await confirm({
|
const shouldRebuild = await confirm({
|
||||||
title: t("taskDetail.plan.rebuildTitle", "Rebuild Plan"),
|
title: t("taskDetail.plan.rebuildTitle", "Rebuild Plan"),
|
||||||
@@ -2320,6 +2337,7 @@ function TaskCardComponent({
|
|||||||
prAutomationLabel: getTaskPrAutomationLabel(t, task.status),
|
prAutomationLabel: getTaskPrAutomationLabel(t, task.status),
|
||||||
onDelete: onDeleteTask ? handleTaskActionDelete : undefined,
|
onDelete: onDeleteTask ? handleTaskActionDelete : undefined,
|
||||||
onDuplicate: onDuplicateTask ? handleTaskActionDuplicate : undefined,
|
onDuplicate: onDuplicateTask ? handleTaskActionDuplicate : undefined,
|
||||||
|
onPlan: onPlanningMode ? handleTaskActionPlan : undefined,
|
||||||
onOpenRefine: onOpenRefine ? () => onOpenRefine(task) : undefined,
|
onOpenRefine: onOpenRefine ? () => onOpenRefine(task) : undefined,
|
||||||
onRespecify: handleTaskActionRespecify,
|
onRespecify: handleTaskActionRespecify,
|
||||||
onRetry: onRetryTask ? handleTaskActionRetry : undefined,
|
onRetry: onRetryTask ? handleTaskActionRetry : undefined,
|
||||||
@@ -2347,6 +2365,7 @@ function TaskCardComponent({
|
|||||||
handleTaskActionEnableGithubTracking,
|
handleTaskActionEnableGithubTracking,
|
||||||
handleTaskActionDuplicate,
|
handleTaskActionDuplicate,
|
||||||
handleTaskActionMerge,
|
handleTaskActionMerge,
|
||||||
|
handleTaskActionPlan,
|
||||||
handleTaskActionReset,
|
handleTaskActionReset,
|
||||||
handleTaskActionRespecify,
|
handleTaskActionRespecify,
|
||||||
handleTaskActionRetry,
|
handleTaskActionRetry,
|
||||||
@@ -2357,6 +2376,7 @@ function TaskCardComponent({
|
|||||||
onMergeTask,
|
onMergeTask,
|
||||||
onUpdateTask,
|
onUpdateTask,
|
||||||
onOpenDetail,
|
onOpenDetail,
|
||||||
|
onPlanningMode,
|
||||||
onOpenRefine,
|
onOpenRefine,
|
||||||
onPauseTask,
|
onPauseTask,
|
||||||
onUnpauseTask,
|
onUnpauseTask,
|
||||||
@@ -2366,7 +2386,7 @@ function TaskCardComponent({
|
|||||||
task.prInfo,
|
task.prInfo,
|
||||||
]);
|
]);
|
||||||
const contextMenuActions = useMemo<TaskMenuActionDescriptor[]>(() => {
|
const contextMenuActions = useMemo<TaskMenuActionDescriptor[]>(() => {
|
||||||
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 [];
|
return [];
|
||||||
}
|
}
|
||||||
const actions = [...taskActionMenuModel.actions];
|
const actions = [...taskActionMenuModel.actions];
|
||||||
@@ -2404,7 +2424,7 @@ function TaskCardComponent({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return actions.filter((action) => action.tone === "note" || action.disabled === true || Boolean(action.onSelect));
|
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 hasContextMenuActions = contextMenuActions.length > 0;
|
||||||
|
|
||||||
const closeContextMenu = useCallback(() => {
|
const closeContextMenu = useCallback(() => {
|
||||||
|
|||||||
@@ -87,6 +87,11 @@ export interface BuildTaskActionMenuModelOptions {
|
|||||||
isCheckingPrStatus?: boolean;
|
isCheckingPrStatus?: boolean;
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
onDuplicate?: () => 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;
|
onOpenRefine?: () => void;
|
||||||
onRespecify?: () => void;
|
onRespecify?: () => void;
|
||||||
onRetry?: () => void;
|
onRetry?: () => void;
|
||||||
@@ -130,6 +135,11 @@ function isMutableLiveColumn(column: string, flags?: TaskContextMenuColumnFlags)
|
|||||||
return column !== "done" && column !== "archived";
|
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 {
|
function isDefaultWorkflowColumnSet(columns: readonly TaskContextMenuColumnMetadata[]): boolean {
|
||||||
if (columns.length !== COLUMNS.length) return false;
|
if (columns.length !== COLUMNS.length) return false;
|
||||||
const ids = new Set(columns.map((column) => column.id));
|
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 });
|
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) {
|
if (isDoneOrReview(task.column, currentColumnFlags) && options.onOpenRefine) {
|
||||||
actions.push({ id: "refine", label: t("taskDetail.refine.btn", "Refine"), onSelect: options.onOpenRefine });
|
actions.push({ id: "refine", label: t("taskDetail.refine.btn", "Refine"), onSelect: options.onOpenRefine });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ interface WorktreeGroupProps {
|
|||||||
allTasks?: Task[];
|
allTasks?: Task[];
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
onOpenDetail: (task: Task | TaskDetail) => void;
|
onOpenDetail: (task: Task | TaskDetail) => void;
|
||||||
|
onPlanningMode?: (initialPlan: string, workflowId?: string | null) => void;
|
||||||
|
workflowId?: string | null;
|
||||||
onOpenRefine?: (task: Task | TaskDetail) => void;
|
onOpenRefine?: (task: Task | TaskDetail) => void;
|
||||||
onMoveTask?: (id: string, column: ColumnId, optionsOrPosition?: { preserveProgress?: boolean } | number) => Promise<Task>;
|
onMoveTask?: (id: string, column: ColumnId, optionsOrPosition?: { preserveProgress?: boolean } | number) => Promise<Task>;
|
||||||
addToast: (message: string, type?: ToastType) => void;
|
addToast: (message: string, type?: ToastType) => void;
|
||||||
@@ -71,6 +73,8 @@ function WorktreeGroupComponent({
|
|||||||
allTasks,
|
allTasks,
|
||||||
projectId,
|
projectId,
|
||||||
onOpenDetail,
|
onOpenDetail,
|
||||||
|
onPlanningMode,
|
||||||
|
workflowId,
|
||||||
onOpenRefine,
|
onOpenRefine,
|
||||||
onMoveTask,
|
onMoveTask,
|
||||||
addToast,
|
addToast,
|
||||||
@@ -109,6 +113,7 @@ function WorktreeGroupComponent({
|
|||||||
};
|
};
|
||||||
const getTaskContextMenuColumns = (task: Task) => taskContextMenuColumnsByTaskId?.get(task.id) ?? workflowContextMenuColumns;
|
const getTaskContextMenuColumns = (task: Task) => taskContextMenuColumnsByTaskId?.get(task.id) ?? workflowContextMenuColumns;
|
||||||
const getTaskColumnFlags = (task: Task) => getTaskContextMenuColumns(task)?.find((candidate) => candidate.id === task.column)?.flags;
|
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 (
|
return (
|
||||||
<div className="worktree-group">
|
<div className="worktree-group">
|
||||||
@@ -124,6 +129,8 @@ function WorktreeGroupComponent({
|
|||||||
task={task}
|
task={task}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
onOpenDetail={onOpenDetail}
|
onOpenDetail={onOpenDetail}
|
||||||
|
onPlanningMode={onPlanningMode}
|
||||||
|
planningWorkflowId={getTaskPlanningWorkflowId(task)}
|
||||||
onOpenRefine={onOpenRefine}
|
onOpenRefine={onOpenRefine}
|
||||||
onMoveTask={onMoveTask}
|
onMoveTask={onMoveTask}
|
||||||
taskColumnFlags={getTaskColumnFlags(task)}
|
taskColumnFlags={getTaskColumnFlags(task)}
|
||||||
@@ -161,6 +168,8 @@ function WorktreeGroupComponent({
|
|||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
queued
|
queued
|
||||||
onOpenDetail={onOpenDetail}
|
onOpenDetail={onOpenDetail}
|
||||||
|
onPlanningMode={onPlanningMode}
|
||||||
|
planningWorkflowId={getTaskPlanningWorkflowId(task)}
|
||||||
onOpenRefine={onOpenRefine}
|
onOpenRefine={onOpenRefine}
|
||||||
onMoveTask={onMoveTask}
|
onMoveTask={onMoveTask}
|
||||||
taskColumnFlags={getTaskColumnFlags(task)}
|
taskColumnFlags={getTaskColumnFlags(task)}
|
||||||
|
|||||||
@@ -809,6 +809,27 @@ describe("ListView", () => {
|
|||||||
viewportSpy.mockRestore();
|
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 () => {
|
it("enables GitHub tracking from desktop and mobile list context menus without selecting rows", async () => {
|
||||||
const desktopViewportSpy = mockDesktopViewport();
|
const desktopViewportSpy = mockDesktopViewport();
|
||||||
const onOpenDetail = vi.fn();
|
const onOpenDetail = vi.fn();
|
||||||
|
|||||||
@@ -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(
|
||||||
|
<TaskCard
|
||||||
|
task={makeTask({ column: "triage", description: "Plan from description", title: "Fallback title" })}
|
||||||
|
onOpenDetail={noop}
|
||||||
|
onPlanningMode={onPlanningMode}
|
||||||
|
planningWorkflowId="WF-intake"
|
||||||
|
addToast={noop}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<TaskCard
|
||||||
|
task={makeTask({ column: "ideas" as any, description: "", title: "Custom intake title" })}
|
||||||
|
taskColumnFlags={{ intake: true }}
|
||||||
|
onOpenDetail={noop}
|
||||||
|
onPlanningMode={onPlanningMode}
|
||||||
|
planningWorkflowId="WF-custom"
|
||||||
|
addToast={noop}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<TaskCard
|
||||||
|
task={makeTask({ column: "todo" })}
|
||||||
|
onOpenDetail={noop}
|
||||||
|
onPlanningMode={onPlanningMode}
|
||||||
|
addToast={noop}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<TaskCard
|
||||||
|
task={makeTask({ column: "triage" })}
|
||||||
|
onOpenDetail={noop}
|
||||||
|
addToast={noop}
|
||||||
|
onDeleteTask={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<TaskCard
|
||||||
|
task={makeTask({ column: "waiting" as any, description: "Touch plan seed" })}
|
||||||
|
taskColumnFlags={{ hold: true }}
|
||||||
|
onOpenDetail={noop}
|
||||||
|
onPlanningMode={onPlanningMode}
|
||||||
|
planningWorkflowId="WF-hold"
|
||||||
|
addToast={noop}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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 () => {
|
it("enables GitHub tracking from the board card context menu and hides the action after refresh", async () => {
|
||||||
const cleanupGeometry = mockBoardContextMenuGeometry();
|
const cleanupGeometry = mockBoardContextMenuGeometry();
|
||||||
const onOpenDetail = vi.fn();
|
const onOpenDetail = vi.fn();
|
||||||
|
|||||||
@@ -48,6 +48,38 @@ describe("TaskContextMenu shared task action model", () => {
|
|||||||
expect(actionIds(makeTask({ column: "archived" }), { hasResetHandler: true })).toEqual(["respecify", "delete"]);
|
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<Parameters<typeof buildTaskActionMenuModel>[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", () => {
|
it("exposes GitHub tracking enablement only for untracked tasks with a host callback", () => {
|
||||||
const onEnableGithubTracking = vi.fn();
|
const onEnableGithubTracking = vi.fn();
|
||||||
const untracked = buildTaskActionMenuModel({
|
const untracked = buildTaskActionMenuModel({
|
||||||
|
|||||||
Reference in New Issue
Block a user