fix(dashboard): dropping into a renamed intake lane reset progress without asking (#3011)

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) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-31 00:30:26 -07:00
committed by GitHub
parent f10261f424
commit 86c5a89169
3 changed files with 123 additions and 1 deletions

View File

@@ -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.

View File

@@ -536,7 +536,24 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktree
addToast(getErrorMessage(err), "error"); 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: FNXC:BoardPromote 2026-07-25-04:55:

View File

@@ -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: () => <article /> }));
vi.mock("../WorktreeGroup", () => ({ WorktreeGroup: () => <div /> }));
vi.mock("../QuickEntryBox", () => ({ QuickEntryBox: () => <div /> }));
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(<Column {...(props as never)} />);
rerender(<Column {...(props as never)} columnFlags={DRAFTING_IS_INTAKE as never} />);
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(<Column {...(props as never)} />);
rerender(<Column {...(props as never)} columnFlags={{ countsTowardWip: true } as never} />);
dropOnto(container);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(confirmMock).not.toHaveBeenCalled();
});
});