fix(FN-7591): stop intake-column cards vanishing from the workflow board

Tasks added to a workflow whose intake column differs from the default
(e.g. Coding (Ideas) -> "ideas") disappeared from the board until a manual
reload. The board resolves a card's lane from the board-workflows
taskWorkflowIds map, which only refetches on mount/focus/workflow-CRUD SSE
-- never on task creation. A freshly created card was absent from that map,
fell back to the default workflow (no "ideas" column), and was dropped from
every lane.

- Board.tsx: force one board-workflows refetch (deferred a tick,
  signature-guarded) whenever a rendered task is missing from taskWorkflowIds,
  so its real workflow + intake column resolve for any create surface.
- Board.tsx: re-home a selected-workflow task whose column the workflow no
  longer declares into the intake lane instead of a phantom bucket.
- useBoardWorkflows.ts: widen refreshBoardWorkflows type to accept forceFresh.
- Add regression tests for tasks arriving via the tasks prop (SSE / non-board
  create surfaces) and the orphan-column safety net.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-05 16:10:36 -07:00
parent f30d55fae7
commit 8b4e5224ea
4 changed files with 171 additions and 4 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Fix tasks vanishing from the board after being added to a workflow like Coding (Ideas).
category: fix
dev: Board.tsx forces a board-workflows refetch (deferred one tick, signature-guarded) whenever a rendered task is missing from the taskWorkflowIds map, so its real workflow and intake column resolve regardless of which create surface added it; the single-workflow grouping also re-homes a task whose column its workflow no longer declares into the intake lane instead of dropping it. Fixes the FN-7591 regression where intake-column cards (column "ideas") fell back to the default workflow, which has no such column, and were filtered out until a manual reload.

View File

