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:
7
.changeset/fn-7591-intake-card-disappears.md
Normal file
7
.changeset/fn-7591-intake-card-disappears.md
Normal 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.
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user