FN-7357: add all workflows to top-level selectors

Extend the dashboard aggregate workflow view across top-level workflow selectors without leaking its sentinel into task creation.

- Add All workflows as an aggregate option for List, Header, Planning/Missions, and Graph selectors.\n- Preserve real workflow handoffs for quick create, planning, missions, and workflow editor entry points.\n- Include aggregate workflow counts and cross-surface regression coverage, plus docs and a changeset.\n\nFiles changed:\n .changeset/fn-7357-all-workflows-selectors.md      |  7 +++\n docs/dashboard-guide.md                            |  9 ++--\n .../workflow-selection-cross-surface.test.tsx      | 20 ++++---\n .../app/components/GraphWorkflowSwitcherSlot.tsx   | 12 +++--\n .../app/components/HeaderWorkflowSwitcherSlot.tsx  | 10 ++--\n packages/dashboard/app/components/ListView.tsx     | 63 +++++++++++++++++-----\n .../components/PlanningWorkflowSwitcherSlot.tsx    |  5 +-\n .../__tests__/GraphWorkflowSwitcherSlot.test.tsx   | 34 ++++++++++++\n .../__tests__/HeaderWorkflowSwitcherSlot.test.tsx  | 27 ++++++++++\n .../app/components/__tests__/ListView.test.tsx     | 41 +++++++++++++-\n .../app/components/dashboard/MainContent.tsx       | 17 ++++--\n .../app/components/workflowStatusCounts.ts         |  5 ++\n packages/dashboard/app/hooks/useBoardWorkflows.ts  | 20 +++++--\n .../dashboard/app/utils/boardWorkflowSelection.ts  |  5 +-\n 14 files changed, 233 insertions(+), 42 deletions(-)

Fusion-Task-Id: FN-7357

Fusion-Task-Lineage: b8658cad-7b12-4a06-b627-e2bde7de6951

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-01 01:22:09 -07:00
parent d448603e71
commit b3b01bc25d
14 changed files with 233 additions and 42 deletions

View File

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

View File

@@ -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.
<!-- 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. -->
<!-- FNXC:WorkflowSelection 2026-06-29-18:37: The Board-only All workflows option renders an aggregate column set across workflows while keeping workflow-specific creates and edits scoped to real workflow ids.
FNXC:WorkflowSelection 2026-06-30-00:00: The Board view preference persists either a real workflow id or the Board-only All workflows sentinel so refresh/remount restores the operator's last board view without treating the sentinel as a backend workflow id. -->
<!-- FNXC:WorkflowSelection 2026-06-29-18:37: The All workflows option renders an aggregate column/task set across workflows while keeping workflow-specific creates and edits scoped to real workflow ids.
FNXC:WorkflowSelection 2026-06-30-00:00: The view preference persists either a real workflow id or the All workflows sentinel so refresh/remount restores the operator's last top-level workflow context without treating the sentinel as a backend workflow id.
FNXC:WorkflowSelection 2026-07-01-00:00: All workflows is available on Board, List, Planning, Missions, and Graph top-level selectors; Planning/Missions task creation receives default/no-specific-workflow behavior instead of the sentinel. -->
<!-- FNXC:WorkflowSelection 2026-06-29-23:58: All workflows quick-create must use a real workflow intake/default column rather than a synthesized lifecycle column, so custom-default boards do not create tasks into invalid or disappearing columns. -->
<!-- FNXC:WorkflowSelection 2026-06-29-23:59: Workflow counts and All workflows grouping resolve each task's effective workflow before evaluating column visibility, so a shared column id hidden in one workflow does not leak that workflow's hidden tasks into another workflow's visible aggregate lane. -->
<!-- FNXC:WorkflowSelection 2026-06-29-21:40: Refinement creation from Task Detail and done-task chat must preserve both the source task workflow and the operator's selected Board/List lane, so non-default workflow users do not get bounced back to Coding/default after refinement. -->
- 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.
<!-- FNXC:BoardTaskCache 2026-06-29-20:05: Board/List returns from non-task-SSE views should reuse a fresh in-memory task snapshot to avoid redundant all-task fetches and loading flashes, while stale, missing, or errored snapshots still trigger one catch-up fetch and restore SSE updates. -->

View File

