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(