From 86c5a891690851f6919c100278941967c0f09aed Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 31 Jul 2026 00:30:26 -0700 Subject: [PATCH] fix(dashboard): dropping into a renamed intake lane reset progress without asking (#3011) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The **sixth** instance of the async-memo shape #2998 documents — and the only one that **loses work** rather than mis-rendering. `handleDrop` gates the "Preserve Progress?" confirmation on the lane's role: ```js const shouldPrompt = hasStepProgress && isPreImplementationColumnRole(columnFlags, column); if (shouldPrompt) { const keepProgress = await confirm({ … }); … } ``` …but its `useCallback` deps were `[addToast, allTasks, column, confirm, onMoveTask, tasks, t]` — no `columnFlags`. The board resolves workflow traits after first paint, so the DOM keeps the closure built during the pre-load render, where `columnFlags` is `undefined` and the helper falls back to `LEGACY_PRE_IMPLEMENTATION_COLUMN_IDS`. A renamed intake lane is not a member. **Result: a card with completed steps dropped into that lane moves with `shouldPrompt === false`.** The user is never offered "Keep Progress", and the steps are reset silently. ## How this was found It is the last unverified candidate from the derivation-aware scan I posted on #2998, where I explicitly declined to file it as a bug without checking. Checking it is what turned it from a scanner hit into this. ## Severity, stated honestly `allTasks` and `tasks` are in the dep list and change identity on any task-list refresh, so the stale closure is rebuilt within seconds on a busy board. The exposure is the quiet gap right after the traits land — **bounded**, like the near-duplicate chip (#2997), not permanent like the ticker (#2996) whose only refreshing dependency fired at local midnight. Bounded still matters here because the cost is not a wrong pixel: it is completed steps discarded without a prompt, and the window is exactly when someone has just opened a board and starts dragging. ## Verification | state | result | |---|---| | clean | 2/2 pass | | revert `columnFlags` from the deps | **1 failed / 1 passed** | The paired negative asserts a non-pre-implementation lane still moves **without** prompting — a fix that prompts everywhere turns the dialog into noise that gets clicked through, costing the same progress it protects. The observable is `confirm`, not `onMoveTask`: whether the user was *asked* is the contract, and asserting on the move alone passes either way. `tsc -p tsconfig.app.json` 0 errors in the new file, lint clean, FNXC gate exit 0, 4/4 across both `Column` flags-arrival suites. ## Running tally of this shape ticker (#2996) · near-duplicate chip (#2997) · fan-out trait index (#2993) · merge signature (#3001) · lifecycle dates (#3007) · this. Six, in two components plus the fan-out path. #3001 called the merge signature "the last live site"; it was the last of *that* sweep's nine candidates, and two more have surfaced since from a different scan. Worth knowing before anyone declares the class closed again. Co-authored-by: Claude Opus 5 (1M context) --- .../column-drop-prompt-renamed-lanes.md | 7 ++ packages/dashboard/app/components/Column.tsx | 19 +++- .../Column.drop-prompt-flags-arrival.test.tsx | 98 +++++++++++++++++++ 3 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 .changeset/column-drop-prompt-renamed-lanes.md create mode 100644 packages/dashboard/app/components/__tests__/Column.drop-prompt-flags-arrival.test.tsx diff --git a/.changeset/column-drop-prompt-renamed-lanes.md b/.changeset/column-drop-prompt-renamed-lanes.md new file mode 100644 index 0000000000..585e457555 --- /dev/null +++ b/.changeset/column-drop-prompt-renamed-lanes.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Dropping a card with completed steps into a renamed intake lane now asks before resetting progress. +category: fix +dev: `handleDrop` in Column omitted `columnFlags` from its `useCallback` deps, so the pre-load closure saw the legacy lane ids and skipped the confirmation. diff --git a/packages/dashboard/app/components/Column.tsx b/packages/dashboard/app/components/Column.tsx index 08408e2f4d..88c1737627 100644 --- a/packages/dashboard/app/components/Column.tsx +++ b/packages/dashboard/app/components/Column.tsx @@ -536,7 +536,24 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktree addToast(getErrorMessage(err), "error"); } } - }, [addToast, allTasks, column, confirm, onMoveTask, tasks, t]); + /* + FNXC:WorkflowResolvedColumns 2026-07-31-00:40: + `columnFlags` BELONGS IN THIS LIST — the drop handler asks it whether this lane is pre-implementation. + + `shouldPrompt` gates the "Preserve Progress?" confirmation on + `isPreImplementationColumnRole(columnFlags, column)`. The flags arrive after first paint, and + `useCallback` without them in its deps hands the DOM the closure built during the pre-load render. + In that closure the helper falls back to `LEGACY_PRE_IMPLEMENTATION_COLUMN_IDS`, which does not + contain a renamed intake/hold lane — so `shouldPrompt` is false and a card with completed steps is + moved WITHOUT asking, silently resetting progress the user was meant to be offered a choice about. + + SEVERITY, STATED HONESTLY: `allTasks` and `tasks` are in this list and change identity on any + task-list refresh, so the stale closure is rebuilt within seconds on an active board — a window, + not a permanent wrong answer, like the near-duplicate chip and unlike the TaskCard ticker whose + refreshing dependency fired only at local midnight. The window is exactly the quiet gap after the + traits land, and a drop inside it loses work without a prompt. + */ + }, [addToast, allTasks, column, columnFlags, confirm, onMoveTask, tasks, t]); /* FNXC:BoardPromote 2026-07-25-04:55: diff --git a/packages/dashboard/app/components/__tests__/Column.drop-prompt-flags-arrival.test.tsx b/packages/dashboard/app/components/__tests__/Column.drop-prompt-flags-arrival.test.tsx new file mode 100644 index 0000000000..f0f92d0141 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/Column.drop-prompt-flags-arrival.test.tsx @@ -0,0 +1,98 @@ +/* +FNXC:WorkflowResolvedColumns 2026-07-31-00:45: +DROPPING ONTO A RENAMED INTAKE LANE RESET PROGRESS WITHOUT ASKING. + +`handleDrop` gates the "Preserve Progress?" confirmation on +`isPreImplementationColumnRole(columnFlags, column)`, but its `useCallback` deps omitted `columnFlags`. +The board resolves workflow traits after first paint, so the DOM kept the closure built during the +pre-load render — one holding `columnFlags === undefined`, where the helper falls back to +`LEGACY_PRE_IMPLEMENTATION_COLUMN_IDS` and a renamed intake lane is not a member. + +Consequence: a card with completed steps dropped into that lane moved with `shouldPrompt === false`, +so the user was never offered "Keep Progress" and the steps were reset silently. This is the only one +of the six instances of this shape that LOSES WORK rather than mis-renders. + +SEVERITY: `allTasks`/`tasks` are also in the dep list and change on any task-list refresh, so the +stale closure is rebuilt within seconds on a busy board. The exposure is the quiet gap right after +the traits land — bounded, like the near-duplicate chip, not permanent like the ticker. + +THE OBSERVABLE IS `confirm`, not the move: whether the prompt was offered is the contract. Asserting +on `onMoveTask` alone would pass whether or not the user was asked. +*/ + +import { describe, it, expect, vi } from "vitest"; +import { render, fireEvent, waitFor } from "@testing-library/react"; +import type { Task, Column as ColumnType } from "@fusion/core"; +import { Column } from "../Column"; + +const confirmMock = vi.hoisted(() => vi.fn()); +vi.mock("../../hooks/useConfirm", () => ({ useConfirm: () => ({ confirm: confirmMock }) })); +vi.mock("../TaskCard", () => ({ TaskCard: () =>
})); +vi.mock("../WorktreeGroup", () => ({ WorktreeGroup: () =>
})); +vi.mock("../QuickEntryBox", () => ({ QuickEntryBox: () =>
})); + +const BASE = { description: "t", createdAt: "2026-06-01T00:00:00.000Z", updatedAt: "2026-06-01T00:00:00.000Z" }; + +/* A card carrying real step progress — the only kind the prompt is meant to protect. */ +const worked = { + id: "KB-WORK", title: "has progress", column: "building", + steps: [{ id: "s1", name: "step", status: "done" }], + ...BASE, +} as unknown as Task; + +/* Stable identity across both renders: if this changed, the callback would be rebuilt for an + unrelated reason and the test would pass without the fix. */ +const allTasks = [worked]; + +const props = { + column: "drafting" as ColumnType, + maxConcurrent: 2, + showWorktreeGrouping: false, + onMoveTask: vi.fn().mockResolvedValue({} as Task), + onOpenDetail: vi.fn(), + addToast: vi.fn(), + tasks: [], + allTasks, +}; + +/** `drafting` is this board's intake lane — it just isn't called `todo`. */ +const DRAFTING_IS_INTAKE = { intake: true, hold: true } as const; + +function dropOnto(container: HTMLElement) { + const zone = container.querySelector(".column") ?? container.firstElementChild!; + fireEvent.drop(zone, { dataTransfer: { getData: () => "KB-WORK" } }); +} + +describe("the drop-progress prompt when column traits arrive after first paint", () => { + it("prompts once the renamed intake lane's flags have arrived", async () => { + confirmMock.mockReset(); + confirmMock.mockResolvedValue(true); + + const { container, rerender } = render(); + rerender(); + + dropOnto(container); + + /* Without `columnFlags` in the deps the DOM keeps the pre-load closure, `shouldPrompt` is false, + and the card moves with its progress reset and no question asked. */ + await waitFor(() => { expect(confirmMock).toHaveBeenCalledTimes(1); }); + }); + + /* + The paired negative: the fix must not turn every lane into a prompting one. A drop onto a lane that + is NOT pre-implementation must still move silently, or the prompt becomes noise and gets clicked + through — which costs the same progress it was added to protect. + */ + it("does not prompt for a lane that is not pre-implementation", async () => { + confirmMock.mockReset(); + confirmMock.mockResolvedValue(true); + + const { container, rerender } = render(); + rerender(); + + dropOnto(container); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(confirmMock).not.toHaveBeenCalled(); + }); +});