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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-29 14:41:52 -07:00
parent d22b8cc60a
commit b36327059a
15 changed files with 637 additions and 114 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.
<!-- FNXC:WorkflowSelection 2026-06-29-13:34: Board, List, Header, and Graph workflow selectors now share a durable per-project selection so operators return to the same lane after remounts, task refreshes, or respecification flows; stale saved workflow ids must fall back to a valid default/first workflow instead of hiding all tasks. -->
- 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

View File

@@ -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(<CrossSurfaceHarness />);
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(<CrossSurfaceHarness />);
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(<CrossSurfaceHarness />);
it("rehydrates selection per project instead of carrying it across projects", async () => {
const { rerender } = render(<CrossSurfaceHarness projectId="project-alpha" />);
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(<CrossSurfaceHarness projectId="project-beta" />);
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(<CrossSurfaceHarness />);
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(<CrossSurfaceHarness projectId="project-disabled" />);
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(<CrossSurfaceHarness />);
await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledWith("project-cross"));
const empty = render(<CrossSurfaceHarness projectId="project-empty" />);
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(<CrossSurfaceHarness />);
await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledWith("project-cross"));
render(<CrossSurfaceHarness projectId="project-single" />);
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();
});
});

View File

@@ -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<Column, string> = {
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<string | null>(null);
const {
boardWorkflows,
workflowMode,
workflowOptions,
selectedWorkflow,
selectedWorkflowId,
setSelectedWorkflowId,
refreshBoardWorkflows,
setBoardWorkflowsState,
} = useBoardWorkflows({ projectId, shouldHydrateCache: shouldHydrateBoardWorkflowsCache });
const [headerWorkflowSlot, setHeaderWorkflowSlot] = useState<HTMLElement | null>(() => {
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<BoardWorkflowDefinition[]>(() => {
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<BoardWorkflowDefinition | null>(() => {
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<ListColumn, string> = {
@@ -1997,6 +1931,7 @@ export function ListView({
availableModels={availableModels}
onPlanningMode={onPlanningMode}
onSubtaskBreakdown={onSubtaskBreakdown}
workflowId={listQuickEntryWorkflowId}
projectId={projectId}
autoExpand={false}
defaultExpanded={false}

View File

@@ -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(<Board {...createBoardProps({
projectId: "project-board-refresh",
tasks: [mkTask({ id: "FN-1", column: "done", title: "Custom task after respec" })],
})} />);
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" })] });

View File

@@ -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(<GraphWorkflowSwitcherSlot projectId="project-graph-persist" />);
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<BoardWorkflowsPayload>(() => {}));
render(<GraphWorkflowSwitcherSlot projectId="project-graph-persist" />);
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(<GraphWorkflowSwitcherSlot projectId="project-graph-stale" onWorkflowSelectionChange={onWorkflowSelectionChange} />);
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(<GraphWorkflowSwitcherSlot projectId="project-focus" onWorkflowSelectionChange={onWorkflowSelectionChange} />);
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();

View File

@@ -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(<HeaderWorkflowSwitcherSlot projectId="project-header-persist" />);
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<BoardWorkflowsPayload>(() => {}));
renderWithHeader(<HeaderWorkflowSwitcherSlot projectId="project-header-persist" />);
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(
<HeaderWorkflowSwitcherSlot projectId="project-header-stale" onWorkflowSelectionChange={onWorkflowSelectionChange} />,
);
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(<HeaderWorkflowSwitcherSlot projectId="project-header-edit" onOpenWorkflowEditor={onOpenWorkflowEditor} />);
@@ -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(<HeaderWorkflowSwitcherSlot projectId="project-off" />);
await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledWith("project-off"));
@@ -118,6 +150,14 @@ describe("HeaderWorkflowSwitcherSlot", () => {
unmount();
sessionStorage.clear();
fetchBoardWorkflowsMock.mockResolvedValueOnce(workflowPayload({ workflows: [] }));
const empty = renderWithHeader(<HeaderWorkflowSwitcherSlot projectId="project-empty" />);
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(<HeaderWorkflowSwitcherSlot projectId="project-one" />);
await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledWith("project-one"));

View File

@@ -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<unknown>;
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 (
<div className="quick-entry-box" data-testid="quick-entry-box">
<textarea
@@ -96,6 +113,12 @@ vi.mock("../QuickEntryBox", () => ({
Models
</button>
<button type="button" data-testid="quick-entry-deps">Deps</button>
<button type="button" data-testid="quick-entry-plan" onClick={() => handoff(onPlanningMode)}>
Plan
</button>
<button type="button" data-testid="quick-entry-subtask" onClick={() => handoff(onSubtaskBreakdown)}>
Subtask
</button>
<button type="button" data-testid="quick-entry-save" onClick={() => void submit()}>
Save
</button>
@@ -697,6 +720,72 @@ describe("ListView", () => {
expect(screen.getByRole("listbox", { name: "Workflow" })).toBeInTheDocument();
});
it("keeps a custom list workflow selected after task refresh and workflow payload revalidation", async () => {
vi.mocked(fetchBoardWorkflows).mockResolvedValue({
flagEnabled: true,
defaultWorkflowId: "builtin:coding",
workflows: [
{
id: "builtin:coding",
name: "Coding",
columns: [
{ id: "todo", name: "Todo", flags: { intake: true } },
{ id: "done", name: "Done", flags: { complete: true } },
],
},
{
id: "wf-custom",
name: "Custom Flow",
columns: [
{ id: "backlog", name: "Backlog", flags: { intake: true } },
{ id: "done", name: "Done", flags: { complete: true } },
],
},
],
taskWorkflowIds: { "FN-001": "wf-custom" },
});
localStorage.setItem(scopedStorageKey("kb-dashboard-list-columns"), JSON.stringify(["title", "status"]));
localStorage.setItem(scopedStorageKey("kb-dashboard-selected-tasks"), JSON.stringify(["FN-002"]));
localStorage.setItem(scopedStorageKey("kb-dashboard-list-collapsed"), JSON.stringify(["archived"]));
localStorage.setItem(scopedStorageKey("kb-dashboard-list-sidebar-width"), "420");
const listProps: React.ComponentProps<typeof ListView> = {
tasks: [createMockTask({ id: "FN-001", column: "backlog", title: "Custom workflow task" })],
onMoveTask: vi.fn(async () => createMockTask()),
onRetryTask: vi.fn(async () => createMockTask()),
onDeleteTask: vi.fn(async () => createMockTask()),
onMergeTask: vi.fn(async () => ({ merged: false })),
onResetTask: vi.fn(async () => createMockTask()),
onDuplicateTask: vi.fn(async () => createMockTask()),
onOpenDetail: vi.fn(),
addToast: mockAddToast,
globalPaused: false,
onNewTask: vi.fn(),
projectId: TEST_PROJECT_ID,
};
const rendered = render(<ListView {...listProps} />);
await selectWorkflow("wf-custom");
await waitFor(() => expect(screen.getByTestId("workflow-switcher")).toHaveTextContent("Custom Flow"));
expect(window.localStorage.getItem(scopedStorageKey(BOARD_WORKFLOW_SELECTION_STORAGE_KEY))).toBe("wf-custom");
expect(window.localStorage.getItem(scopedStorageKey("kb-dashboard-list-columns"))).toBe(JSON.stringify(["title", "status"]));
expect(window.localStorage.getItem(scopedStorageKey("kb-dashboard-selected-tasks"))).toBe(JSON.stringify(["FN-002"]));
expect(window.localStorage.getItem(scopedStorageKey("kb-dashboard-list-collapsed"))).toBe(JSON.stringify(["archived"]));
expect(window.localStorage.getItem(scopedStorageKey("kb-dashboard-list-sidebar-width"))).toBe("420");
expect(screen.getByText("Custom workflow task")).toBeInTheDocument();
await act(async () => {
rendered.rerender(<ListView {...listProps} tasks={[createMockTask({ id: "FN-001", column: "done", title: "Custom workflow task after respec" })]} />);
});
await act(async () => {
listViewSseHandlers["workflow:updated"]?.();
});
await waitFor(() => expect(screen.getByTestId("workflow-switcher")).toHaveTextContent("Custom Flow"));
expect(screen.getByText("Custom workflow task after respec")).toBeInTheDocument();
});
it("re-homes a preserved-column task to the new workflow after workflow invalidation", async () => {
const preservedWorkflow = {
id: "wf-preserved",
@@ -2824,6 +2913,39 @@ describe("ListView Quick Entry", () => {
});
});
it("passes the selected workflow id to list quick-entry Plan and Subtask handoffs", async () => {
const onPlanningMode = vi.fn();
const onSubtaskBreakdown = vi.fn();
vi.mocked(fetchBoardWorkflows).mockResolvedValue({
flagEnabled: true,
defaultWorkflowId: "builtin:default",
workflows: [
{
id: "builtin:default",
name: "Default",
columns: [{ id: "triage", name: "Triage", flags: { intake: true } }],
},
{
id: "wf-list-active",
name: "List Active",
columns: [{ id: "triage", name: "Triage", flags: { intake: true } }],
},
],
taskWorkflowIds: {},
});
renderListView({ onPlanningMode, onSubtaskBreakdown });
await selectWorkflow("wf-list-active");
const input = screen.getByTestId("quick-entry-input");
fireEvent.change(input, { target: { value: "Plan on selected list workflow" } });
fireEvent.click(screen.getByTestId("quick-entry-toggle"));
fireEvent.click(screen.getByTestId("quick-entry-plan"));
fireEvent.click(screen.getByTestId("quick-entry-subtask"));
expect(onPlanningMode).toHaveBeenCalledWith("Plan on selected list workflow", "wf-list-active");
expect(onSubtaskBreakdown).toHaveBeenCalledWith("Plan on selected list workflow", "wf-list-active");
});
it("shows error toast when onQuickCreate fails and keeps input content", async () => {
const mockOnQuickCreate = vi.fn().mockRejectedValue(new Error("Create failed"));
renderListView({ onQuickCreate: mockOnQuickCreate });

View File

@@ -23,6 +23,8 @@ describe("useBoardWorkflows", () => {
beforeEach(() => {
subscribeHandlers = {};
unsubscribe = vi.fn();
localStorage.clear();
sessionStorage.clear();
});
function makeDeps(fetchImpl: () => Promise<BoardWorkflowsPayload>) {
@@ -197,6 +199,33 @@ describe("useBoardWorkflows", () => {
});
});
it("preserves the current payload and durable selection when a refresh fetch fails", async () => {
let shouldReject = false;
const deps = makeDeps(() => {
if (shouldReject) return Promise.reject(new Error("temporary workflow API failure"));
return Promise.resolve(makePayload());
});
const { result } = renderHook(() => useBoardWorkflows({ projectId: "p1", ...deps }));
await waitFor(() => expect(result.current.selectedWorkflow?.id).toBe("wf-a"));
act(() => { result.current.setSelectedWorkflowId("wf-b"); });
await waitFor(() => expect(result.current.selectedWorkflow?.id).toBe("wf-b"));
expect(localStorage.getItem("kb:p1:kb-dashboard-board-workflow-selection")).toBe("wf-b");
shouldReject = true;
await act(async () => {
result.current.refreshBoardWorkflows();
await Promise.resolve();
});
await waitFor(() => expect(deps.fetchBoardWorkflows).toHaveBeenCalledTimes(2));
expect(result.current.workflowMode).toBe(true);
expect(result.current.boardWorkflows).toEqual(makePayload());
expect(result.current.selectedWorkflow?.id).toBe("wf-b");
expect(result.current.selectedWorkflowId).toBe("wf-b");
expect(localStorage.getItem("kb:p1:kb-dashboard-board-workflow-selection")).toBe("wf-b");
});
it("keeps selected workflow state isolated per hook consumer", async () => {
const depsOne = makeDeps(() => Promise.resolve(makePayload()));
const depsTwo = makeDeps(() => Promise.resolve(makePayload()));

View File

@@ -9,10 +9,18 @@ import {
readBoardWorkflowsCache as defaultReadBoardWorkflowsCache,
writeBoardWorkflowsCache as defaultWriteBoardWorkflowsCache,
} from "../utils/boardWorkflowsCache";
import {
readBoardWorkflowSelection,
removeBoardWorkflowSelection,
writeBoardWorkflowSelection,
} from "../utils/boardWorkflowSelection";
/*
FNXC:Workflows 2026-06-22-17:00:
Single source of truth for board-workflow fetch/cache/SSE/selection, shared verbatim by Board.tsx and the Planning header slot (PlanningWorkflowSwitcherSlot.tsx). Both surfaces must show the SAME workflow dropdown driven by the SAME data path: refetch on mount, on tab visibility/focus, and on `workflow:created|updated|deleted` SSE; every fetch is guarded by a monotonic sequence ref that drops out-of-order responses; successful payloads persist to the per-project session cache; failures collapse to a flag-off payload. Selection (`selectedWorkflowId`) is local per-consumer and auto-syncs to the resolved default/first workflow.
Single source of truth for board-workflow fetch/cache/SSE/selection, shared verbatim by Board.tsx and the Planning header slot (PlanningWorkflowSwitcherSlot.tsx). Both surfaces must show the SAME workflow dropdown driven by the SAME data path: refetch on mount, on tab visibility/focus, and on `workflow:created|updated|deleted` SSE; every fetch is guarded by a monotonic sequence ref that drops out-of-order responses; successful payloads persist to the per-project session cache. Selection (`selectedWorkflowId`) hydrates from project-scoped durable storage, user changes write immediately, and stale stored ids are repaired only after the current payload proves the workflow no longer exists.
FNXC:Workflows 2026-06-29-14:45:
Transient board-workflows fetch failures are not authoritative workflow-mode disable signals. Preserve the last payload and durable workflow selection on API/focus/refresh blips so operators return to their selected lane unless the server explicitly returns workflow mode off, an empty list, or a single unswitchable workflow.
Per-consumer subscription semantics are preserved: each call to this hook installs its OWN visibilitychange/focus listeners and its OWN SSE subscription, so two consumers (Board + Planning slot) each subscribe and unsubscribe independently — the hook does not dedupe across consumers. Dependencies (fetch, subscribeSse, cache helpers) are injectable to keep the hook DI-friendly and free of App-level singletons.
*/
@@ -65,7 +73,23 @@ export function useBoardWorkflows(params: UseBoardWorkflowsParams): UseBoardWork
return cached ? { projectId, payload: cached } : null;
});
const boardWorkflows = boardWorkflowsState?.projectId === projectId && boardWorkflowsState ? boardWorkflowsState.payload : null;
const [selectedWorkflowId, setSelectedWorkflowId] = useState<string | null>(null);
const [selectedWorkflowId, setSelectedWorkflowIdState] = useState<string | null>(() => readBoardWorkflowSelection(projectId));
const storedSelectionRef = useRef<string | null>(selectedWorkflowId);
const setSelectedWorkflowId = useCallback<Dispatch<SetStateAction<string | null>>>((nextSelection) => {
setSelectedWorkflowIdState((previousSelection) => {
const resolvedSelection = typeof nextSelection === "function"
? nextSelection(previousSelection)
: nextSelection;
storedSelectionRef.current = resolvedSelection;
if (resolvedSelection) {
writeBoardWorkflowSelection(projectId, resolvedSelection);
} else {
removeBoardWorkflowSelection(projectId);
}
return resolvedSelection;
});
}, [projectId]);
// Stale-response guard: a monotonic sequence ref drops out-of-order responses.
const boardWorkflowsFetchSeqRef = useRef(0);
@@ -73,6 +97,9 @@ export function useBoardWorkflows(params: UseBoardWorkflowsParams): UseBoardWork
// Re-hydrate from the per-project cache on project change (and gate change).
useEffect(() => {
const cached = shouldHydrateCache ? readBoardWorkflowsCache(projectId) : null;
const storedSelection = readBoardWorkflowSelection(projectId);
storedSelectionRef.current = storedSelection;
setSelectedWorkflowIdState(storedSelection);
setBoardWorkflowsState(cached ? { projectId, payload: cached } : null);
}, [projectId, shouldHydrateCache, readBoardWorkflowsCache]);
@@ -86,9 +113,7 @@ export function useBoardWorkflows(params: UseBoardWorkflowsParams): UseBoardWork
}
})
.catch(() => {
if (seq === boardWorkflowsFetchSeqRef.current) {
setBoardWorkflowsState({ projectId, payload: { flagEnabled: false, defaultWorkflowId: "builtin:coding", workflows: [], taskWorkflowIds: {} } });
}
// Fetch failures are non-authoritative: keep the current/cache-hydrated payload so the cleanup effect does not erase durable selection.
});
}, [projectId, fetchBoardWorkflows, writeBoardWorkflowsCache]);
@@ -137,14 +162,37 @@ export function useBoardWorkflows(params: UseBoardWorkflowsParams): UseBoardWork
}, [boardWorkflows?.defaultWorkflowId, selectedWorkflowId, workflowMode, workflowOptions]);
useEffect(() => {
if (!workflowMode) {
setSelectedWorkflowId(null);
if (!boardWorkflows) {
return;
}
if (selectedWorkflow && selectedWorkflow.id !== selectedWorkflowId) {
setSelectedWorkflowId(selectedWorkflow.id);
if (!workflowMode) {
if (storedSelectionRef.current !== null) {
removeBoardWorkflowSelection(projectId);
storedSelectionRef.current = null;
}
setSelectedWorkflowIdState(null);
return;
}
}, [selectedWorkflow, selectedWorkflowId, workflowMode]);
if (workflowOptions.length < 2) {
if (storedSelectionRef.current !== null) {
removeBoardWorkflowSelection(projectId);
storedSelectionRef.current = null;
}
setSelectedWorkflowIdState(selectedWorkflow?.id ?? null);
return;
}
if (selectedWorkflow && selectedWorkflow.id !== selectedWorkflowId) {
const shouldRepairStoredSelection = storedSelectionRef.current !== null;
setSelectedWorkflowIdState(selectedWorkflow.id);
if (shouldRepairStoredSelection) {
writeBoardWorkflowSelection(projectId, selectedWorkflow.id);
storedSelectionRef.current = selectedWorkflow.id;
}
}
}, [boardWorkflows, projectId, selectedWorkflow, selectedWorkflowId, workflowMode, workflowOptions.length]);
return {
boardWorkflows,

View File

@@ -0,0 +1,135 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
BOARD_WORKFLOW_SELECTION_STORAGE_KEY,
readBoardWorkflowSelection,
removeBoardWorkflowSelection,
writeBoardWorkflowSelection,
} from "../boardWorkflowSelection";
import { scopedKey } from "../projectStorage";
const projectKey = (projectId: string) => scopedKey(BOARD_WORKFLOW_SELECTION_STORAGE_KEY, projectId);
describe("boardWorkflowSelection", () => {
beforeEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
window.localStorage.clear();
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
window.localStorage.clear();
});
it("persists selected workflow ids per project", () => {
writeBoardWorkflowSelection("project-a", "builtin:coding");
writeBoardWorkflowSelection("project-b", "WF-123");
expect(window.localStorage.getItem(projectKey("project-a"))).toBe("builtin:coding");
expect(window.localStorage.getItem(projectKey("project-b"))).toBe("WF-123");
expect(readBoardWorkflowSelection("project-a")).toBe("builtin:coding");
expect(readBoardWorkflowSelection("project-b")).toBe("WF-123");
});
it("uses the unscoped default when no project is selected", () => {
writeBoardWorkflowSelection(undefined, "default-workflow");
writeBoardWorkflowSelection("project-a", "project-workflow");
expect(window.localStorage.getItem(BOARD_WORKFLOW_SELECTION_STORAGE_KEY)).toBe("default-workflow");
expect(readBoardWorkflowSelection()).toBe("default-workflow");
expect(readBoardWorkflowSelection("project-a")).toBe("project-workflow");
});
it("ignores malformed or empty stored values", () => {
window.localStorage.setItem(projectKey("empty"), " ");
window.localStorage.setItem(projectKey("object"), JSON.stringify({ id: "builtin:coding" }));
window.localStorage.setItem(projectKey("array"), JSON.stringify(["builtin:coding"]));
window.localStorage.setItem(projectKey("control"), "workflow\u0000id");
expect(readBoardWorkflowSelection("empty")).toBeNull();
expect(readBoardWorkflowSelection("object")).toBeNull();
expect(readBoardWorkflowSelection("array")).toBeNull();
expect(readBoardWorkflowSelection("control")).toBeNull();
});
it("trims valid workflow ids before storing and reading", () => {
writeBoardWorkflowSelection("project-a", " builtin:coding ");
expect(window.localStorage.getItem(projectKey("project-a"))).toBe("builtin:coding");
expect(readBoardWorkflowSelection("project-a")).toBe("builtin:coding");
});
it("removes the stored selection when writing an empty or malformed value", () => {
writeBoardWorkflowSelection("project-a", "builtin:coding");
writeBoardWorkflowSelection("project-a", " ");
expect(readBoardWorkflowSelection("project-a")).toBeNull();
writeBoardWorkflowSelection("project-a", "builtin:coding");
writeBoardWorkflowSelection("project-a", "{\"id\":\"builtin:coding\"}");
expect(readBoardWorkflowSelection("project-a")).toBeNull();
writeBoardWorkflowSelection("project-a", "builtin:coding");
writeBoardWorkflowSelection("project-a", "workflow\u0000id");
expect(readBoardWorkflowSelection("project-a")).toBeNull();
});
it("removes stored selections for the requested project only", () => {
writeBoardWorkflowSelection("project-a", "builtin:coding");
writeBoardWorkflowSelection("project-b", "WF-123");
removeBoardWorkflowSelection("project-a");
expect(readBoardWorkflowSelection("project-a")).toBeNull();
expect(readBoardWorkflowSelection("project-b")).toBe("WF-123");
});
it("returns null and no-ops when storage APIs are unavailable", () => {
vi.stubGlobal("window", { localStorage: {} });
expect(readBoardWorkflowSelection("project-a")).toBeNull();
expect(() => writeBoardWorkflowSelection("project-a", "builtin:coding")).not.toThrow();
expect(() => removeBoardWorkflowSelection("project-a")).not.toThrow();
});
it("returns null and no-ops without a browser window", () => {
vi.stubGlobal("window", undefined);
expect(readBoardWorkflowSelection("project-a")).toBeNull();
expect(() => writeBoardWorkflowSelection("project-a", "builtin:coding")).not.toThrow();
expect(() => removeBoardWorkflowSelection("project-a")).not.toThrow();
});
it("swallows localStorage read, write, and remove failures", () => {
vi.stubGlobal("window", {
localStorage: {
getItem: () => {
throw new Error("private mode read");
},
},
});
expect(readBoardWorkflowSelection("project-a")).toBeNull();
vi.unstubAllGlobals();
vi.stubGlobal("window", {
localStorage: {
setItem: () => {
throw new Error("quota");
},
removeItem: () => undefined,
},
});
expect(() => writeBoardWorkflowSelection("project-a", "builtin:coding")).not.toThrow();
vi.unstubAllGlobals();
vi.stubGlobal("window", {
localStorage: {
removeItem: () => {
throw new Error("private mode remove");
},
},
});
expect(() => removeBoardWorkflowSelection("project-a")).not.toThrow();
});
});

View File

@@ -103,10 +103,11 @@ describe("projectStorage", () => {
"kb-capacity-risk-banner-dismissed",
"kb-files-line-numbers",
"kb-dashboard-dock-files-current",
"kb-dashboard-board-workflow-selection",
"fusion-plugin-dependency-graph:positions",
]),
);
expect(PROJECT_STORAGE_KEYS).toHaveLength(28);
expect(PROJECT_STORAGE_KEYS).toHaveLength(29);
});
it("stores branch filter values as scoped strings per project", () => {

View File

@@ -0,0 +1,49 @@
import { getScopedItem, removeScopedItem, setScopedItem } from "./projectStorage";
export const BOARD_WORKFLOW_SELECTION_STORAGE_KEY = "kb-dashboard-board-workflow-selection";
function isValidWorkflowSelection(value: unknown): value is string {
if (typeof value !== "string") return false;
const trimmed = value.trim();
if (trimmed.length === 0) return false;
if (trimmed.startsWith("{") || trimmed.startsWith("[")) return false;
if (/\p{C}/u.test(trimmed)) return false;
return true;
}
/**
* FNXC:BoardWorkflowSelection 2026-06-29-12:00:
* Persist only the last selected workflow id in project-scoped localStorage so Board, Header, Graph, and List selectors can restore the operator's workflow after board remounts, task state changes, respecification returns, and browser/server restarts. Storage is best-effort because private-mode, SSR, missing APIs, and quota failures must never block board rendering.
*/
export function readBoardWorkflowSelection(projectId?: string): string | null {
try {
const stored = getScopedItem(BOARD_WORKFLOW_SELECTION_STORAGE_KEY, projectId);
return isValidWorkflowSelection(stored) ? stored.trim() : null;
} catch {
return null;
}
}
export function writeBoardWorkflowSelection(projectId: string | undefined, workflowId: string): void {
try {
const trimmed = workflowId.trim();
if (!isValidWorkflowSelection(trimmed)) {
removeScopedItem(BOARD_WORKFLOW_SELECTION_STORAGE_KEY, projectId);
return;
}
setScopedItem(BOARD_WORKFLOW_SELECTION_STORAGE_KEY, trimmed, projectId);
} catch {
// Best-effort preference persistence; board rendering must continue on storage failures.
}
}
export function removeBoardWorkflowSelection(projectId?: string): void {
try {
removeScopedItem(BOARD_WORKFLOW_SELECTION_STORAGE_KEY, projectId);
} catch {
// Best-effort preference cleanup; storage failures are non-fatal.
}
}

View File

@@ -36,6 +36,7 @@ export const PROJECT_STORAGE_KEYS: string[] = [
"kb-capacity-risk-banner-dismissed",
"kb-files-line-numbers",
"kb-dashboard-dock-files-current",
"kb-dashboard-board-workflow-selection",
"fusion-plugin-dependency-graph:positions",
];