@@ -71,8 +71,8 @@ function CrossSurfaceHarness({ projectId = "project-cross", tasks = TASKS }: { p
<div id="header-workflow-slot" data-testid="header-workflow-slot" />
<HeaderWorkflowSwitcherSlot projectId={projectId} onWorkflowSelectionChange={setHeaderSelection} />
<GraphWorkflowSwitcherSlot projectId={projectId} onWorkflowSelectionChange={setGraphSelection} />
<output data-testid="header-selection">{headerSelection?.selectedWorkflow.id ?? "none"}</output>
<output data-testid="graph-selection">{graphSelection?.selectedWorkflow.id ?? "none"}</output>
<output data-testid="header-selection">{headerSelection?.isAllWorkflowsSelected ? ALL_WORKFLOWS_BOARD_VIEW_ID : headerSelection?.selectedWorkflow.id ?? "none"}</output>
<output data-testid="graph-selection">{graphSelection?.isAllWorkflowsSelected ? ALL_WORKFLOWS_BOARD_VIEW_ID : graphSelection?.selectedWorkflow.id ?? "none"}</output>
<ul data-testid="graph-tasks">
{graphTasks.map((task) => (
<li key={task.id} data-testid={`graph-task-${task.id}`}>{task.title}</li>
@@ -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(<CrossSurfaceHarness />);
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);
});

View File

@@ -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<T extends { id: string }>(
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<GraphWorkflowSelection | null>(() => {
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({
<div className="board-workflow-selector">
<WorkflowSwitcher
workflows={workflowOptions}
value={selectedWorkflow.id}
value={isAllWorkflowsSelected ? ALL_WORKFLOWS_BOARD_VIEW_ID : selectedWorkflow.id}
onChange={setSelectedWorkflowId}
counts={EMPTY_COUNTS}
aggregateOption={{ id: ALL_WORKFLOWS_BOARD_VIEW_ID, name: "All workflows" }}
onOpen={refreshBoardWorkflows}
onEditWorkflow={onOpenWorkflowEditor}
onCreateWorkflow={onCreateWorkflow}

View File

@@ -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 HeaderWorkflowSelection {
boardWorkflows: BoardWorkflowsPayload;
selectedWorkflow: BoardWorkflowDefinition;
isAllWorkflowsSelected: boolean;
}
interface HeaderWorkflowSwitcherSlotProps {
@@ -37,6 +39,7 @@ export function HeaderWorkflowSwitcherSlot({
workflowMode,
workflowOptions,
selectedWorkflow,
isAllWorkflowsSelected,
setSelectedWorkflowId,
refreshBoardWorkflows,
} = useBoardWorkflows({ projectId });
@@ -69,8 +72,8 @@ export function HeaderWorkflowSwitcherSlot({
const selection = useMemo<HeaderWorkflowSelection | null>(() => {
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({
<div className="board-workflow-selector">
<WorkflowSwitcher
workflows={workflowOptions}
value={selectedWorkflow.id}
value={isAllWorkflowsSelected ? ALL_WORKFLOWS_BOARD_VIEW_ID : selectedWorkflow.id}
onChange={setSelectedWorkflowId}
counts={EMPTY_COUNTS}
aggregateOption={{ id: ALL_WORKFLOWS_BOARD_VIEW_ID, name: "All workflows" }}
onOpen={refreshBoardWorkflows}
onEditWorkflow={onOpenWorkflowEditor}
onCreateWorkflow={onCreateWorkflow}

View File

@@ -20,6 +20,7 @@ import { isTaskStuck } from "../utils/taskStuck";
import type { ToastType } from "../hooks/useToast";
import { useViewportMode } from "../hooks/useViewportMode";
import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/projectStorage";
import { ALL_WORKFLOWS_BOARD_VIEW_ID } from "../utils/boardWorkflowSelection";
import { getUnifiedTaskProgress } from "../utils/taskProgress";
import { useConfirm } from "../hooks/useConfirm";
import { extractDependencyDeleteConflict, extractLineageDeleteConflict } from "../utils/taskDelete";
@@ -371,6 +372,7 @@ export function ListView({
workflowOptions,
selectedWorkflow,
selectedWorkflowId,
isAllWorkflowsSelected,
setSelectedWorkflowId,
refreshBoardWorkflows,
setBoardWorkflowsState,
@@ -607,8 +609,28 @@ export function ListView({
const listColumns = useMemo<BoardWorkflowColumn[]>(() => {
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<ColumnId, BoardWorkflowColumn>();
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<ColumnId, string>();
@@ -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<string>();
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<ListColumn, string> = {
@@ -2033,9 +2071,10 @@ export function ListView({
<div className="list-workflow-control">
<WorkflowSwitcher
workflows={workflowOptions}
value={selectedWorkflow.id}
value={isAllWorkflowsSelected ? ALL_WORKFLOWS_BOARD_VIEW_ID : selectedWorkflow.id}
onChange={setSelectedWorkflowId}
counts={workflowStatusCounts}
aggregateOption={{ id: ALL_WORKFLOWS_BOARD_VIEW_ID, name: "All workflows" }}
onOpen={refreshBoardWorkflows}
label={t("listView.workflowLabel", "Workflow")}
onEditWorkflow={onOpenWorkflowEditor}
@@ -2366,7 +2405,7 @@ export function ListView({
onSubtaskBreakdown={onSubtaskBreakdown}
workflowId={listQuickEntryWorkflowId}
workflowOptions={workflowMode ? workflowOptions : undefined}
defaultWorkflowId={workflowMode ? selectedWorkflow?.id ?? boardWorkflows?.defaultWorkflowId ?? null : undefined}
defaultWorkflowId={workflowMode ? createTargetWorkflowId ?? boardWorkflows?.defaultWorkflowId ?? null : undefined}
projectId={projectId}
autoExpand={false}
defaultExpanded={false}

View File

@@ -1,4 +1,4 @@
import { HeaderWorkflowSwitcherSlot } from "./HeaderWorkflowSwitcherSlot";
import { HeaderWorkflowSwitcherSlot, type HeaderWorkflowSelection } from "./HeaderWorkflowSwitcherSlot";
/*
FNXC:PlanningWorkflowSwitcher 2026-06-25-00:00:
@@ -7,8 +7,9 @@ Planning keeps this compatibility wrapper while the neutral HeaderWorkflowSwitch
interface PlanningWorkflowSwitcherSlotProps {
projectId?: string;
onOpenWorkflowEditor?: () => void;
onOpenWorkflowEditor?: (workflowId?: string) => void;
onCreateWorkflow?: () => void;
onWorkflowSelectionChange?: (selection: HeaderWorkflowSelection | null) => void;
}
export function PlanningWorkflowSwitcherSlot(props: PlanningWorkflowSwitcherSlotProps) {

View File

@@ -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(<GraphWorkflowSwitcherSlot projectId="project-graph-all" onWorkflowSelectionChange={onWorkflowSelectionChange} onOpenWorkflowEditor={onOpenWorkflowEditor} />);
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();

View File

@@ -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(
<HeaderWorkflowSwitcherSlot
projectId="project-header-all"
onWorkflowSelectionChange={onWorkflowSelectionChange}
onOpenWorkflowEditor={onOpenWorkflowEditor}
/>,
);
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(<HeaderWorkflowSwitcherSlot projectId="project-header-edit" onOpenWorkflowEditor={onOpenWorkflowEditor} />);

View File

@@ -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();

View File

@@ -175,6 +175,7 @@ export function MainContent({
_WorkflowEditorView,
}: MainContentProps) {
const [missionWorkflowId, setMissionWorkflowId] = useState<string | null>(null);
const [planningHeaderWorkflowId, setPlanningHeaderWorkflowId] = useState<string | null>(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.
*/}
<HeaderWorkflowSwitcherSlot
projectId={currentProject?.id}
onOpenWorkflowEditor={openWorkflowEditorWithNav}
onWorkflowSelectionChange={(selection) => setMissionWorkflowId(selection?.selectedWorkflow.id ?? null)}
onWorkflowSelectionChange={(selection) => setMissionWorkflowId(selection && !selection.isAllWorkflowsSelected ? selection.selectedWorkflow.id : null)}
/>
<MissionManager
isInline={true}
@@ -584,8 +588,15 @@ export function MainContent({
{/*
FNXC:Navigation 2026-06-22-00:00:
Planning shows the same board WorkflowSwitcher in the same Header workflow slot as Board/List (portaled by PlanningWorkflowSwitcherSlot), so workflow selection is reachable from the left-sidebar Planning destination.
FNXC:WorkflowAggregation 2026-07-01-00:00:
The Planning header selector may choose All workflows for aggregate browsing, but embedded PlanningModeModal receives a real workflow id from the header or `null` default behavior; explicit modalManager workflow entry points still win.
*/}
<PlanningWorkflowSwitcherSlot projectId={currentProject?.id} onOpenWorkflowEditor={openWorkflowEditorWithNav} />
<PlanningWorkflowSwitcherSlot
projectId={currentProject?.id}
onOpenWorkflowEditor={openWorkflowEditorWithNav}
onWorkflowSelectionChange={(selection) => setPlanningHeaderWorkflowId(selection && !selection.isAllWorkflowsSelected ? selection.selectedWorkflow.id : null)}
/>
<PlanningModeModal
isOpen={true}
onClose={closePlanningView}
@@ -594,7 +605,7 @@ export function MainContent({
tasks={tasks}
initialPlan={modalManager.planningInitialPlan ?? undefined}
projectId={currentProject?.id}
workflowId={modalManager.planningWorkflowId}
workflowId={modalManager.planningWorkflowId ?? planningHeaderWorkflowId}
resumeSessionId={modalManager.planningResumeSessionId}
presentation="embedded"
/>

View File

@@ -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);
}

View File

@@ -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<SetStateAction<string | null>>;
/** 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<string | null>(() => readBoardWorkflowSelection(projectId));
const [selectedWorkflowId, setSelectedWorkflowIdState] = useState<string | null>(() => readBoardWorkflowViewSelection(projectId));
const storedSelectionRef = useRef<string | null>(selectedWorkflowId);
const setSelectedWorkflowId = useCallback<Dispatch<SetStateAction<string | null>>>((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<BoardWorkflowDefinition | null>(() => {
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,

View File

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