diff --git a/.changeset/fn-7357-all-workflows-selectors.md b/.changeset/fn-7357-all-workflows-selectors.md
new file mode 100644
index 0000000000..4bd2c54c55
--- /dev/null
+++ b/.changeset/fn-7357-all-workflows-selectors.md
@@ -0,0 +1,7 @@
+---
+"@runfusion/fusion": patch
+---
+
+summary: Add All workflows to top-level dashboard workflow selectors.
+category: feature
+dev: Extends the dashboard aggregate workflow sentinel to List, Planning, Missions, and Graph without backend handoff.
diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md
index b7749dabca..dd65b80c48 100644
--- a/docs/dashboard-guide.md
+++ b/docs/dashboard-guide.md
@@ -150,13 +150,14 @@ FNXC:DoneTaskRefine 2026-07-01-00:00: Completed Board card context menus must la
- Done-column sorting has two descending modes: **Completion date (newest first)** keeps the default completion-time order, while **Task ID (newest first)** places the highest numeric task IDs first. The sort actions are only shown in Done/complete column action menus, including custom workflow completion lanes; Archive All Done lives in the same menu when available.
- 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 identity (Fusion icon for built-ins, optional custom icon for custom workflows), name, and chevron only; compact Todo / In Progress / Done counts derived from workflow column flags (excluding archived and board-hidden 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. Board also shows **All workflows** before real workflows as a dashboard-only aggregate view with combined counts and a deterministic union of visible workflow columns; shared column ids use the default workflow label/flags when available, otherwise the first workflow definition that declares the column. Hidden columns stay workflow-scoped in the aggregate: a task whose effective workflow hides a shared column is omitted from that aggregate column even if another workflow exposes the same column id. That option is not editable, is persisted only as a Board view preference, and its quick-create affordance appears only on the chosen real workflow intake/default column so task creation still sends a real workflow id and column. Each real workflow 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. The selected workflow is remembered per project in durable browser storage and restored when returning to Board/List after task refreshes, route changes, respecification flows, or refinement creation from Task Detail and done-task chat; Board also restores the dashboard-only **All workflows** aggregate view when that was the last selected Board view. If a saved real workflow is later deleted, Fusion falls back to a valid default/first workflow so tasks remain visible.
+- Board and List workflow switchers use a themed dropdown instead of a native select. The closed trigger shows the workflow identity (Fusion icon for built-ins, optional custom icon for custom workflows), name, and chevron only; compact Todo / In Progress / Done counts derived from workflow column flags (excluding archived and board-hidden 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. Board and List also show **All workflows** before real workflows as a dashboard-only aggregate view with combined counts and a deterministic union of visible workflow columns; shared column ids use the default workflow label/flags when available, otherwise the first workflow definition that declares the column. Hidden columns stay workflow-scoped in the aggregate: a task whose effective workflow hides a shared column is omitted from that aggregate column even if another workflow exposes the same column id. That option is not editable, persists as top-level workflow view state, and quick-create/Plan/Subtask/Mission handoffs translate it to a real default workflow id or no-specific-workflow behavior so task creation never sends the sentinel. Each real workflow 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. The selected workflow is remembered per project in durable browser storage and restored when returning to Board/List after task refreshes, route changes, respecification flows, or refinement creation from Task Detail and done-task chat; Board, List, Planning/Missions header selectors, and Graph also restore the dashboard-only **All workflows** aggregate view when that was the last selected top-level workflow context. If a saved real workflow is later deleted, Fusion falls back to a valid default/first workflow so tasks remain visible.
- Briefly leaving Board/List for a task detail or another non-task-SSE view preserves the current in-memory task snapshot. Returning to Board/List reuses that fresh snapshot immediately and restores live SSE updates without an extra all-task fetch; Fusion still runs one catch-up fetch when task data is missing, stale, or from a failed refresh.
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 b25927b393..7045403e0e 100644
--- a/packages/dashboard/app/__tests__/workflow-selection-cross-surface.test.tsx
+++ b/packages/dashboard/app/__tests__/workflow-selection-cross-surface.test.tsx
@@ -71,8 +71,8 @@ function CrossSurfaceHarness({ projectId = "project-cross", tasks = TASKS }: { p
-
-
+
+
{graphTasks.map((task) => (
- {task.title}
@@ -213,17 +213,23 @@ describe("workflow selection across dashboard surfaces", () => {
});
});
- it("filters the Board-only all-workflows sentinel out of Header and Graph real-workflow surfaces", async () => {
+ it("shares the all-workflows aggregate sentinel without filtering Graph tasks or editing aggregate rows", async () => {
localStorage.setItem("kb:project-cross:kb-dashboard-board-workflow-selection", ALL_WORKFLOWS_BOARD_VIEW_ID);
render();
- expect(await screen.findAllByTestId("workflow-switcher")).toHaveLength(2);
+ const switchers = await screen.findAllByTestId("workflow-switcher");
+ expect(switchers).toHaveLength(2);
await waitFor(() => {
- expect(screen.getByTestId("header-selection")).toHaveTextContent(DEFAULT_WORKFLOW.id);
- expect(screen.getByTestId("graph-selection")).toHaveTextContent(DEFAULT_WORKFLOW.id);
+ expect(screen.getByTestId("header-selection")).toHaveTextContent(ALL_WORKFLOWS_BOARD_VIEW_ID);
+ expect(screen.getByTestId("graph-selection")).toHaveTextContent(ALL_WORKFLOWS_BOARD_VIEW_ID);
});
- expect(screen.queryByText("All workflows")).toBeNull();
+ for (const task of TASKS) {
+ expect(screen.getByTestId(`graph-task-${task.id}`)).toBeInTheDocument();
+ }
+ fireEvent.click(switchers[0]);
+ expect(screen.getByTestId(`workflow-switcher-option-${ALL_WORKFLOWS_BOARD_VIEW_ID}`)).toHaveTextContent("All workflows");
+ expect(screen.queryByTestId(`workflow-switcher-edit-${ALL_WORKFLOWS_BOARD_VIEW_ID}`)).toBeNull();
expect(localStorage.getItem("kb:project-cross:kb-dashboard-board-workflow-selection")).toBe(ALL_WORKFLOWS_BOARD_VIEW_ID);
});
diff --git a/packages/dashboard/app/components/GraphWorkflowSwitcherSlot.tsx b/packages/dashboard/app/components/GraphWorkflowSwitcherSlot.tsx
index b10a047d3c..3683ee0236 100644
--- a/packages/dashboard/app/components/GraphWorkflowSwitcherSlot.tsx
+++ b/packages/dashboard/app/components/GraphWorkflowSwitcherSlot.tsx
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import type { BoardWorkflowDefinition, BoardWorkflowsPayload } from "../api";
import { useBoardWorkflows } from "../hooks/useBoardWorkflows";
+import { ALL_WORKFLOWS_BOARD_VIEW_ID } from "../utils/boardWorkflowSelection";
import { useViewportMode } from "../hooks/useViewportMode";
import { WorkflowSwitcher } from "./WorkflowSwitcher";
import type { WorkflowStatusCounts } from "./workflowStatusCounts";
@@ -9,6 +10,7 @@ import type { WorkflowStatusCounts } from "./workflowStatusCounts";
export interface GraphWorkflowSelection {
boardWorkflows: BoardWorkflowsPayload;
selectedWorkflow: BoardWorkflowDefinition;
+ isAllWorkflowsSelected: boolean;
}
interface GraphWorkflowSwitcherSlotProps {
@@ -29,7 +31,7 @@ export function filterTasksByGraphWorkflowSelection(
projectId: string | undefined,
selection: GraphWorkflowSelection | null,
): T[] {
- if (!projectId || !selection) return tasks;
+ if (!projectId || !selection || selection.isAllWorkflowsSelected) return tasks;
const workflowIds = new Set(selection.boardWorkflows.workflows.map((workflow) => workflow.id));
return tasks.filter((task) => {
const rawAssignedWorkflowId = selection.boardWorkflows.taskWorkflowIds[task.id];
@@ -55,6 +57,7 @@ export function GraphWorkflowSwitcherSlot({
workflowMode,
workflowOptions,
selectedWorkflow,
+ isAllWorkflowsSelected,
setSelectedWorkflowId,
refreshBoardWorkflows,
} = useBoardWorkflows({ projectId });
@@ -87,8 +90,8 @@ export function GraphWorkflowSwitcherSlot({
const selection = useMemo(() => {
if (!workflowMode || !boardWorkflows || !selectedWorkflow) return null;
- return { boardWorkflows, selectedWorkflow };
- }, [boardWorkflows, selectedWorkflow, workflowMode]);
+ return { boardWorkflows, selectedWorkflow, isAllWorkflowsSelected };
+ }, [boardWorkflows, isAllWorkflowsSelected, selectedWorkflow, workflowMode]);
useEffect(() => {
onWorkflowSelectionChange?.(selection);
@@ -107,9 +110,10 @@ export function GraphWorkflowSwitcherSlot({
(() => {
if (!workflowMode || !boardWorkflows || !selectedWorkflow) return null;
- return { boardWorkflows, selectedWorkflow };
- }, [boardWorkflows, selectedWorkflow, workflowMode]);
+ return { boardWorkflows, selectedWorkflow, isAllWorkflowsSelected };
+ }, [boardWorkflows, isAllWorkflowsSelected, selectedWorkflow, workflowMode]);
useEffect(() => {
onWorkflowSelectionChange?.(selection);
@@ -89,9 +92,10 @@ export function HeaderWorkflowSwitcherSlot({
(() => {
if (!workflowMode || !selectedWorkflow) return LEGACY_LIST_COLUMNS;
- return selectedWorkflow.columns.filter((column) => !column.flags.hiddenFromBoard);
- }, [selectedWorkflow, workflowMode]);
+ if (!isAllWorkflowsSelected || !boardWorkflows) {
+ return selectedWorkflow.columns.filter((column) => !column.flags.hiddenFromBoard);
+ }
+
+ /*
+ FNXC:WorkflowAggregation 2026-07-01-00:00:
+ The aggregate List view is a dashboard-only cross-workflow context. Build its column metadata from real workflows with the default workflow first so shared columns keep stable names/flags, then append the first non-hidden declaration from other workflows for tasks that would otherwise have no visible group.
+ */
+ const workflowsById = new Map(boardWorkflows.workflows.map((workflow) => [workflow.id, workflow]));
+ const orderedWorkflows = [
+ workflowsById.get(boardWorkflows.defaultWorkflowId),
+ ...boardWorkflows.workflows.filter((workflow) => workflow.id !== boardWorkflows.defaultWorkflowId),
+ ].filter((workflow): workflow is BoardWorkflowsPayload["workflows"][number] => Boolean(workflow));
+ const columnsById = new Map();
+ for (const workflow of orderedWorkflows) {
+ for (const column of workflow.columns) {
+ if (column.flags.hiddenFromBoard || columnsById.has(column.id)) continue;
+ columnsById.set(column.id, column);
+ }
+ }
+ return [...columnsById.values()];
+ }, [boardWorkflows, isAllWorkflowsSelected, selectedWorkflow, workflowMode]);
const columnNameById = useMemo(() => {
const map = new Map();
@@ -644,25 +666,41 @@ export function ListView({
}, [columnFlagsById, workflowMode]);
const selectedWorkflowTaskIds = useMemo(() => {
- if (!workflowMode || !boardWorkflows || !selectedWorkflow) return null;
+ if (!workflowMode || !boardWorkflows || !selectedWorkflow || isAllWorkflowsSelected) return null;
const ids = new Set();
+ const workflowIds = new Set(boardWorkflows.workflows.map((workflow) => workflow.id));
for (const task of tasks) {
- const workflowId = boardWorkflows.taskWorkflowIds[task.id] ?? boardWorkflows.defaultWorkflowId;
+ const rawWorkflowId = boardWorkflows.taskWorkflowIds[task.id];
+ const workflowId = rawWorkflowId && workflowIds.has(rawWorkflowId) ? rawWorkflowId : boardWorkflows.defaultWorkflowId;
if (workflowId === selectedWorkflow.id) ids.add(task.id);
}
return ids;
- }, [boardWorkflows, selectedWorkflow, tasks, workflowMode]);
+ }, [boardWorkflows, isAllWorkflowsSelected, selectedWorkflow, tasks, workflowMode]);
const workflowStatusCounts = useMemo(
() => computeWorkflowStatusCounts(tasks, boardWorkflows),
[boardWorkflows, tasks],
);
+ const createTargetWorkflowId = useMemo(() => {
+ if (!workflowMode || !boardWorkflows) return null;
+ if (!isAllWorkflowsSelected) return selectedWorkflow?.id ?? null;
+ return boardWorkflows.workflows.find((workflow) => workflow.id === boardWorkflows.defaultWorkflowId)?.id
+ ?? boardWorkflows.workflows[0]?.id
+ ?? null;
+ }, [boardWorkflows, isAllWorkflowsSelected, selectedWorkflow, workflowMode]);
+
const createTargetColumn = useMemo(() => {
+ if (workflowMode && boardWorkflows && createTargetWorkflowId) {
+ const workflow = boardWorkflows.workflows.find((candidate) => candidate.id === createTargetWorkflowId);
+ const target = workflow?.columns.find((column) => column.flags.intake && !column.flags.archived && !column.flags.hiddenFromBoard)
+ ?? workflow?.columns.find((column) => !column.flags.archived && !column.flags.hiddenFromBoard);
+ if (target) return target.id;
+ }
const target = listColumns.find((column) => column.flags.intake && !column.flags.archived)
?? listColumns.find((column) => !column.flags.archived);
return target?.id;
- }, [listColumns]);
+ }, [boardWorkflows, createTargetWorkflowId, listColumns, workflowMode]);
/**
* FNXC:WorkflowList 2026-06-21-21:37:
@@ -698,8 +736,8 @@ export function ListView({
const handleListQuickCreate = useCallback(async (input: TaskCreateInput) => {
const create = onQuickCreate ?? (async () => addToast(t("listView.taskCreationUnavailable", "Task creation not available"), "error"));
- if (workflowMode && selectedWorkflow && createTargetColumn) {
- const workflowId = typeof input.workflowId === "string" ? input.workflowId : selectedWorkflow.id;
+ if (workflowMode && createTargetWorkflowId && createTargetColumn) {
+ const workflowId = typeof input.workflowId === "string" && input.workflowId !== ALL_WORKFLOWS_BOARD_VIEW_ID ? input.workflowId : createTargetWorkflowId;
const targetColumn = resolveListQuickCreateTarget(workflowId, input.column) ?? createTargetColumn;
const created = await create({
...input,
@@ -714,13 +752,13 @@ export function ListView({
return created;
}
return create(input);
- }, [addToast, applyOptimisticTaskWorkflow, createTargetColumn, onQuickCreate, refreshBoardWorkflows, resolveListQuickCreateTarget, selectedWorkflow, t, workflowMode]);
+ }, [addToast, applyOptimisticTaskWorkflow, createTargetColumn, createTargetWorkflowId, onQuickCreate, refreshBoardWorkflows, resolveListQuickCreateTarget, 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;
+ const listQuickEntryWorkflowId = workflowMode ? createTargetWorkflowId : undefined;
// Column display labels
const COLUMN_LABELS_MAP: Record = {
@@ -2033,9 +2071,10 @@ export function ListView({
void;
+ onOpenWorkflowEditor?: (workflowId?: string) => void;
onCreateWorkflow?: () => void;
+ onWorkflowSelectionChange?: (selection: HeaderWorkflowSelection | null) => void;
}
export function PlanningWorkflowSwitcherSlot(props: PlanningWorkflowSwitcherSlotProps) {
diff --git a/packages/dashboard/app/components/__tests__/GraphWorkflowSwitcherSlot.test.tsx b/packages/dashboard/app/components/__tests__/GraphWorkflowSwitcherSlot.test.tsx
index 09ebdb9d39..fcfdb2ab8f 100644
--- a/packages/dashboard/app/components/__tests__/GraphWorkflowSwitcherSlot.test.tsx
+++ b/packages/dashboard/app/components/__tests__/GraphWorkflowSwitcherSlot.test.tsx
@@ -1,6 +1,7 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { BoardWorkflowDefinition, BoardWorkflowsPayload } from "../../api";
+import { ALL_WORKFLOWS_BOARD_VIEW_ID } from "../../utils/boardWorkflowSelection";
import { filterTasksByGraphWorkflowSelection, GraphWorkflowSwitcherSlot, type GraphWorkflowSelection } from "../GraphWorkflowSwitcherSlot";
const fetchBoardWorkflowsMock = vi.fn();
@@ -74,6 +75,7 @@ describe("filterTasksByGraphWorkflowSelection", () => {
},
}),
selectedWorkflow: DEFAULT_WORKFLOW,
+ isAllWorkflowsSelected: false,
};
expect(filterTasksByGraphWorkflowSelection(tasks, "project-graph", selection).map((task) => task.id)).toEqual([
@@ -86,11 +88,23 @@ describe("filterTasksByGraphWorkflowSelection", () => {
]);
});
+ it("preserves all graph tasks for the aggregate workflow selection", () => {
+ const tasks = [{ id: "FN-a" }, { id: "FN-b" }];
+ const selection: GraphWorkflowSelection = {
+ boardWorkflows: workflowPayload({ taskWorkflowIds: { "FN-b": CUSTOM_WORKFLOW.id } }),
+ selectedWorkflow: DEFAULT_WORKFLOW,
+ isAllWorkflowsSelected: true,
+ };
+
+ expect(filterTasksByGraphWorkflowSelection(tasks, "project-graph", selection)).toBe(tasks);
+ });
+
it("preserves unfiltered graph tasks without a project or workflow payload", () => {
const tasks = [{ id: "FN-a" }, { id: "FN-b" }];
const selection: GraphWorkflowSelection = {
boardWorkflows: workflowPayload({ taskWorkflowIds: { "FN-b": CUSTOM_WORKFLOW.id } }),
selectedWorkflow: CUSTOM_WORKFLOW,
+ isAllWorkflowsSelected: false,
};
expect(filterTasksByGraphWorkflowSelection(tasks, undefined, selection)).toBe(tasks);
@@ -112,6 +126,7 @@ describe("GraphWorkflowSwitcherSlot", () => {
expect(onWorkflowSelectionChange).toHaveBeenLastCalledWith({
boardWorkflows: workflowPayload(),
selectedWorkflow: DEFAULT_WORKFLOW,
+ isAllWorkflowsSelected: false,
});
});
});
@@ -198,6 +213,25 @@ describe("GraphWorkflowSwitcherSlot", () => {
});
});
+ it("exposes all workflows as an aggregate non-editable graph selection", async () => {
+ appendHeaderWorkflowSlot();
+ const onWorkflowSelectionChange = vi.fn();
+ const onOpenWorkflowEditor = vi.fn();
+ render();
+
+ fireEvent.click(await screen.findByTestId("workflow-switcher"));
+ expect(screen.getByTestId(`workflow-switcher-option-${ALL_WORKFLOWS_BOARD_VIEW_ID}`)).toHaveTextContent("All workflows");
+ expect(screen.queryByTestId(`workflow-switcher-edit-${ALL_WORKFLOWS_BOARD_VIEW_ID}`)).toBeNull();
+ fireEvent.click(screen.getByTestId(`workflow-switcher-option-${ALL_WORKFLOWS_BOARD_VIEW_ID}`));
+
+ await waitFor(() => {
+ const lastSelection = onWorkflowSelectionChange.mock.calls.at(-1)?.[0] as GraphWorkflowSelection | null;
+ expect(lastSelection?.isAllWorkflowsSelected).toBe(true);
+ expect(lastSelection?.selectedWorkflow.id).toBe(DEFAULT_WORKFLOW.id);
+ });
+ expect(onOpenWorkflowEditor).not.toHaveBeenCalledWith(ALL_WORKFLOWS_BOARD_VIEW_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 a59a392f2b..f994d0bf67 100644
--- a/packages/dashboard/app/components/__tests__/HeaderWorkflowSwitcherSlot.test.tsx
+++ b/packages/dashboard/app/components/__tests__/HeaderWorkflowSwitcherSlot.test.tsx
@@ -8,6 +8,7 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { BoardWorkflowDefinition, BoardWorkflowsPayload } from "../../api";
import { HeaderWorkflowSwitcherSlot, type HeaderWorkflowSelection } from "../HeaderWorkflowSwitcherSlot";
+import { ALL_WORKFLOWS_BOARD_VIEW_ID } from "../../utils/boardWorkflowSelection";
import { PlanningWorkflowSwitcherSlot } from "../PlanningWorkflowSwitcherSlot";
const fetchBoardWorkflowsMock = vi.fn();
@@ -131,6 +132,32 @@ describe("HeaderWorkflowSwitcherSlot", () => {
expect(localStorage.getItem("kb:project-header-stale:kb-dashboard-board-workflow-selection")).toBe(DEFAULT_WORKFLOW.id);
});
+ it("exposes all workflows as an aggregate non-editable header selection", async () => {
+ const onWorkflowSelectionChange = vi.fn<(selection: HeaderWorkflowSelection | null) => void>();
+ const onOpenWorkflowEditor = vi.fn();
+ renderWithHeader(
+ ,
+ );
+
+ fireEvent.click(await screen.findByTestId("workflow-switcher"));
+ expect(screen.getByTestId(`workflow-switcher-option-${ALL_WORKFLOWS_BOARD_VIEW_ID}`)).toHaveTextContent("All workflows");
+ expect(screen.queryByTestId(`workflow-switcher-edit-${ALL_WORKFLOWS_BOARD_VIEW_ID}`)).toBeNull();
+ fireEvent.click(screen.getByTestId(`workflow-switcher-option-${ALL_WORKFLOWS_BOARD_VIEW_ID}`));
+
+ await waitFor(() => {
+ expect(onWorkflowSelectionChange).toHaveBeenLastCalledWith(expect.objectContaining({
+ isAllWorkflowsSelected: true,
+ selectedWorkflow: expect.objectContaining({ id: DEFAULT_WORKFLOW.id }),
+ }));
+ });
+ expect(onOpenWorkflowEditor).not.toHaveBeenCalledWith(ALL_WORKFLOWS_BOARD_VIEW_ID);
+ expect(localStorage.getItem("kb:project-header-all:kb-dashboard-board-workflow-selection")).toBe(ALL_WORKFLOWS_BOARD_VIEW_ID);
+ });
+
it("forwards dropdown edit workflow ids from the shared header slot", async () => {
const onOpenWorkflowEditor = vi.fn();
renderWithHeader();
diff --git a/packages/dashboard/app/components/__tests__/ListView.test.tsx b/packages/dashboard/app/components/__tests__/ListView.test.tsx
index b4ccbfc373..7e750661d8 100644
--- a/packages/dashboard/app/components/__tests__/ListView.test.tsx
+++ b/packages/dashboard/app/components/__tests__/ListView.test.tsx
@@ -6,7 +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 { ALL_WORKFLOWS_BOARD_VIEW_ID, BOARD_WORKFLOW_SELECTION_STORAGE_KEY } from "../../utils/boardWorkflowSelection";
import { loadAllAppCss } from "../../test/cssFixture";
// Mock the API
@@ -1143,6 +1143,45 @@ describe("ListView", () => {
mobileSpy.mockRestore();
});
+ it("shows all workflows in ListView without submitting the aggregate sentinel", async () => {
+ const mockOnQuickCreate = vi.fn().mockResolvedValue({ id: "FN-new" });
+ vi.mocked(fetchBoardWorkflows).mockResolvedValue({
+ flagEnabled: true,
+ defaultWorkflowId: "builtin:coding",
+ workflows: [
+ { id: "builtin:coding", name: "Coding", columns: [{ id: "triage", name: "Triage", flags: { intake: true } }, { id: "done", name: "Done", flags: { complete: true } }] },
+ { id: "wf-custom", name: "Custom", columns: [{ id: "backlog", name: "Backlog", flags: { intake: true } }, { id: "review", name: "Review", flags: { countsTowardWip: true } }] },
+ ],
+ taskWorkflowIds: { "FN-002": "wf-custom", "FN-003": "wf-deleted" },
+ });
+
+ renderListView({
+ tasks: [
+ createMockTask({ id: "FN-001", column: "triage", title: "Coding task" }),
+ createMockTask({ id: "FN-002", column: "backlog", title: "Custom task" }),
+ createMockTask({ id: "FN-003", column: "triage", title: "Stale workflow task" }),
+ ],
+ onQuickCreate: mockOnQuickCreate,
+ });
+
+ await selectWorkflow(ALL_WORKFLOWS_BOARD_VIEW_ID);
+
+ expect(screen.getByText("Coding task")).toBeInTheDocument();
+ expect(screen.getByText("Custom task")).toBeInTheDocument();
+ expect(screen.getByText("Stale workflow task")).toBeInTheDocument();
+ expect(screen.getByTestId("workflow-switcher")).toHaveTextContent("All workflows");
+ expect(screen.queryByTestId(`workflow-switcher-edit-${ALL_WORKFLOWS_BOARD_VIEW_ID}`)).toBeNull();
+
+ fireEvent.change(screen.getByTestId("quick-entry-input"), { target: { value: "Aggregate quick add" } });
+ fireEvent.keyDown(screen.getByTestId("quick-entry-input"), { key: "Enter" });
+
+ await waitFor(() => expect(mockOnQuickCreate).toHaveBeenCalledWith(expect.objectContaining({
+ description: "Aggregate quick add",
+ workflowId: "builtin:coding",
+ })));
+ expect(mockOnQuickCreate).not.toHaveBeenCalledWith(expect.objectContaining({ workflowId: ALL_WORKFLOWS_BOARD_VIEW_ID }));
+ });
+
it("shows workflow edit and New actions inside the dropdown", async () => {
const onCreateWorkflow = vi.fn();
const onOpenWorkflowEditor = vi.fn();
diff --git a/packages/dashboard/app/components/dashboard/MainContent.tsx b/packages/dashboard/app/components/dashboard/MainContent.tsx
index ff6e8415d4..1f64b24469 100644
--- a/packages/dashboard/app/components/dashboard/MainContent.tsx
+++ b/packages/dashboard/app/components/dashboard/MainContent.tsx
@@ -175,6 +175,7 @@ export function MainContent({
_WorkflowEditorView,
}: MainContentProps) {
const [missionWorkflowId, setMissionWorkflowId] = useState(null);
+ const [planningHeaderWorkflowId, setPlanningHeaderWorkflowId] = useState(null);
if (showBackendConnectionErrorPage) {
return (
@@ -364,11 +365,14 @@ export function MainContent({
{/*
FNXC:MissionWorkflows 2026-06-25-00:00:
Missions intentionally shares Planning's header workflow-selection surface because feature and slice triage create tasks. Keep the selected workflow local to this project view and thread only the resolved id into mission task creation.
+
+ FNXC:WorkflowAggregation 2026-07-01-00:00:
+ The All workflows row is view-only context for Missions; mission task creation receives `null` default behavior instead of the aggregate sentinel.
*/}
setMissionWorkflowId(selection?.selectedWorkflow.id ?? null)}
+ onWorkflowSelectionChange={(selection) => setMissionWorkflowId(selection && !selection.isAllWorkflowsSelected ? selection.selectedWorkflow.id : null)}
/>
+ setPlanningHeaderWorkflowId(selection && !selection.isAllWorkflowsSelected ? selection.selectedWorkflow.id : null)}
+ />
diff --git a/packages/dashboard/app/components/workflowStatusCounts.ts b/packages/dashboard/app/components/workflowStatusCounts.ts
index a60f4177b1..45b389ddae 100644
--- a/packages/dashboard/app/components/workflowStatusCounts.ts
+++ b/packages/dashboard/app/components/workflowStatusCounts.ts
@@ -1,5 +1,6 @@
import type { Task } from "@fusion/core";
import type { BoardWorkflowColumn, BoardWorkflowsPayload } from "../api";
+import { ALL_WORKFLOWS_BOARD_VIEW_ID } from "../utils/boardWorkflowSelection";
export interface WorkflowStatusCounts {
todo: number;
@@ -71,6 +72,8 @@ export function computeWorkflowStatusCounts(
);
}
+ const aggregateCounts = EMPTY_COUNTS();
+ countsByWorkflow.set(ALL_WORKFLOWS_BOARD_VIEW_ID, aggregateCounts);
if (!tasks?.length) return countsByWorkflow;
for (const task of tasks) {
@@ -93,12 +96,14 @@ export function computeWorkflowStatusCounts(
const counts = countsByWorkflow.get(workflow.id) ?? EMPTY_COUNTS();
counts[bucket] += 1;
+ aggregateCounts[bucket] += 1;
if (MERGING_STATUSES.has(task.status ?? "")) {
/*
FNXC:WorkflowSwitcher 2026-06-22-20:30:
Workflow boards need a visible flashing indicator in the workflow dropdown when any task assigned to that workflow is actively merging, independent of whether the workflow's review/merge column buckets as Todo or In Progress.
*/
counts.merging += 1;
+ aggregateCounts.merging += 1;
}
countsByWorkflow.set(workflow.id, counts);
}
diff --git a/packages/dashboard/app/hooks/useBoardWorkflows.ts b/packages/dashboard/app/hooks/useBoardWorkflows.ts
index aa431d4c72..b6113ce7bb 100644
--- a/packages/dashboard/app/hooks/useBoardWorkflows.ts
+++ b/packages/dashboard/app/hooks/useBoardWorkflows.ts
@@ -10,7 +10,8 @@ import {
writeBoardWorkflowsCache as defaultWriteBoardWorkflowsCache,
} from "../utils/boardWorkflowsCache";
import {
- readBoardWorkflowSelection,
+ ALL_WORKFLOWS_BOARD_VIEW_ID,
+ readBoardWorkflowViewSelection,
removeBoardWorkflowSelection,
writeBoardWorkflowSelection,
} from "../utils/boardWorkflowSelection";
@@ -45,9 +46,11 @@ export interface UseBoardWorkflowsResult {
workflowMode: boolean;
/** Workflows sorted with the default first, then alphabetical. Empty unless in workflow mode. */
workflowOptions: BoardWorkflowDefinition[];
- /** Currently selected workflow (resolved from selection / default / first), or null. */
+ /** Currently selected real workflow (resolved from selection / default / first), or null. */
selectedWorkflow: BoardWorkflowDefinition | null;
selectedWorkflowId: string | null;
+ /** True when the dashboard-only aggregate workflow view is selected. */
+ isAllWorkflowsSelected: boolean;
setSelectedWorkflowId: Dispatch>;
/** Force a fresh fetch (used on switcher open, since task assignment changes emit no workflow SSE). */
refreshBoardWorkflows: () => void;
@@ -73,7 +76,7 @@ export function useBoardWorkflows(params: UseBoardWorkflowsParams): UseBoardWork
return cached ? { projectId, payload: cached } : null;
});
const boardWorkflows = boardWorkflowsState?.projectId === projectId && boardWorkflowsState ? boardWorkflowsState.payload : null;
- const [selectedWorkflowId, setSelectedWorkflowIdState] = useState(() => readBoardWorkflowSelection(projectId));
+ const [selectedWorkflowId, setSelectedWorkflowIdState] = useState(() => readBoardWorkflowViewSelection(projectId));
const storedSelectionRef = useRef(selectedWorkflowId);
const setSelectedWorkflowId = useCallback>>((nextSelection) => {
@@ -97,7 +100,7 @@ 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);
+ const storedSelection = readBoardWorkflowViewSelection(projectId);
storedSelectionRef.current = storedSelection;
setSelectedWorkflowIdState(storedSelection);
setBoardWorkflowsState(cached ? { projectId, payload: cached } : null);
@@ -153,6 +156,8 @@ export function useBoardWorkflows(params: UseBoardWorkflowsParams): UseBoardWork
});
}, [boardWorkflows, workflowMode]);
+ const isAllWorkflowsSelected = selectedWorkflowId === ALL_WORKFLOWS_BOARD_VIEW_ID;
+
const selectedWorkflow = useMemo(() => {
if (!workflowMode) return null;
return workflowOptions.find((workflow) => workflow.id === selectedWorkflowId)
@@ -184,6 +189,10 @@ export function useBoardWorkflows(params: UseBoardWorkflowsParams): UseBoardWork
return;
}
+ if (isAllWorkflowsSelected) {
+ return;
+ }
+
if (selectedWorkflow && selectedWorkflow.id !== selectedWorkflowId) {
const shouldRepairStoredSelection = storedSelectionRef.current !== null;
setSelectedWorkflowIdState(selectedWorkflow.id);
@@ -192,7 +201,7 @@ export function useBoardWorkflows(params: UseBoardWorkflowsParams): UseBoardWork
storedSelectionRef.current = selectedWorkflow.id;
}
}
- }, [boardWorkflows, projectId, selectedWorkflow, selectedWorkflowId, workflowMode, workflowOptions.length]);
+ }, [boardWorkflows, isAllWorkflowsSelected, projectId, selectedWorkflow, selectedWorkflowId, workflowMode, workflowOptions.length]);
return {
boardWorkflows,
@@ -200,6 +209,7 @@ export function useBoardWorkflows(params: UseBoardWorkflowsParams): UseBoardWork
workflowOptions,
selectedWorkflow,
selectedWorkflowId,
+ isAllWorkflowsSelected,
setSelectedWorkflowId,
refreshBoardWorkflows,
setBoardWorkflowsState,
diff --git a/packages/dashboard/app/utils/boardWorkflowSelection.ts b/packages/dashboard/app/utils/boardWorkflowSelection.ts
index b76d6da6b8..e8986fbbfd 100644
--- a/packages/dashboard/app/utils/boardWorkflowSelection.ts
+++ b/packages/dashboard/app/utils/boardWorkflowSelection.ts
@@ -19,7 +19,10 @@ function isValidWorkflowSelection(value: unknown): value is string {
* Persist the last selected Board workflow view 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.
*
* FNXC:BoardWorkflowSelection 2026-06-30-00:00:
- * The durable Board view preference may be either a real workflow id or the Board-only All workflows sentinel so a refresh can restore the aggregate board. Shared real-workflow consumers must call `readBoardWorkflowSelection`; it filters the sentinel out so Header, Graph, List, task creation, and workflow APIs never receive `__all_workflows__` as a backend workflow id.
+ * The durable Board view preference may be either a real workflow id or the Board-only All workflows sentinel so a refresh can restore the aggregate board. Shared real-workflow consumers must call `readBoardWorkflowSelection`; it filters the sentinel out so task creation, workflow APIs, and other backend handoffs never receive `__all_workflows__` as a real workflow id.
+ *
+ * FNXC:WorkflowAggregation 2026-07-01-00:00:
+ * Top-level dashboard selectors (Board/List/Planning/Missions/Graph) may display the aggregate sentinel as view state, but any task-creating or workflow-editing path must translate it to a real workflow id or `null` default behavior before crossing component/API boundaries.
*/
export function readBoardWorkflowViewSelection(projectId?: string): string | null {
try {