From b36327059a229fd6ec230d109bae1871c39fe690 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 29 Jun 2026 14:41:52 -0700 Subject: [PATCH] FN-7234: preserve board workflow selections Persist workflow choices across dashboard board surfaces so operators return to their selected lane reliably. - Add project-scoped durable storage helpers for board workflow selection with stale-id validation and cleanup. - Share the workflow selection hook across Board, List, Header, and Graph surfaces while preserving cached payloads on transient refresh failures. - Update List quick-create/planning handoffs, cross-surface tests, and dashboard documentation for durable workflow selection behavior. - Add patch changesets for the published Fusion package. Files changed: .changeset/fn-7234-durable-board-workflow-selection.md | 7 ++ .changeset/workflow-selection-board-list.md | 7 ++ docs/dashboard-guide.md | 5 +- .../workflow-selection-cross-surface.test.tsx | 66 ++++++++-- packages/dashboard/app/components/ListView.tsx | 111 ++++------------- .../app/components/__tests__/Board.test.tsx | 45 +++++++ .../__tests__/GraphWorkflowSwitcherSlot.test.tsx | 57 +++++++++ .../__tests__/HeaderWorkflowSwitcherSlot.test.tsx | 42 ++++++- .../app/components/__tests__/ListView.test.tsx | 122 +++++++++++++++++++ .../app/hooks/__tests__/useBoardWorkflows.test.ts | 29 +++++ packages/dashboard/app/hooks/useBoardWorkflows.ts | 64 ++++++++- .../utils/__tests__/boardWorkflowSelection.test.ts | 135 +++++++++++++++++++++ .../app/utils/__tests__/projectStorage.test.ts | 3 +- .../dashboard/app/utils/boardWorkflowSelection.ts | 49 ++++++++ packages/dashboard/app/utils/projectStorage.ts | 1 + 15 files changed, 633 insertions(+), 110 deletions(-) Fusion-Task-Id: FN-7234 Fusion-Task-Lineage: 6ebc000c-19c3-4a8e-9303-4e18a59902ed Co-authored-by: Fusion (runfusion.ai) --- ...n-7234-durable-board-workflow-selection.md | 7 + .changeset/workflow-selection-board-list.md | 7 + docs/dashboard-guide.md | 5 +- .../workflow-selection-cross-surface.test.tsx | 70 +++++++-- .../dashboard/app/components/ListView.tsx | 111 +++----------- .../app/components/__tests__/Board.test.tsx | 45 ++++++ .../GraphWorkflowSwitcherSlot.test.tsx | 57 ++++++++ .../HeaderWorkflowSwitcherSlot.test.tsx | 42 +++++- .../components/__tests__/ListView.test.tsx | 122 ++++++++++++++++ .../hooks/__tests__/useBoardWorkflows.test.ts | 29 ++++ .../dashboard/app/hooks/useBoardWorkflows.ts | 68 +++++++-- .../__tests__/boardWorkflowSelection.test.ts | 135 ++++++++++++++++++ .../utils/__tests__/projectStorage.test.ts | 3 +- .../app/utils/boardWorkflowSelection.ts | 49 +++++++ .../dashboard/app/utils/projectStorage.ts | 1 + 15 files changed, 637 insertions(+), 114 deletions(-) create mode 100644 .changeset/fn-7234-durable-board-workflow-selection.md create mode 100644 .changeset/workflow-selection-board-list.md create mode 100644 packages/dashboard/app/utils/__tests__/boardWorkflowSelection.test.ts create mode 100644 packages/dashboard/app/utils/boardWorkflowSelection.ts diff --git a/.changeset/fn-7234-durable-board-workflow-selection.md b/.changeset/fn-7234-durable-board-workflow-selection.md new file mode 100644 index 0000000000..e3fcd706ab --- /dev/null +++ b/.changeset/fn-7234-durable-board-workflow-selection.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Preserve dashboard workflow selections per project across Board, List, Header, and Graph. +category: fix +dev: Board/List/Header/Graph workflow selection uses project-scoped localStorage and repairs stale ids. diff --git a/.changeset/workflow-selection-board-list.md b/.changeset/workflow-selection-board-list.md new file mode 100644 index 0000000000..c68931b689 --- /dev/null +++ b/.changeset/workflow-selection-board-list.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Keep Board and List workflow choices selected across refreshes and route returns. +category: fix +dev: Uses project-scoped localStorage workflow-selection helpers shared with header and graph selectors. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index f75f9cc96a..7a54012139 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -135,8 +135,9 @@ Features: - Task card header meta badges group priority, fast mode, agent-created provenance, and elapsed/created-time chips into one wrapping row; agent labels prefer `sourceMetadata.agentName` over raw agent IDs - Column ordering semantics: `todo` mirrors scheduler pickup order (priority descending, then oldest `createdAt`, then task ID); `triage`, `in-progress`, `in-review`, and `archived` remain priority-first with task-ID tie-breaks; `done` is ordered by most recent completion first (`columnMovedAt`, then `updatedAt`, then `createdAt` fallback) - On mobile, both default and workflow-mode boards fill the project viewport while the column strip remains the internal horizontal scroller with contained edge overscroll. + - Board and List workflow switchers use a themed dropdown instead of a native select. The closed trigger shows the workflow name and chevron only; compact Todo / In Progress / Done counts derived from workflow column flags (excluding archived columns) refresh each time the dropdown opens and appear while the dropdown is expanded, including on each workflow option. Built-in lanes with synthesized trait-less lifecycle columns fall back to canonical column ids (`todo`, `in-progress`, `done`, and `archived`) for those counts. Each option row also exposes an inline edit action, and a persistent **New workflow** footer stays visible below the scrollable option list. The open listbox grows from the longest workflow name plus its count/edit decorations while remaining viewport-bounded; the closed trigger stays narrow and ellipsized. Those inline count badges intentionally use the same board column color tokens as cards: `--todo`, `--in-progress`, and `--done`. -- When workflow columns are enabled, Board and List hydrate the last successful workflow-lane payload from a per-project session cache; cold loads show a neutral skeleton until settings and workflow metadata are known, avoiding a legacy single-lane flash. +- When workflow columns are enabled, Board and List hydrate the last successful workflow-lane payload from a per-project session cache; cold loads show a neutral skeleton until settings and workflow metadata are known, avoiding a legacy single-lane flash. The selected workflow is remembered per project in durable browser storage and restored when returning to Board/List after task refreshes, route changes, or respecification flows; if that saved workflow is later deleted, Fusion falls back to a valid default/first workflow so tasks remain visible. ![Board view](./screenshots/dashboard-overview.png) @@ -190,7 +191,7 @@ Navigation: Behavior: - Shows only tasks in `triage`, `todo`, `in-progress`, and `in-review` - Excludes `done` and `archived` -- On desktop/tablet, the header workflow dropdown mirrors Board/List selection behavior and filters graph nodes to tasks assigned to the selected workflow; **All workflows** restores the full active-task graph. +- On desktop/tablet, the header workflow dropdown mirrors Board/List selection behavior, restores the same per-project saved workflow when available, and filters graph nodes to tasks assigned to the selected workflow; **All workflows** restores the full active-task graph. - Uses Sugiyama-style layered auto-layout to place nodes by dependency depth - Renders directed bezier dependency edges (dependent → dependency) with arrowheads - Supports cursor-centered wheel zoom, pinch zoom, keyboard shortcuts (`Ctrl/Cmd+=`, `Ctrl/Cmd+-`, `Ctrl/Cmd+0`, `Ctrl/Cmd+Shift+F`, `Escape`), and fit/reset controls via the floating toolbar with live zoom percentage diff --git a/packages/dashboard/app/__tests__/workflow-selection-cross-surface.test.tsx b/packages/dashboard/app/__tests__/workflow-selection-cross-surface.test.tsx index 4e43c0f2ae..3466a5c44c 100644 --- a/packages/dashboard/app/__tests__/workflow-selection-cross-surface.test.tsx +++ b/packages/dashboard/app/__tests__/workflow-selection-cross-surface.test.tsx @@ -81,6 +81,7 @@ function CrossSurfaceHarness({ projectId = "project-cross" }: { projectId?: stri beforeEach(() => { sessionStorage.clear(); + localStorage.clear(); fetchBoardWorkflowsMock.mockReset(); subscribeSseMock.mockClear(); fetchBoardWorkflowsMock.mockResolvedValue(workflowPayload()); @@ -92,7 +93,11 @@ afterEach(() => { }); describe("workflow selection across dashboard surfaces", () => { - it("hydrates remounted surfaces from the persisted board-workflows payload", async () => { + /* + FNXC:BoardWorkflowSelection 2026-06-29-13:30: + Board workflow selectors keep independent mounted state for Header and Graph, but remounts intentionally hydrate from the same project-scoped durable workflow selection so fetch latency cannot bounce operators back to the default workflow. + */ + it("hydrates remounted Header and Graph surfaces from durable storage while fetch is pending", async () => { const { unmount } = render(); expect(await screen.findAllByTestId("workflow-switcher")).toHaveLength(2); @@ -113,12 +118,12 @@ describe("workflow selection across dashboard surfaces", () => { const remountedSwitchers = screen.getAllByTestId("workflow-switcher"); expect(remountedSwitchers).toHaveLength(2); - expect(screen.getByTestId("header-selection")).toHaveTextContent(DEFAULT_WORKFLOW.id); - expect(screen.getByTestId("graph-selection")).toHaveTextContent(DEFAULT_WORKFLOW.id); + expect(screen.getByTestId("header-selection")).toHaveTextContent(GRAPH_WORKFLOW.id); + expect(screen.getByTestId("graph-selection")).toHaveTextContent(GRAPH_WORKFLOW.id); expect(fetchBoardWorkflowsMock).toHaveBeenCalledWith("project-cross"); }); - it("keeps Graph and Header workflow selections isolated while Graph filtering follows only Graph", async () => { + it("keeps mounted Graph and Header workflow selections isolated while Graph filtering follows only Graph", async () => { render(); const switchers = await screen.findAllByTestId("workflow-switcher"); @@ -154,31 +159,72 @@ describe("workflow selection across dashboard surfaces", () => { }); }); - it("preserves boundary behavior for disabled, empty, and single-workflow payloads", async () => { - fetchBoardWorkflowsMock.mockResolvedValue(workflowPayload({ flagEnabled: false, workflows: [] })); - const { unmount } = render(); + it("rehydrates selection per project instead of carrying it across projects", async () => { + const { rerender } = render(); - await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledWith("project-cross")); + const alphaSwitchers = await screen.findAllByTestId("workflow-switcher"); + fireEvent.click(alphaSwitchers[1]); + fireEvent.click(screen.getByTestId(`workflow-switcher-option-${GRAPH_WORKFLOW.id}`)); + await waitFor(() => expect(screen.getByTestId("graph-selection")).toHaveTextContent(GRAPH_WORKFLOW.id)); + + rerender(); + + await waitFor(() => { + expect(screen.getByTestId("header-selection")).toHaveTextContent(DEFAULT_WORKFLOW.id); + expect(screen.getByTestId("graph-selection")).toHaveTextContent(DEFAULT_WORKFLOW.id); + }); + }); + + it("repairs stale stored workflow ids to the default workflow without hiding graph tasks", async () => { + localStorage.setItem("kb:project-cross:kb-dashboard-board-workflow-selection", "wf-deleted"); + + render(); + + expect(await screen.findAllByTestId("workflow-switcher")).toHaveLength(2); + await waitFor(() => { + expect(screen.getByTestId("header-selection")).toHaveTextContent(DEFAULT_WORKFLOW.id); + expect(screen.getByTestId("graph-selection")).toHaveTextContent(DEFAULT_WORKFLOW.id); + }); + expect(localStorage.getItem("kb:project-cross:kb-dashboard-board-workflow-selection")).toBe(DEFAULT_WORKFLOW.id); + + const graphTasks = screen.getByTestId("graph-tasks"); + expect(within(graphTasks).getByTestId("graph-task-FN-default")).toBeInTheDocument(); + expect(within(graphTasks).getByTestId("graph-task-FN-unassigned")).toBeInTheDocument(); + expect(within(graphTasks).getByTestId("graph-task-FN-deleted")).toBeInTheDocument(); + expect(within(graphTasks).queryByTestId("graph-task-FN-graph")).toBeNull(); + }); + + it("preserves boundary behavior for disabled, empty, and single-workflow payloads", async () => { + localStorage.setItem("kb:project-disabled:kb-dashboard-board-workflow-selection", GRAPH_WORKFLOW.id); + fetchBoardWorkflowsMock.mockResolvedValue(workflowPayload({ flagEnabled: false, workflows: [] })); + const { unmount } = render(); + + await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledWith("project-disabled")); expect(screen.queryByTestId("workflow-switcher")).toBeNull(); expect(screen.getByTestId("header-workflow-slot")).toBeEmptyDOMElement(); + expect(localStorage.getItem("kb:project-disabled:kb-dashboard-board-workflow-selection")).toBeNull(); for (const task of TASKS) { expect(screen.getByTestId(`graph-task-${task.id}`)).toBeInTheDocument(); } unmount(); sessionStorage.clear(); + localStorage.setItem("kb:project-empty:kb-dashboard-board-workflow-selection", GRAPH_WORKFLOW.id); fetchBoardWorkflowsMock.mockResolvedValue(workflowPayload({ workflows: [] })); - const empty = render(); - await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledWith("project-cross")); + const empty = render(); + await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledWith("project-empty")); expect(screen.queryByTestId("workflow-switcher")).toBeNull(); expect(screen.getByTestId("header-workflow-slot")).toBeEmptyDOMElement(); + expect(localStorage.getItem("kb:project-empty:kb-dashboard-board-workflow-selection")).toBeNull(); empty.unmount(); sessionStorage.clear(); + localStorage.setItem("kb:project-single:kb-dashboard-board-workflow-selection", GRAPH_WORKFLOW.id); fetchBoardWorkflowsMock.mockResolvedValue(workflowPayload({ workflows: [DEFAULT_WORKFLOW] })); - render(); - await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledWith("project-cross")); + render(); + await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledWith("project-single")); expect(screen.queryByTestId("workflow-switcher")).toBeNull(); expect(screen.getByTestId("header-workflow-slot")).toBeEmptyDOMElement(); + expect(localStorage.getItem("kb:project-single:kb-dashboard-board-workflow-selection")).toBeNull(); }); }); diff --git a/packages/dashboard/app/components/ListView.tsx b/packages/dashboard/app/components/ListView.tsx index 9bc3148a4e..ffaf3a2139 100644 --- a/packages/dashboard/app/components/ListView.tsx +++ b/packages/dashboard/app/components/ListView.tsx @@ -8,9 +8,9 @@ import type { Task, TaskDetail, Column, ColumnId, TaskCreateInput, MergeResult, import { COLUMNS, DEFAULT_COLUMN, getErrorMessage, isColumn } from "@fusion/core"; import { useColumnLabel } from "../i18n/labels"; import { sortTasksForDisplayColumn } from "./taskSorting"; -import { batchUpdateTaskModels, fetchBoardWorkflows, fetchNodes, fetchTaskDetail } from "../api"; +import { batchUpdateTaskModels, fetchNodes, fetchTaskDetail } from "../api"; import { TaskDetailContent } from "./TaskDetailModal"; -import type { BoardWorkflowColumn, BoardWorkflowDefinition, BoardWorkflowsPayload, ModelInfo, NodeInfo } from "../api"; +import type { BoardWorkflowColumn, BoardWorkflowsPayload, ModelInfo, NodeInfo } from "../api"; import { QuickEntryBox } from "./QuickEntryBox"; import { CustomModelDropdown } from "./CustomModelDropdown"; import { NodeHealthDot } from "./NodeHealthDot"; @@ -21,10 +21,10 @@ import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/project import { getUnifiedTaskProgress } from "../utils/taskProgress"; import { useConfirm } from "../hooks/useConfirm"; import { extractDependencyDeleteConflict, extractLineageDeleteConflict } from "../utils/taskDelete"; -import { subscribeSse } from "../sse-bus"; import { WorkflowSwitcher } from "./WorkflowSwitcher"; import { computeWorkflowStatusCounts } from "./workflowStatusCounts"; -import { readBoardWorkflowsCache, writeBoardWorkflowsCache } from "../utils/boardWorkflowsCache"; +import { writeBoardWorkflowsCache } from "../utils/boardWorkflowsCache"; +import { useBoardWorkflows } from "../hooks/useBoardWorkflows"; const COLUMN_COLOR_MAP: Record = { triage: "var(--triage)", @@ -333,14 +333,21 @@ export function ListView({ /* FNXC:BoardWorkflows 2026-06-20-09:07: ListView shares the board-workflows first-paint invariant with Board: hydrate per-project workflow metadata from sessionStorage and gate legacy list columns while workflowColumns settings or uncached lane metadata are still unknown. + + FNXC:BoardWorkflowSelection 2026-06-29-12:35: + ListView must use the same project-scoped durable workflow selection invariant as Board/Header/Graph so task refreshes, respecification route returns, and remounts do not reset operators from a custom workflow back to the default workflow. Keep this separate from list task-selection storage keys. */ const shouldHydrateBoardWorkflowsCache = workflowColumnsEnabled === true || settingsLoaded === false; - const [boardWorkflowsState, setBoardWorkflowsState] = useState<{ projectId?: string; payload: BoardWorkflowsPayload } | null>(() => { - const cached = shouldHydrateBoardWorkflowsCache ? readBoardWorkflowsCache(projectId) : null; - return cached ? { projectId, payload: cached } : null; - }); - const boardWorkflows = boardWorkflowsState?.projectId === projectId && boardWorkflowsState ? boardWorkflowsState.payload : null; - const [selectedWorkflowId, setSelectedWorkflowId] = useState(null); + const { + boardWorkflows, + workflowMode, + workflowOptions, + selectedWorkflow, + selectedWorkflowId, + setSelectedWorkflowId, + refreshBoardWorkflows, + setBoardWorkflowsState, + } = useBoardWorkflows({ projectId, shouldHydrateCache: shouldHydrateBoardWorkflowsCache }); const [headerWorkflowSlot, setHeaderWorkflowSlot] = useState(() => { if (typeof document === "undefined") return null; return document.getElementById("header-workflow-slot"); @@ -414,7 +421,6 @@ export function ListView({ // FNXC:ListView 2026-06-22-18:00: Holds the active pointer-drag teardown so move/up/cancel/unmount all detach the same listeners — prevents the "window mousemove with no cleanup" leak called out by the frontend-races review. const splitResizeTeardownRef = useRef<(() => void) | null>(null); const previousStorageProjectIdRef = useRef(projectId); - const boardWorkflowsFetchSeqRef = useRef(0); useEffect(() => { if (previousStorageProjectIdRef.current === projectId) return; @@ -433,55 +439,6 @@ export function ListView({ setSidebarWidth(readSidebarWidth(projectId)); }, [projectId, tasks]); - useEffect(() => { - const cached = shouldHydrateBoardWorkflowsCache ? readBoardWorkflowsCache(projectId) : null; - setBoardWorkflowsState(cached ? { projectId, payload: cached } : null); - }, [projectId, shouldHydrateBoardWorkflowsCache]); - - /* - FNXC:WorkflowControls 2026-06-21-00:00: - Opening the workflow switcher must refresh the board-workflows payload because task workflow assignment changes do not emit workflow definition SSE events. - Share this path with mount, visibility/focus, and workflow-definition SSE refetches so desktop sidebar and mobile toolbar counts cannot drift. - */ - const refreshBoardWorkflows = useCallback(() => { - const seq = ++boardWorkflowsFetchSeqRef.current; - fetchBoardWorkflows(projectId) - .then((payload) => { - if (seq === boardWorkflowsFetchSeqRef.current) { - setBoardWorkflowsState({ projectId, payload }); - writeBoardWorkflowsCache(projectId, payload); - } - }) - .catch(() => { - if (seq === boardWorkflowsFetchSeqRef.current) { - setBoardWorkflowsState({ projectId, payload: { flagEnabled: false, defaultWorkflowId: "builtin:coding", workflows: [], taskWorkflowIds: {} } }); - } - }); - }, [projectId]); - - useEffect(() => { - refreshBoardWorkflows(); - const onVisible = () => { - if (typeof document === "undefined" || document.visibilityState === "visible") refreshBoardWorkflows(); - }; - if (typeof document !== "undefined") document.addEventListener("visibilitychange", onVisible); - if (typeof window !== "undefined") window.addEventListener("focus", onVisible); - const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : ""; - const unsubscribe = subscribeSse(`/api/events${query}`, { - events: { - "workflow:created": refreshBoardWorkflows, - "workflow:updated": refreshBoardWorkflows, - "workflow:deleted": refreshBoardWorkflows, - }, - }); - return () => { - boardWorkflowsFetchSeqRef.current++; - if (typeof document !== "undefined") document.removeEventListener("visibilitychange", onVisible); - if (typeof window !== "undefined") window.removeEventListener("focus", onVisible); - unsubscribe(); - }; - }, [projectId, refreshBoardWorkflows]); - // Persist selection to localStorage useEffect(() => { if (typeof window !== "undefined") { @@ -617,34 +574,6 @@ export function ListView({ }); }, []); - const workflowMode = boardWorkflows?.flagEnabled === true && boardWorkflows.workflows.length > 0; - const workflowOptions = useMemo(() => { - if (!workflowMode || !boardWorkflows) return []; - return [...boardWorkflows.workflows].sort((a, b) => { - if (a.id === boardWorkflows.defaultWorkflowId) return -1; - if (b.id === boardWorkflows.defaultWorkflowId) return 1; - return a.name.localeCompare(b.name); - }); - }, [boardWorkflows, workflowMode]); - - const selectedWorkflow = useMemo(() => { - if (!workflowMode) return null; - return workflowOptions.find((workflow) => workflow.id === selectedWorkflowId) - ?? workflowOptions.find((workflow) => workflow.id === boardWorkflows?.defaultWorkflowId) - ?? workflowOptions[0] - ?? null; - }, [boardWorkflows?.defaultWorkflowId, selectedWorkflowId, workflowMode, workflowOptions]); - - useEffect(() => { - if (!workflowMode) { - setSelectedWorkflowId(null); - return; - } - if (selectedWorkflow && selectedWorkflow.id !== selectedWorkflowId) { - setSelectedWorkflowId(selectedWorkflow.id); - } - }, [selectedWorkflow, selectedWorkflowId, workflowMode]); - useEffect(() => { setSelectedColumn(null); }, [selectedWorkflowId]); @@ -743,6 +672,11 @@ export function ListView({ return create(input); }, [addToast, applyOptimisticTaskWorkflow, createTargetColumn, onQuickCreate, refreshBoardWorkflows, selectedWorkflow, t, workflowMode]); + /* + FNXC:ListWorkflowSelection 2026-06-29-00:00: + List quick-add Plan/Subtask handoffs must inherit the same active workflow as direct quick-create. Passing null only while workflow mode has no selected workflow preserves stale-id fallback behavior without reverting to the project default lane. + */ + const listQuickEntryWorkflowId = workflowMode ? selectedWorkflow?.id ?? null : undefined; // Column display labels const COLUMN_LABELS_MAP: Record = { @@ -1997,6 +1931,7 @@ export function ListView({ availableModels={availableModels} onPlanningMode={onPlanningMode} onSubtaskBreakdown={onSubtaskBreakdown} + workflowId={listQuickEntryWorkflowId} projectId={projectId} autoExpand={false} defaultExpanded={false} diff --git a/packages/dashboard/app/components/__tests__/Board.test.tsx b/packages/dashboard/app/components/__tests__/Board.test.tsx index 67d34783ea..a4b052ba01 100644 --- a/packages/dashboard/app/components/__tests__/Board.test.tsx +++ b/packages/dashboard/app/components/__tests__/Board.test.tsx @@ -3,6 +3,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"; import { Board } from "../Board"; import { COLUMNS } from "@fusion/core"; +import { BOARD_WORKFLOW_SELECTION_STORAGE_KEY } from "../../utils/boardWorkflowSelection"; +import { scopedKey } from "../../utils/projectStorage"; import type { Task } from "@fusion/core"; @@ -962,6 +964,49 @@ describe("Board", () => { expect(screen.queryByTestId(/^lane-/)).toBeNull(); }); + it("hydrates remounted board workflow selection from durable project storage", async () => { + const projectId = "project-board-persist"; + enableFlag({}, [DEFAULT_WORKFLOW, CUSTOM_WORKFLOW]); + const { unmount } = renderBoard({ projectId }); + + await selectWorkflow(CUSTOM_WORKFLOW.id); + await waitFor(() => expect(screen.getByTestId("workflow-switcher")).toHaveTextContent(CUSTOM_WORKFLOW.name)); + expect(window.localStorage.getItem(scopedKey(BOARD_WORKFLOW_SELECTION_STORAGE_KEY, projectId))).toBe(CUSTOM_WORKFLOW.id); + + unmount(); + enableFlag({}, [DEFAULT_WORKFLOW, CUSTOM_WORKFLOW]); + renderBoard({ projectId }); + + await waitFor(() => expect(screen.getByTestId("workflow-switcher")).toHaveTextContent(CUSTOM_WORKFLOW.name)); + }); + + it("keeps a custom board workflow selected after task refresh and workflow payload revalidation", async () => { + fetchBoardWorkflowsMock.mockResolvedValue({ + flagEnabled: true, + defaultWorkflowId: "builtin:coding", + workflows: [DEFAULT_WORKFLOW, CUSTOM_WORKFLOW], + taskWorkflowIds: { "FN-1": "wf-custom" }, + }); + const { rerender } = renderBoard({ + projectId: "project-board-refresh", + tasks: [mkTask({ id: "FN-1", column: "intake", title: "Custom task" })], + }); + + await selectWorkflow(CUSTOM_WORKFLOW.id); + await waitFor(() => expect(screen.getByTestId("workflow-switcher")).toHaveTextContent(CUSTOM_WORKFLOW.name)); + + rerender(); + await act(async () => { + sseHandlers["workflow:updated"]?.(); + }); + + await waitFor(() => expect(screen.getByTestId("workflow-switcher")).toHaveTextContent(CUSTOM_WORKFLOW.name)); + expect(screen.getByTestId("column-done")).toHaveAttribute("data-tasks", expect.stringContaining("FN-1")); + }); + it("tasks with no selection render in the default selected workflow", async () => { enableFlag({ "FN-1": "builtin:coding", "FN-2": "builtin:coding" }); renderBoard({ tasks: [mkTask({ id: "FN-1" }), mkTask({ id: "FN-2", column: "in-progress" })] }); diff --git a/packages/dashboard/app/components/__tests__/GraphWorkflowSwitcherSlot.test.tsx b/packages/dashboard/app/components/__tests__/GraphWorkflowSwitcherSlot.test.tsx index 90b69025c9..09ebdb9d39 100644 --- a/packages/dashboard/app/components/__tests__/GraphWorkflowSwitcherSlot.test.tsx +++ b/packages/dashboard/app/components/__tests__/GraphWorkflowSwitcherSlot.test.tsx @@ -46,6 +46,7 @@ function appendHeaderWorkflowSlot() { beforeEach(() => { sessionStorage.clear(); + localStorage.clear(); fetchBoardWorkflowsMock.mockReset(); subscribeSseMock.mockClear(); fetchBoardWorkflowsMock.mockResolvedValue(workflowPayload()); @@ -141,6 +142,62 @@ describe("GraphWorkflowSwitcherSlot", () => { }); }); + it("hydrates a remounted graph selector from durable project storage while fetch is pending", async () => { + appendHeaderWorkflowSlot(); + const { unmount } = render(); + + fireEvent.click(await screen.findByTestId("workflow-switcher")); + fireEvent.click(screen.getByTestId("workflow-switcher-option-wf-review")); + await waitFor(() => expect(screen.getByTestId("workflow-switcher")).toHaveTextContent(CUSTOM_WORKFLOW.name)); + + unmount(); + fetchBoardWorkflowsMock.mockImplementation(() => new Promise(() => {})); + render(); + + expect(await screen.findByTestId("workflow-switcher")).toHaveTextContent(CUSTOM_WORKFLOW.name); + }); + + it("repairs a stale stored graph workflow id to the default workflow", async () => { + appendHeaderWorkflowSlot(); + localStorage.setItem("kb:project-graph-stale:kb-dashboard-board-workflow-selection", "wf-deleted"); + const onWorkflowSelectionChange = vi.fn(); + + render(); + + expect(await screen.findByTestId("workflow-switcher")).toHaveTextContent(DEFAULT_WORKFLOW.name); + await waitFor(() => { + const lastSelection = onWorkflowSelectionChange.mock.calls.at(-1)?.[0] as GraphWorkflowSelection | null; + expect(lastSelection?.selectedWorkflow.id).toBe(DEFAULT_WORKFLOW.id); + }); + expect(localStorage.getItem("kb:project-graph-stale:kb-dashboard-board-workflow-selection")).toBe(DEFAULT_WORKFLOW.id); + }); + + it("keeps a valid graph workflow selection through focus refreshes", async () => { + appendHeaderWorkflowSlot(); + const onWorkflowSelectionChange = vi.fn(); + render(); + + fireEvent.click(await screen.findByTestId("workflow-switcher")); + fireEvent.click(screen.getByTestId("workflow-switcher-option-wf-review")); + await waitFor(() => { + const lastSelection = onWorkflowSelectionChange.mock.calls.at(-1)?.[0] as GraphWorkflowSelection | null; + expect(lastSelection?.selectedWorkflow.id).toBe(CUSTOM_WORKFLOW.id); + }); + + fetchBoardWorkflowsMock.mockResolvedValue(workflowPayload({ + defaultWorkflowId: DEFAULT_WORKFLOW.id, + workflows: [DEFAULT_WORKFLOW, CUSTOM_WORKFLOW], + })); + const callsBeforeFocus = fetchBoardWorkflowsMock.mock.calls.length; + window.dispatchEvent(new Event("focus")); + + await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledTimes(callsBeforeFocus + 1)); + await waitFor(() => { + const lastSelection = onWorkflowSelectionChange.mock.calls.at(-1)?.[0] as GraphWorkflowSelection | null; + expect(lastSelection?.selectedWorkflow.id).toBe(CUSTOM_WORKFLOW.id); + }); + }); + it("forwards dropdown edit workflow ids to the graph editor launcher", async () => { appendHeaderWorkflowSlot(); const onOpenWorkflowEditor = vi.fn(); diff --git a/packages/dashboard/app/components/__tests__/HeaderWorkflowSwitcherSlot.test.tsx b/packages/dashboard/app/components/__tests__/HeaderWorkflowSwitcherSlot.test.tsx index d94e3ae8d2..a59a392f2b 100644 --- a/packages/dashboard/app/components/__tests__/HeaderWorkflowSwitcherSlot.test.tsx +++ b/packages/dashboard/app/components/__tests__/HeaderWorkflowSwitcherSlot.test.tsx @@ -54,6 +54,7 @@ function renderWithHeader(children: ReactNode) { beforeEach(() => { sessionStorage.clear(); + localStorage.clear(); fetchBoardWorkflowsMock.mockReset(); subscribeSseMock.mockClear(); fetchBoardWorkflowsMock.mockResolvedValue(workflowPayload()); @@ -99,6 +100,37 @@ describe("HeaderWorkflowSwitcherSlot", () => { expect(headerSlot.contains(selector)).toBe(true); }); + it("hydrates a remounted desktop header selector from durable project storage", async () => { + const { unmount } = renderWithHeader(); + + fireEvent.click(await screen.findByTestId("workflow-switcher")); + fireEvent.click(screen.getByTestId("workflow-switcher-option-wf-missions")); + await waitFor(() => expect(screen.getByTestId("workflow-switcher")).toHaveTextContent(MISSION_WORKFLOW.name)); + + unmount(); + fetchBoardWorkflowsMock.mockImplementation(() => new Promise(() => {})); + renderWithHeader(); + + expect(await screen.findByTestId("workflow-switcher")).toHaveTextContent(MISSION_WORKFLOW.name); + }); + + it("repairs a stale stored header workflow id to the default workflow", async () => { + localStorage.setItem("kb:project-header-stale:kb-dashboard-board-workflow-selection", "wf-deleted"); + const onWorkflowSelectionChange = vi.fn<(selection: HeaderWorkflowSelection | null) => void>(); + + renderWithHeader( + , + ); + + expect(await screen.findByTestId("workflow-switcher")).toHaveTextContent(DEFAULT_WORKFLOW.name); + await waitFor(() => { + expect(onWorkflowSelectionChange).toHaveBeenLastCalledWith(expect.objectContaining({ + selectedWorkflow: expect.objectContaining({ id: DEFAULT_WORKFLOW.id }), + })); + }); + expect(localStorage.getItem("kb:project-header-stale:kb-dashboard-board-workflow-selection")).toBe(DEFAULT_WORKFLOW.id); + }); + it("forwards dropdown edit workflow ids from the shared header slot", async () => { const onOpenWorkflowEditor = vi.fn(); renderWithHeader(); @@ -109,7 +141,7 @@ describe("HeaderWorkflowSwitcherSlot", () => { expect(onOpenWorkflowEditor).toHaveBeenCalledWith("wf-missions"); }); - it("renders no toolbar shell when workflow mode is off or only one workflow exists", async () => { + it("renders no toolbar shell when workflow mode is off, empty, or only one workflow exists", async () => { fetchBoardWorkflowsMock.mockResolvedValueOnce(workflowPayload({ flagEnabled: false, workflows: [] })); const { unmount } = renderWithHeader(); await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledWith("project-off")); @@ -118,6 +150,14 @@ describe("HeaderWorkflowSwitcherSlot", () => { unmount(); sessionStorage.clear(); + fetchBoardWorkflowsMock.mockResolvedValueOnce(workflowPayload({ workflows: [] })); + const empty = renderWithHeader(); + await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledWith("project-empty")); + expect(screen.queryByTestId("workflow-switcher")).toBeNull(); + expect(screen.getByTestId("header-workflow-slot")).toBeEmptyDOMElement(); + + empty.unmount(); + sessionStorage.clear(); fetchBoardWorkflowsMock.mockResolvedValueOnce(workflowPayload({ workflows: [DEFAULT_WORKFLOW] })); renderWithHeader(); await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledWith("project-one")); diff --git a/packages/dashboard/app/components/__tests__/ListView.test.tsx b/packages/dashboard/app/components/__tests__/ListView.test.tsx index 9cd7f039e5..87393a4b92 100644 --- a/packages/dashboard/app/components/__tests__/ListView.test.tsx +++ b/packages/dashboard/app/components/__tests__/ListView.test.tsx @@ -6,6 +6,7 @@ import userEvent from "@testing-library/user-event"; import { ListView } from "../ListView"; import type { Task, TaskDetail } from "@fusion/core"; import { scopedKey } from "../../utils/projectStorage"; +import { BOARD_WORKFLOW_SELECTION_STORAGE_KEY } from "../../utils/boardWorkflowSelection"; import { loadAllAppCss } from "../../test/cssFixture"; // Mock the API @@ -46,9 +47,15 @@ vi.mock("../QuickEntryBox", () => ({ QuickEntryBox: ({ onCreate, addToast, + onPlanningMode, + onSubtaskBreakdown, + workflowId, }: { onCreate?: (input: { description: string }) => Promise; addToast: (message: string, type?: "error" | "success" | "info" | "warning") => void; + onPlanningMode?: (initialPlan: string, workflowId?: string | null) => void; + onSubtaskBreakdown?: (description: string, workflowId?: string | null) => void; + workflowId?: string | null; }) => { const [value, setValue] = useState(""); const [expanded, setExpanded] = useState(false); @@ -65,6 +72,16 @@ vi.mock("../QuickEntryBox", () => ({ } }; + const handoff = (callback?: (description: string, workflowId?: string | null) => void) => { + const description = value.trim(); + if (!description || !callback) return; + if (workflowId !== undefined) { + callback(description, workflowId); + return; + } + callback(description); + }; + return (