FN-005: remove duplicate planning move target

Prevent duplicate Planning menu entries for in-review task cards.

- Filter supplemental legacy move targets against declared visible workflow columns.
- Preserve legacy targets when workflow metadata is unavailable and cover default, legacy, hidden, and non-review menus.
- Add a patch changeset for the dashboard fix.

Files changed:
 .changeset/fn-005-duplicate-move-to-planning.md    |  7 ++
 packages/dashboard/app/components/TaskCard.tsx     | 33 +++-----
 .../app/components/__tests__/TaskCard.test.tsx     | 95 ++++++++++++++++++++++
 3 files changed, 112 insertions(+), 23 deletions(-)

Fusion-Task-Id: FN-005

Fusion-Task-Lineage: 6964d445-446c-449b-a606-a8fc3d87e34d

Co-authored-by: Fusion <noreply@runfusion.ai>
This commit is contained in:
Fusion Agent
2026-08-18 07:42:18 +00:00
parent 200b31089e
commit 6f91764f2e
3 changed files with 112 additions and 23 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Fix duplicate "Move to Planning" entry in the task card menu for review-lane tasks.
category: fix
dev: TaskCard's supplemental in-review move targets are now filtered against the workflow's declared columns, so the legacy `triage` id is not offered on workflows that no longer declare it.

View File

