From 6f91764f2e21332fe0c0ea6a3f46772aac345d53 Mon Sep 17 00:00:00 2001 From: Fusion Agent Date: Tue, 18 Aug 2026 07:42:18 +0000 Subject: [PATCH] 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 --- .../fn-005-duplicate-move-to-planning.md | 7 ++ .../dashboard/app/components/TaskCard.tsx | 33 ++----- .../components/__tests__/TaskCard.test.tsx | 95 +++++++++++++++++++ 3 files changed, 112 insertions(+), 23 deletions(-) create mode 100644 .changeset/fn-005-duplicate-move-to-planning.md diff --git a/.changeset/fn-005-duplicate-move-to-planning.md b/.changeset/fn-005-duplicate-move-to-planning.md new file mode 100644 index 0000000000..e7456ed369 --- /dev/null +++ b/.changeset/fn-005-duplicate-move-to-planning.md @@ -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. diff --git a/packages/dashboard/app/components/TaskCard.tsx b/packages/dashboard/app/components/TaskCard.tsx index 7b3c6d5248..58999dd42f 100644 --- a/packages/dashboard/app/components/TaskCard.tsx +++ b/packages/dashboard/app/components/TaskCard.tsx @@ -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(() => { diff --git a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx index aacbc98361..fc921d9602 100644 --- a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx @@ -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( + , + ); + + 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( + , + ); + + 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( + 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( + , + ); + + 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(