diff --git a/.changeset/fn-7591-intake-card-disappears.md b/.changeset/fn-7591-intake-card-disappears.md new file mode 100644 index 0000000000..819f32512c --- /dev/null +++ b/.changeset/fn-7591-intake-card-disappears.md @@ -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. diff --git a/packages/dashboard/app/components/Board.tsx b/packages/dashboard/app/components/Board.tsx index f343fa8cfb..04d6b5934b 100644 --- a/packages/dashboard/app/components/Board.tsx +++ b/packages/dashboard/app/components/Board.tsx @@ -454,6 +454,46 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o : boardWorkflows.defaultWorkflowId; }, [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(null); + const unmappedRefetchTimerRef = useRef | 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 => { if (targetWorkflowId === ALL_WORKFLOWS_BOARD_VIEW_ID) return undefined; const workflow = boardWorkflows?.workflows.find((candidate) => candidate.id === targetWorkflowId); @@ -578,8 +618,15 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o const grouped: Record = {}; if (!selectedWorkflow) return grouped; 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) { - (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) { /* @@ -592,7 +639,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o : sortTasksForDisplayColumn(grouped[column.id] ?? [], column.id as ColumnType); } return grouped; - }, [doneSortMode, selectedWorkflow, selectedWorkflowTasks]); + }, [doneSortMode, selectedWorkflow, selectedWorkflowCreateColumnId, selectedWorkflowTasks]); // 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. diff --git a/packages/dashboard/app/components/__tests__/board-quickcreate-workflow-lane-visibility.test.tsx b/packages/dashboard/app/components/__tests__/board-quickcreate-workflow-lane-visibility.test.tsx index 28964c172a..b6c8167549 100644 --- a/packages/dashboard/app/components/__tests__/board-quickcreate-workflow-lane-visibility.test.tsx +++ b/packages/dashboard/app/components/__tests__/board-quickcreate-workflow-lane-visibility.test.tsx @@ -183,6 +183,9 @@ function BoardHarness({ createdTaskId = "FN-new", createReturnsTask = true, onCr onNewTask={vi.fn()} autoMerge onToggleAutoMerge={vi.fn()} + showWorktreeGrouping={false} + planAutoApproveEnabled={false} + onTogglePlanAutoApprove={vi.fn()} workflowColumnsEnabled settingsLoaded /> @@ -360,3 +363,112 @@ describe("workflow lane quick-create visibility", () => { 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(); + fetchBoardWorkflowsMock.mockImplementation(() => { + const map: Record = {}; + for (const id of serverMappedIds) map[id] = CODING_IDEAS_WORKFLOW.id; + return Promise.resolve(workflowPayload(map)); + }); + + const { rerender } = render(); + 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(); + }); + + // 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(); + 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(); + }); + + // 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(); + }); + } + 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(); + 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(); + }); + }); +}); diff --git a/packages/dashboard/app/hooks/useBoardWorkflows.ts b/packages/dashboard/app/hooks/useBoardWorkflows.ts index 8d2a44e469..e1abdbe765 100644 --- a/packages/dashboard/app/hooks/useBoardWorkflows.ts +++ b/packages/dashboard/app/hooks/useBoardWorkflows.ts @@ -54,8 +54,9 @@ export interface UseBoardWorkflowsResult { /** True when the dashboard-only aggregate workflow view is selected. */ isAllWorkflowsSelected: boolean; setSelectedWorkflowId: Dispatch>; - /** Force a fresh fetch (used on switcher open, since task assignment changes emit no workflow SSE). */ - refreshBoardWorkflows: () => void; + /** Force a fresh fetch (used on switcher open, and when the board detects a rendered + * 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. * Planning does not use this.