@@ -454,6 +454,46 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
: boardWorkflows.defaultWorkflowId; : boardWorkflows.defaultWorkflowId;
}, [boardWorkflows, knownWorkflowIds]); }, [boardWorkflows, knownWorkflowIds]);
/*
FNXC:WorkflowBoard 2026-07-05-14:20:
Invariant: every rendered task must resolve to its REAL workflow, or the board silently drops it.
A task created into a workflow whose intake column differs from the default (e.g. Coding (Ideas) → "ideas", per FN-7591) disappears until the next mount/focus/workflow-CRUD refetch. Cause: the task list (SSE) updates before the board-workflows `taskWorkflowIds` map, so getEffectiveTaskWorkflowId falls back to `defaultWorkflowId` (plain Coding), whose columns do not declare the intake column; the aggregate grouping then `continue`-skips the card and the single-workflow grouping files it into a never-rendered phantom bucket. The board's own quick-create handlers dodge this via applyOptimisticTaskWorkflow, but the shared create surfaces (QuickEntryBox / NewTaskModal / InlineCreateCard→TodoView / insight→task) route through useTaskHandlers and never seed the map. Fix at the invariant, not the create surface: whenever a rendered task is absent from taskWorkflowIds, force ONE board-workflows refetch so its persisted workflow selection (and intake column) resolves. Signature-guarded on the sorted unmapped-id set so we never spin an infinite refetch loop, and only run in workflow mode once the payload has loaded.
The refetch is deferred by one macrotask and re-checked against the latest state at fire time: the board's own quick-create commits the new task one microtask before applyOptimisticTaskWorkflow seeds it, so a synchronous refetch here would double-fire alongside the optimistic path. Deferring lets the seed land first — an already-mapped task is then skipped — so this only fetches for tasks that truly arrived without a workflow mapping.
*/
const boardWorkflowsRef = useRef(boardWorkflows);
boardWorkflowsRef.current = boardWorkflows;
const tasksRef = useRef(tasks);
tasksRef.current = tasks;
const lastUnmappedTaskSignatureRef = useRef<string | null>(null);
const unmappedRefetchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (!boardWorkflows || !workflowMode) return;
const unmapped = tasks
.filter((task) => boardWorkflows.taskWorkflowIds[task.id] === undefined)
.map((task) => task.id)
.sort();
if (unmapped.length === 0) {
lastUnmappedTaskSignatureRef.current = null;
return;
}
const signature = unmapped.join(",");
if (signature === lastUnmappedTaskSignatureRef.current) return;
lastUnmappedTaskSignatureRef.current = signature;
if (unmappedRefetchTimerRef.current) clearTimeout(unmappedRefetchTimerRef.current);
unmappedRefetchTimerRef.current = setTimeout(() => {
unmappedRefetchTimerRef.current = null;
const latestWorkflows = boardWorkflowsRef.current;
if (!latestWorkflows) return;
const stillUnmapped = tasksRef.current.some((task) => latestWorkflows.taskWorkflowIds[task.id] === undefined);
if (stillUnmapped) refreshBoardWorkflows({ forceFresh: true });
}, 0);
}, [boardWorkflows, refreshBoardWorkflows, tasks, workflowMode]);
useEffect(() => () => {
if (unmappedRefetchTimerRef.current) clearTimeout(unmappedRefetchTimerRef.current);
}, []);
const resolveWorkflowQuickCreateTarget = useCallback((targetWorkflowId: string, preferredColumnId?: string | null): ColumnId | undefined => { const resolveWorkflowQuickCreateTarget = useCallback((targetWorkflowId: string, preferredColumnId?: string | null): ColumnId | undefined => {
if (targetWorkflowId === ALL_WORKFLOWS_BOARD_VIEW_ID) return undefined; if (targetWorkflowId === ALL_WORKFLOWS_BOARD_VIEW_ID) return undefined;
const workflow = boardWorkflows?.workflows.find((candidate) => candidate.id === targetWorkflowId); const workflow = boardWorkflows?.workflows.find((candidate) => candidate.id === targetWorkflowId);
@@ -578,8 +618,15 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
const grouped: Record<string, Task[]> = {}; const grouped: Record<string, Task[]> = {};
if (!selectedWorkflow) return grouped; if (!selectedWorkflow) return grouped;
for (const column of selectedWorkflow.columns) grouped[column.id] = []; for (const column of selectedWorkflow.columns) grouped[column.id] = [];
/*
FNXC:WorkflowBoard 2026-07-05-14:20:
Safety net (defense in depth for the taskWorkflowIds refetch above): a card that passed the selected-workflow membership filter genuinely belongs on THIS board, so it must always land in a rendered lane. If its stored `column` is not one this workflow declares (a workflow edited to drop a column, or a create/refetch race that lands an intake-column card before its lane is known), re-home it for DISPLAY into the workflow's intake/first visible column instead of a `??=`-created bucket that is never rendered. Display-only — the task's stored column is untouched.
*/
for (const task of selectedWorkflowTasks) { for (const task of selectedWorkflowTasks) {
(grouped[task.column] ??= []).push(task); const columnId = grouped[task.column] !== undefined
? task.column
: (selectedWorkflowCreateColumnId ?? task.column);
(grouped[columnId] ??= []).push(task);
} }
for (const column of selectedWorkflow.columns) { for (const column of selectedWorkflow.columns) {
/* /*
@@ -592,7 +639,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
: sortTasksForDisplayColumn(grouped[column.id] ?? [], column.id as ColumnType); : sortTasksForDisplayColumn(grouped[column.id] ?? [], column.id as ColumnType);
} }
return grouped; return grouped;
}, [doneSortMode, selectedWorkflow, selectedWorkflowTasks]); }, [doneSortMode, selectedWorkflow, selectedWorkflowCreateColumnId, selectedWorkflowTasks]);
// Card-placed field defs grouped by workflow id (U13/KTD-14). Only recomputes // Card-placed field defs grouped by workflow id (U13/KTD-14). Only recomputes
// when the board-workflows payload changes, not on every SSE task tick. // when the board-workflows payload changes, not on every SSE task tick.

View File

@@ -183,6 +183,9 @@ function BoardHarness({ createdTaskId = "FN-new", createReturnsTask = true, onCr
onNewTask={vi.fn()} onNewTask={vi.fn()}
autoMerge autoMerge
onToggleAutoMerge={vi.fn()} onToggleAutoMerge={vi.fn()}
showWorktreeGrouping={false}
planAutoApproveEnabled={false}
onTogglePlanAutoApprove={vi.fn()}
workflowColumnsEnabled workflowColumnsEnabled
settingsLoaded settingsLoaded
/> />
@@ -360,3 +363,112 @@ describe("workflow lane quick-create visibility", () => {
expect(readWorkflowCache()?.taskWorkflowIds["FN-new"]).toBe(DEFAULT_WORKFLOW.id); expect(readWorkflowCache()?.taskWorkflowIds["FN-new"]).toBe(DEFAULT_WORKFLOW.id);
}); });
}); });
/*
FNXC:WorkflowBoard 2026-07-05-14:20:
Regression coverage for the disappearing intake-column card (Coding (Ideas) → "ideas", FN-7591 fallout).
Surface enumeration:
- Create-path independence: tasks that arrive via the `tasks` prop (SSE / QuickEntryBox / NewTaskModal / InlineCreateCard→TodoView / insight→task) — i.e. NOT the board's own optimistic-seeding quick-create — must still resolve their real workflow. Invariant: an unmapped rendered task forces one board-workflows refetch (Part A), and once mapped renders in its intake lane instead of being dropped.
- No infinite loop: if the refetch never maps the task, the signature guard fires the refetch at most once per distinct unmapped-id set.
- Orphan column safety net (Part B): a task that belongs to the selected workflow but whose stored column the workflow no longer declares renders in the intake lane, never vanishing.
*/
function boardProps(tasks: Task[]) {
return {
tasks,
projectId: PROJECT_ID,
maxConcurrent: 2,
onMoveTask: vi.fn(),
onOpenDetail: vi.fn(),
addToast: vi.fn(),
onQuickCreate: vi.fn(),
onNewTask: vi.fn(),
autoMerge: true,
onToggleAutoMerge: vi.fn(),
showWorktreeGrouping: false,
planAutoApproveEnabled: false,
onTogglePlanAutoApprove: vi.fn(),
workflowColumnsEnabled: true as const,
settingsLoaded: true as const,
};
}
describe("workflow lane visibility for externally-arriving tasks (FN-7591 disappearing-card fix)", () => {
it("force-refetches board-workflows and renders an intake-column task that arrives via the tasks prop (non-board create surface)", async () => {
// Model the server: board-workflows derives taskWorkflowIds from the current store tasks,
// so once the ideas task exists it maps to Coding (Ideas) on the next fetch.
const serverMappedIds = new Set<string>();
fetchBoardWorkflowsMock.mockImplementation(() => {
const map: Record<string, string> = {};
for (const id of serverMappedIds) map[id] = CODING_IDEAS_WORKFLOW.id;
return Promise.resolve(workflowPayload(map));
});
const { rerender } = render(<Board {...boardProps([])} />);
await screen.findByTestId("workflow-switcher");
selectWorkflow(CODING_IDEAS_WORKFLOW.id);
const callsBeforeArrival = fetchBoardWorkflowsMock.mock.calls.length;
// A card lands in the "ideas" intake column via a surface that does NOT optimistically
// seed taskWorkflowIds (the store already persisted its workflow selection).
serverMappedIds.add("FN-ext");
const ideasTask = mkTask({ id: "FN-ext", title: "Ext ideas card", column: "ideas" });
await act(async () => {
rerender(<Board {...boardProps([ideasTask])} />);
});
// Part A: an unmapped rendered task forces a fresh board-workflows fetch.
await waitFor(() => expect(fetchBoardWorkflowsMock.mock.calls.length).toBeGreaterThan(callsBeforeArrival));
// Once mapped, the card renders in the ideas lane instead of being dropped.
await waitFor(() => {
const ideasColumn = screen.getByTestId("column-ideas");
expect(within(ideasColumn).getByText("Ext ideas card")).toBeTruthy();
});
});
it("fires a bounded number of refetches for a persistently-unmapped task (no infinite loop)", async () => {
// The server never maps FN-ext, so it stays unmapped after every refetch.
fetchBoardWorkflowsMock.mockResolvedValue(workflowPayload({}));
const { rerender } = render(<Board {...boardProps([])} />);
await screen.findByTestId("workflow-switcher");
selectWorkflow(CODING_IDEAS_WORKFLOW.id);
const ideasTask = mkTask({ id: "FN-ext", title: "Ext ideas card", column: "ideas" });
await act(async () => {
rerender(<Board {...boardProps([ideasTask])} />);
});
// Let the single deferred refetch fire; the signature guard blocks reschedules for the same set.
await waitFor(() => expect(fetchBoardWorkflowsMock.mock.calls.length).toBeGreaterThanOrEqual(2));
const settled = fetchBoardWorkflowsMock.mock.calls.length;
// Extra renders with the SAME unmapped-id set must not schedule further refetches.
for (let i = 0; i < 3; i++) {
await act(async () => {
rerender(<Board {...boardProps([ideasTask])} />);
});
}
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 5));
});
expect(fetchBoardWorkflowsMock.mock.calls.length).toBe(settled);
});
it("renders a selected-workflow task whose column the workflow no longer declares in the intake lane (never dropped)", async () => {
// FN-orphan is correctly mapped to Coding (Ideas) but sits in a column the workflow does not declare.
fetchBoardWorkflowsMock.mockResolvedValue(workflowPayload({ "FN-orphan": CODING_IDEAS_WORKFLOW.id }));
const orphan = mkTask({ id: "FN-orphan", title: "Orphan column card", column: "removed-column" });
render(<Board {...boardProps([orphan])} />);
await screen.findByTestId("workflow-switcher");
selectWorkflow(CODING_IDEAS_WORKFLOW.id);
// Part B safety net: re-homed for display into the intake ("ideas") lane, not dropped.
await waitFor(() => {
const ideasColumn = screen.getByTestId("column-ideas");
expect(within(ideasColumn).getByText("Orphan column card")).toBeTruthy();
});
});
});

View File

@@ -54,8 +54,9 @@ export interface UseBoardWorkflowsResult {
/** True when the dashboard-only aggregate workflow view is selected. */ /** True when the dashboard-only aggregate workflow view is selected. */
isAllWorkflowsSelected: boolean; isAllWorkflowsSelected: boolean;
setSelectedWorkflowId: Dispatch<SetStateAction<string | null>>; setSelectedWorkflowId: Dispatch<SetStateAction<string | null>>;
/** Force a fresh fetch (used on switcher open, since task assignment changes emit no workflow SSE). */ /** Force a fresh fetch (used on switcher open, and when the board detects a rendered
refreshBoardWorkflows: () => void; * task missing from `taskWorkflowIds`, since task→workflow assignment emits no workflow SSE). */
refreshBoardWorkflows: (options?: { forceFresh?: boolean }) => void;
/** /**
* Raw state setter, exposed so Board can apply optimistic task→workflow assignment. * Raw state setter, exposed so Board can apply optimistic task→workflow assignment.
* Planning does not use this. * Planning does not use this.