@@ -2881,31 +2881,18 @@ function TaskCardComponent({
The retired in-review Move dropdown offered Done (no merge) and Triage in addition to the shared menu model's Todo/In Progress defaults. Fold those targets into this TaskCard-only menu so card consolidation retains every move capability without changing ListView or TaskDetail menus.
*/
/*
FNXC:WorkflowResolvedColumns 2026-07-30-02:10 (CORRECTION of the note this replaced):
The previous version of this comment claimed `triage` was a column U11/#2515 had DELETED, making
this a live stale-target bug. THAT WAS WRONG, and it shipped. `triage` is a real, present column:
builtin-coding-workflow-ir.ts:49 { id: "triage", name: "Planning", traits: [{ trait: "intake" }] }
`triage` (intake) and `todo` (hold) are still SEPARATE columns on the default board, and `triage`
also exists in builtin-pr and builtin-lead-generation. I was carrying a merged-planning-column
shape from other work in this program and asserted it against the tree without checking the IR.
The move target is valid; there is no stale-target bug here.
WHAT IS ACTUALLY TRUE OF THIS SITE, and why it stays a literal. `column` below is the loop variable
over this function's OWN hardcoded `["done", "triage"]` array. The comparison asks "which entry of
my own list am I on" in order to pick a label — not "what role does this card's column play".
Resolving a trait for a string this code just wrote itself would be meaningless.
The ARRAY is the part worth revisiting, because it names move targets by id rather than by role,
so a workflow that renames those lanes gets targets it cannot show. That is a behaviour question
about which targets a review card should offer — and removing or changing a visible menu entry is
the UI-affordance change AGENTS requires a Surface Enumeration for (the workflow-row chevron took
FN-6115 -> FN-6118 -> FN-6123 for skipping it). Out of scope for a vocabulary conversion, but a
real question, unlike the one the old comment invented.
FNXC:BoardCardActions 2026-08-18-06:18 (FN-005):
Supplemental in-review targets are named by legacy id so card menus retain
Done (no merge) and legacy workflow parity. The default workflow no longer
declares `triage`; its `todo` column is labelled Planning. Without filtering,
COLUMN_LABELS.triage also reads as Planning and duplicates the declared todo
target. Only offer a supplemental target when loaded workflow metadata declares
a visible column; retain both legacy targets while metadata is unavailable.
*/
if (isReviewColumn) {
for (const column of ["done", "triage"] as const) {
const workflowColumn = taskMoveColumns?.find((candidate) => candidate.id === column);
if (taskMoveColumns && (!workflowColumn || workflowColumn.flags?.hiddenFromBoard)) continue;
if (moveTransitions.some((transition) => transition.column === column)) continue;
moveTransitions.push({
column,
@@ -2927,7 +2914,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, onPlanningMode, onOpenRefine, onPauseTask, onResetTask, onRetryTask, onRevertTask, onUnarchiveTask, onUnpauseTask, onUpdateTask, t, task.column, taskActionColumnLabel, 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, taskActionColumnLabel, taskActionMenuModel.actions, taskActionMenuModel.moveTransitions, taskActionMenuModel.reviewAction, taskMoveColumns]);
const hasContextMenuActions = contextMenuActions.length > 0;
const closeContextMenu = useCallback(() => {

View File

@@ -4826,6 +4826,101 @@ describe("TaskCard", () => {
expect(onMoveTask).toHaveBeenCalledWith("FN-001", "done", undefined);
});
it("offers only declared default-workflow targets from the in-review three-dot menu", async () => {
const onMoveTask = vi.fn();
const taskMoveColumns = [
{ id: "todo" as const, label: "Planning", flags: { hold: true, intake: true } },
{ id: "in-progress" as const, label: "In progress", flags: { countsTowardWip: true } },
{ id: "in-review" as const, label: "In review", flags: { mergeBlocker: true, humanReview: true }, moveTargets: ["done", "in-progress", "todo"] },
{ id: "done" as const, label: "Done", flags: { complete: true } },
{ id: "archived" as const, label: "Archived", flags: { archived: true } },
];
render(
<TaskCard
task={makeTask({ column: "in-review" })}
onOpenDetail={noop}
addToast={noop}
onMoveTask={onMoveTask}
taskMoveColumns={taskMoveColumns}
/>,
);
fireEvent.click(screen.getByTestId("card-menu-btn-FN-001"));
const planningMoves = screen.getAllByRole("menuitem", { name: "Move to Planning" });
expect(planningMoves).toHaveLength(1);
expect(screen.queryByRole("menuitem", { name: "Move to Triage" })).toBeNull();
fireEvent.click(planningMoves[0]);
expect(onMoveTask).toHaveBeenCalledWith("FN-001", "todo", undefined);
await waitFor(() => expect(screen.queryByRole("menu")).not.toBeInTheDocument());
fireEvent.contextMenu(document.querySelector(".card")!, { clientX: 24, clientY: 28 });
await waitFor(() => expect(screen.getAllByRole("menuitem", { name: "Move to Planning" })).toHaveLength(1));
expect(screen.getByRole("menuitem", { name: "Move to Done" })).toBeTruthy();
});
it("keeps the declared legacy triage target and hides hidden supplemental targets", () => {
const onMoveTask = vi.fn();
const legacyMoveColumns = [
{ id: "triage" as const, label: "Planning", flags: { intake: true } },
{ id: "todo" as const, label: "Todo", flags: { hold: true } },
{ id: "in-progress" as const, label: "In progress", flags: { countsTowardWip: true } },
{ id: "in-review" as const, label: "In review", flags: { mergeBlocker: true }, moveTargets: ["todo", "in-progress"] },
{ id: "done" as const, label: "Done", flags: { complete: true } },
];
const { rerender } = render(
<TaskCard
task={makeTask({ column: "in-review" })}
onOpenDetail={noop}
addToast={noop}
onMoveTask={onMoveTask}
taskMoveColumns={legacyMoveColumns}
/>,
);
fireEvent.click(screen.getByTestId("card-menu-btn-FN-001"));
const planningMoves = screen.getAllByRole("menuitem", { name: "Move to Planning" });
expect(planningMoves).toHaveLength(1);
fireEvent.click(planningMoves[0]);
expect(onMoveTask).toHaveBeenCalledWith("FN-001", "triage", undefined);
rerender(
<TaskCard
task={makeTask({ column: "in-review" })}
onOpenDetail={noop}
addToast={noop}
onMoveTask={onMoveTask}
taskMoveColumns={legacyMoveColumns.map((column) => column.id === "done" ? { ...column, flags: { ...column.flags, hiddenFromBoard: true } } : column)}
/>,
);
fireEvent.click(screen.getByTestId("card-menu-btn-FN-001"));
expect(screen.queryByRole("menuitem", { name: "Done (no merge)" })).toBeNull();
expect(screen.getAllByRole("menuitem", { name: "Move to Planning" })).toHaveLength(1);
});
it("leaves non-review menu transitions unchanged when workflow metadata is present", () => {
const taskMoveColumns = [
{ id: "todo" as const, label: "Planning", flags: { hold: true } },
{ id: "in-progress" as const, label: "In progress", flags: { countsTowardWip: true }, moveTargets: ["todo", "done"] },
{ id: "done" as const, label: "Done", flags: { complete: true } },
];
render(
<TaskCard
task={makeTask({ column: "in-progress" })}
onOpenDetail={noop}
addToast={noop}
onMoveTask={vi.fn()}
taskMoveColumns={taskMoveColumns}
/>,
);
fireEvent.click(screen.getByTestId("card-menu-btn-FN-001"));
expect(screen.getByRole("menuitem", { name: "Move to Planning" })).toBeTruthy();
expect(screen.getByRole("menuitem", { name: "Move to Done" })).toBeTruthy();
expect(screen.queryByRole("menuitem", { name: "Done (no merge)" })).toBeNull();
});
it("uses the three-dot menu for every in-progress move target without a Send back shell", () => {
const { container } = render(
<TaskCard