From c4c8961c1ea56a98d59a5c2e19387f12d254bd61 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 21 Jun 2026 22:04:32 -0700 Subject: [PATCH] FN-6903: preserve workflow quick-create visibility Optimistically attach newly quick-created tasks to the active workflow lane until workflow metadata refetches. - Update board and list quick-create paths to cache the selected workflow for created tasks immediately.\n- Refresh board-workflows metadata after optimistic assignment so authoritative state reconciles.\n- Cover board/list workflow-lane quick-create visibility with regression tests.\n- Document the workflow-filtered quick-create behavior and add a patch changeset.\n\nFiles changed:\n .../fn-6903-workflow-lane-create-visibility.md | 5 +\n docs/dashboard-guide.md | 2 +-\n packages/dashboard/app/components/Board.tsx | 34 ++-\n packages/dashboard/app/components/ListView.tsx | 36 ++-\n ...d-quickcreate-workflow-lane-visibility.test.tsx | 319 +++++++++++++++++++++\n 5 files changed, 390 insertions(+), 6 deletions(-) Fusion-Task-Id: FN-6903 Fusion-Task-Lineage: dd2c6fa1-f2e8-4777-aa3c-69d000efb1b1 --- ...fn-6903-workflow-lane-create-visibility.md | 5 + docs/dashboard-guide.md | 2 +- packages/dashboard/app/components/Board.tsx | 34 +- .../dashboard/app/components/ListView.tsx | 36 +- ...ckcreate-workflow-lane-visibility.test.tsx | 319 ++++++++++++++++++ 5 files changed, 390 insertions(+), 6 deletions(-) create mode 100644 .changeset/fn-6903-workflow-lane-create-visibility.md create mode 100644 packages/dashboard/app/components/__tests__/board-quickcreate-workflow-lane-visibility.test.tsx diff --git a/.changeset/fn-6903-workflow-lane-create-visibility.md b/.changeset/fn-6903-workflow-lane-create-visibility.md new file mode 100644 index 0000000000..69d2e69766 --- /dev/null +++ b/.changeset/fn-6903-workflow-lane-create-visibility.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Tasks created from a selected non-default workflow lane now appear on that lane immediately instead of vanishing until the board-workflows metadata refetch catches up. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 11f40743a4..50d5b04412 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -238,7 +238,7 @@ Planning Mode now includes branch controls on the summary screen before you crea These values are sent with the Planning Mode create-task request as `branchSelection`, so created tasks persist branch/base-branch settings consistently with other branch-aware task creation flows. -When Planning Mode or Subtask Breakdown is opened from a workflow-filtered board lane, the create request also carries that active workflow selection. Single-task planning saves, planning breakdown saves, and subtask-breakdown saves create their tasks directly on the selected workflow lane instead of briefly landing on the default board. +When inline quick-create, Planning Mode, or Subtask Breakdown is opened from a workflow-filtered board/list lane, the create request also carries that active workflow selection. Quick-created tasks appear on the selected workflow lane immediately while board-workflows metadata refreshes, and planning saves, planning breakdown saves, and subtask-breakdown saves create their tasks directly on the selected workflow lane instead of briefly landing on the default board. Completed single-task planning sessions remain in the Planning Mode history after you create the task, and selecting one restores the completed summary instead of restarting the composer. History rows are deduplicated by session id even if the initial load and live session updates arrive out of order, and deleting a history entry now waits for the server delete to persist (failures keep the row visible and surface an error instead of silently disappearing until refresh). diff --git a/packages/dashboard/app/components/Board.tsx b/packages/dashboard/app/components/Board.tsx index 5bf36b11b8..fa4d9745d0 100644 --- a/packages/dashboard/app/components/Board.tsx +++ b/packages/dashboard/app/components/Board.tsx @@ -491,6 +491,38 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask }); }, [boardWorkflows, selectedWorkflow, tasks, workflowMode]); + const applyOptimisticTaskWorkflow = useCallback((taskId: string, workflowId: string) => { + setBoardWorkflowsState((previous) => { + if (!previous || previous.projectId !== projectId) return previous; + if (previous.payload.taskWorkflowIds[taskId]) return previous; + + const payload: BoardWorkflowsPayload = { + ...previous.payload, + taskWorkflowIds: { + ...previous.payload.taskWorkflowIds, + [taskId]: workflowId, + }, + }; + writeBoardWorkflowsCache(projectId, payload); + return { projectId, payload }; + }); + }, [projectId]); + + /** + * FNXC:WorkflowBoard 2026-06-21-21:34: + * A task created on a selected non-default workflow lane must render in that lane immediately. The task list updates before board-workflows taskWorkflowIds, so without this optimistic project-scoped assignment the filter falls back to the default workflow and hides the new card until the next metadata refetch (FN-6903). + */ + const handleWorkflowQuickCreate = useCallback(async (input: TaskCreateInput) => { + if (!onQuickCreate || !selectedWorkflow) return undefined; + const created = await onQuickCreate(input); + if (created?.id) { + const createdWorkflowId = (created as Task & { workflowId?: string }).workflowId ?? selectedWorkflow.id; + applyOptimisticTaskWorkflow(created.id, createdWorkflowId); + refreshBoardWorkflows(); + } + return created; + }, [applyOptimisticTaskWorkflow, onQuickCreate, refreshBoardWorkflows, selectedWorkflow]); + const selectedWorkflowArchivedColumn = useMemo(() => { if (!selectedWorkflow) return null; return selectedWorkflow.columns.find((column) => column.flags.archived) ?? null; @@ -658,7 +690,7 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask blockerFanoutMap={blockerFanoutMap} prAuthAvailable={prAuthAvailable} autoMerge={autoMerge} - {...(isCreateColumn ? { onQuickCreate, onNewTask, onPlanningMode, onSubtaskBreakdown } : {})} + {...(isCreateColumn ? { onQuickCreate: handleWorkflowQuickCreate, onNewTask, onPlanningMode, onSubtaskBreakdown } : {})} {...(columnDef.flags.mergeBlocker || columnDef.flags.humanReview ? { onToggleAutoMerge: handleToggleAutoMerge } : {})} {...(columnDef.id === "done" ? { onArchiveAllDone } : {})} /> diff --git a/packages/dashboard/app/components/ListView.tsx b/packages/dashboard/app/components/ListView.tsx index e0eba2446b..6cbf862bb8 100644 --- a/packages/dashboard/app/components/ListView.tsx +++ b/packages/dashboard/app/components/ListView.tsx @@ -687,17 +687,45 @@ export function ListView({ return target?.id; }, [listColumns]); - const handleListQuickCreate = useCallback((input: TaskCreateInput) => { + /** + * FNXC:WorkflowList 2026-06-21-21:37: + * List quick-create shares Board's workflow filtering invariant: when taskWorkflowIds lags task creation, optimistically recording the selected workflow keeps the newly-created row visible in the active workflow lane until the authoritative refetch reconciles it (FN-6903). + */ + const applyOptimisticTaskWorkflow = useCallback((taskId: string, workflowId: string) => { + setBoardWorkflowsState((previous) => { + if (!previous || previous.projectId !== projectId) return previous; + if (previous.payload.taskWorkflowIds[taskId]) return previous; + + const payload: BoardWorkflowsPayload = { + ...previous.payload, + taskWorkflowIds: { + ...previous.payload.taskWorkflowIds, + [taskId]: workflowId, + }, + }; + writeBoardWorkflowsCache(projectId, payload); + return { projectId, payload }; + }); + }, [projectId]); + + const handleListQuickCreate = useCallback(async (input: TaskCreateInput) => { const create = onQuickCreate ?? (async () => addToast(t("listView.taskCreationUnavailable", "Task creation not available"), "error")); if (workflowMode && selectedWorkflow && createTargetColumn) { - return create({ + const workflowId = input.workflowId ?? selectedWorkflow.id; + const created = await create({ ...input, column: input.column ?? createTargetColumn, - workflowId: input.workflowId ?? selectedWorkflow.id, + workflowId, }); + if (created?.id) { + const createdWorkflowId = (created as Task & { workflowId?: string }).workflowId ?? workflowId; + applyOptimisticTaskWorkflow(created.id, createdWorkflowId); + refreshBoardWorkflows(); + } + return created; } return create(input); - }, [addToast, createTargetColumn, onQuickCreate, selectedWorkflow, t, workflowMode]); + }, [addToast, applyOptimisticTaskWorkflow, createTargetColumn, onQuickCreate, refreshBoardWorkflows, selectedWorkflow, t, workflowMode]); // Column display labels 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 new file mode 100644 index 0000000000..1f76f1c3f1 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/board-quickcreate-workflow-lane-visibility.test.tsx @@ -0,0 +1,319 @@ +import React, { useState } from "react"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import type { Task, TaskCreateInput } from "@fusion/core"; +import { Board } from "../Board"; +import { ListView } from "../ListView"; +import type { BoardWorkflowsPayload } from "../../api"; + +const fetchBoardWorkflowsMock = vi.fn(); +const fetchTaskDetailMock = vi.fn(); +const batchUpdateTaskModelsMock = vi.fn(); +const fetchNodesMock = vi.fn(() => new Promise(() => {})); + +vi.mock("../../api", () => ({ + fetchWorkflowSteps: vi.fn(() => new Promise(() => {})), + fetchBoardWorkflows: (...args: unknown[]) => fetchBoardWorkflowsMock(...args), + promoteTask: vi.fn().mockResolvedValue({}), + fetchTaskDetail: (...args: unknown[]) => fetchTaskDetailMock(...args), + batchUpdateTaskModels: (...args: unknown[]) => batchUpdateTaskModelsMock(...args), + fetchNodes: (...args: unknown[]) => fetchNodesMock(...args), +})); + +vi.mock("../../sse-bus", () => ({ + subscribeSse: vi.fn(() => () => {}), +})); + +vi.mock("../Column", () => ({ + Column: ({ column, tasks, onQuickCreate, workflowId, workflowMode }: { + column: string; + tasks: Task[]; + onQuickCreate?: (input: TaskCreateInput) => Promise; + workflowId?: string; + workflowMode?: boolean; + }) => ( +
task.id))}> + {tasks.map((task) =>
{task.title}
)} + {onQuickCreate ? ( + + ) : null} +
+ ), +})); + +vi.mock("../QuickEntryBox", () => ({ + QuickEntryBox: ({ onCreate }: { onCreate?: (input: TaskCreateInput) => Promise }) => ( + + ), +})); + +vi.mock("../TaskDetailModal", () => ({ + TaskDetailContent: () =>
, +})); + +vi.mock("../CustomModelDropdown", () => ({ + CustomModelDropdown: () =>
, +})); + +const PROJECT_ID = "project-fn-6903"; + +const DEFAULT_WORKFLOW = { + id: "builtin:coding", + name: "Coding", + columns: [ + { id: "triage", name: "Triage", flags: { intake: true } }, + { id: "todo", name: "Todo", flags: { hold: true } }, + { id: "done", name: "Done", flags: { complete: true } }, + { id: "archived", name: "Archived", flags: { archived: true } }, + ], +}; + +const CUSTOM_WORKFLOW = { + id: "wf-custom", + name: "Custom Flow", + columns: [ + { id: "intake", name: "Intake", flags: { intake: true } }, + { id: "done", name: "Done", flags: { complete: true } }, + ], +}; + +function mkTask(overrides: Partial & { id: string }): Task { + return { + title: overrides.id, + description: "Task", + column: "triage", + dependencies: [], + steps: [], + currentStep: 0, + log: [], + createdAt: "2026-06-21T00:00:00.000Z", + updatedAt: "2026-06-21T00:00:00.000Z", + ...overrides, + }; +} + +function workflowPayload(taskWorkflowIds: Record, flagEnabled = true): BoardWorkflowsPayload { + return { + flagEnabled, + defaultWorkflowId: DEFAULT_WORKFLOW.id, + workflows: flagEnabled ? [DEFAULT_WORKFLOW, CUSTOM_WORKFLOW] : [], + taskWorkflowIds, + }; +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + +function readWorkflowCache(): BoardWorkflowsPayload | null { + const raw = window.sessionStorage.getItem(`fusion:board-workflows:${PROJECT_ID}`); + return raw ? JSON.parse(raw) as BoardWorkflowsPayload : null; +} + +function selectWorkflow(workflowId: string) { + fireEvent.click(screen.getByTestId("workflow-switcher")); + fireEvent.click(screen.getByTestId(`workflow-switcher-option-${workflowId}`)); +} + +function BoardHarness({ createdTaskId = "FN-new", createReturnsTask = true, onCreateInput }: { + createdTaskId?: string; + createReturnsTask?: boolean; + onCreateInput?: (input: TaskCreateInput) => void; +}) { + const [tasks, setTasks] = useState([]); + const onQuickCreate = vi.fn(async (input: TaskCreateInput) => { + onCreateInput?.(input); + if (!createReturnsTask) return undefined; + const task = mkTask({ + id: createdTaskId, + title: input.title ?? input.description ?? createdTaskId, + description: input.description ?? "Task", + column: input.column ?? "triage", + }); + setTasks((current) => [...current, task]); + return task; + }); + + return ( + + ); +} + +function ListHarness({ createdTaskId = "FN-new", createReturnsTask = true, onCreateInput }: { + createdTaskId?: string; + createReturnsTask?: boolean; + onCreateInput?: (input: TaskCreateInput) => void; +}) { + const [tasks, setTasks] = useState([]); + const onQuickCreate = vi.fn(async (input: TaskCreateInput) => { + onCreateInput?.(input); + if (!createReturnsTask) return undefined; + const task = mkTask({ + id: createdTaskId, + title: input.title ?? input.description ?? createdTaskId, + description: input.description ?? "Task", + column: input.column ?? "triage", + }); + setTasks((current) => [...current, task]); + return task; + }); + + return ( + + ); +} + +beforeEach(() => { + fetchBoardWorkflowsMock.mockReset(); + fetchTaskDetailMock.mockReset(); + batchUpdateTaskModelsMock.mockReset(); + fetchNodesMock.mockClear(); + window.sessionStorage.clear(); + window.localStorage.clear(); +}); + +afterEach(() => { + window.sessionStorage.clear(); + window.localStorage.clear(); +}); + +describe("workflow lane quick-create visibility", () => { + it.each([ + ["Board", BoardHarness, () => fireEvent.click(screen.getByTestId("quick-create-intake")), "Created wf-custom"], + ["ListView", ListHarness, () => fireEvent.click(screen.getByTestId("list-quick-create")), "Created from list"], + ] as const)("%s shows a task created in a non-default workflow lane before the board-workflows refetch resolves", async (_surface, Harness, create, title) => { + const refetch = deferred(); + fetchBoardWorkflowsMock + .mockResolvedValueOnce(workflowPayload({})) + .mockResolvedValueOnce(workflowPayload({})) + .mockReturnValueOnce(refetch.promise); + + render(); + await screen.findByTestId("workflow-switcher"); + selectWorkflow(CUSTOM_WORKFLOW.id); + + await act(async () => { + create(); + }); + + expect(screen.getByText(title)).toBeTruthy(); + expect(readWorkflowCache()?.taskWorkflowIds["FN-new"]).toBe(CUSTOM_WORKFLOW.id); + expect(fetchBoardWorkflowsMock).toHaveBeenCalledTimes(3); + + await act(async () => { + refetch.resolve(workflowPayload({ "FN-new": CUSTOM_WORKFLOW.id })); + await refetch.promise; + }); + + expect(screen.getByText(title)).toBeTruthy(); + }); + + it.each([ + ["Board", BoardHarness, () => fireEvent.click(screen.getByTestId("quick-create-triage")), "Created builtin:coding"], + ["ListView", ListHarness, () => fireEvent.click(screen.getByTestId("list-quick-create")), "Created from list"], + ] as const)("%s keeps default workflow quick-create visible immediately", async (_surface, Harness, create, title) => { + fetchBoardWorkflowsMock.mockResolvedValue(workflowPayload({})); + + render(); + await screen.findByTestId("workflow-switcher"); + + await act(async () => { + create(); + }); + + expect(screen.getByText(title)).toBeTruthy(); + }); + + it("leaves the legacy flag-off Board quick-create path unchanged", async () => { + const inputs: TaskCreateInput[] = []; + fetchBoardWorkflowsMock.mockResolvedValue(workflowPayload({}, false)); + + render( inputs.push(input)} />); + await waitFor(() => expect(screen.getByTestId("quick-create-triage")).toBeTruthy()); + + await act(async () => { + fireEvent.click(screen.getByTestId("quick-create-triage")); + }); + + expect(screen.getByText("Created legacy")).toBeTruthy(); + expect(inputs[0]?.workflowId).toBeUndefined(); + }); + + it.each([ + ["Board", BoardHarness, () => fireEvent.click(screen.getByTestId("quick-create-intake"))], + ["ListView", ListHarness, () => fireEvent.click(screen.getByTestId("list-quick-create"))], + ] as const)("%s does not crash or merge when quick-create resolves void", async (_surface, Harness, create) => { + fetchBoardWorkflowsMock.mockResolvedValue(workflowPayload({})); + + render(); + await screen.findByTestId("workflow-switcher"); + selectWorkflow(CUSTOM_WORKFLOW.id); + + await act(async () => { + create(); + }); + + expect(screen.queryByText(/Created/)).toBeNull(); + expect(readWorkflowCache()?.taskWorkflowIds["FN-new"]).toBeUndefined(); + }); + + it("does not overwrite a server-raced taskWorkflowIds entry in the Board cache", async () => { + fetchBoardWorkflowsMock.mockResolvedValue(workflowPayload({ "FN-new": DEFAULT_WORKFLOW.id })); + + render(); + await screen.findByTestId("workflow-switcher"); + selectWorkflow(CUSTOM_WORKFLOW.id); + + await act(async () => { + fireEvent.click(screen.getByTestId("quick-create-intake")); + }); + + expect(within(screen.getByTestId("column-intake")).queryByText("Created wf-custom")).toBeNull(); + expect(readWorkflowCache()?.taskWorkflowIds["FN-new"]).toBe(DEFAULT_WORKFLOW.id); + }); +});