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. - 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. - 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-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-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 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-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: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-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. --> <!-- 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`. - 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 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. - 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. - 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. --> <!-- 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" /> <div id="header-workflow-slot" data-testid="header-workflow-slot" />
<HeaderWorkflowSwitcherSlot projectId={projectId} onWorkflowSelectionChange={setHeaderSelection} /> <HeaderWorkflowSwitcherSlot projectId={projectId} onWorkflowSelectionChange={setHeaderSelection} />
<GraphWorkflowSwitcherSlot projectId={projectId} onWorkflowSelectionChange={setGraphSelection} /> <GraphWorkflowSwitcherSlot projectId={projectId} onWorkflowSelectionChange={setGraphSelection} />
<output data-testid="header-selection">{headerSelection?.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?.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"> <ul data-testid="graph-tasks">
{graphTasks.map((task) => ( {graphTasks.map((task) => (
<li key={task.id} data-testid={`graph-task-${task.id}`}>{task.title}</li> <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); localStorage.setItem("kb:project-cross:kb-dashboard-board-workflow-selection", ALL_WORKFLOWS_BOARD_VIEW_ID);
render(<CrossSurfaceHarness />); render(<CrossSurfaceHarness />);
expect(await screen.findAllByTestId("workflow-switcher")).toHaveLength(2); const switchers = await screen.findAllByTestId("workflow-switcher");
expect(switchers).toHaveLength(2);
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId("header-selection")).toHaveTextContent(DEFAULT_WORKFLOW.id); expect(screen.getByTestId("header-selection")).toHaveTextContent(ALL_WORKFLOWS_BOARD_VIEW_ID);
expect(screen.getByTestId("graph-selection")).toHaveTextContent(DEFAULT_WORKFLOW.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); 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 { createPortal } from "react-dom";
import type { BoardWorkflowDefinition, BoardWorkflowsPayload } from "../api"; import type { BoardWorkflowDefinition, BoardWorkflowsPayload } from "../api";
import { useBoardWorkflows } from "../hooks/useBoardWorkflows"; import { useBoardWorkflows } from "../hooks/useBoardWorkflows";
import { ALL_WORKFLOWS_BOARD_VIEW_ID } from "../utils/boardWorkflowSelection";
import { useViewportMode } from "../hooks/useViewportMode"; import { useViewportMode } from "../hooks/useViewportMode";
import { WorkflowSwitcher } from "./WorkflowSwitcher"; import { WorkflowSwitcher } from "./WorkflowSwitcher";
import type { WorkflowStatusCounts } from "./workflowStatusCounts"; import type { WorkflowStatusCounts } from "./workflowStatusCounts";
@@ -9,6 +10,7 @@ import type { WorkflowStatusCounts } from "./workflowStatusCounts";
export interface GraphWorkflowSelection { export interface GraphWorkflowSelection {
boardWorkflows: BoardWorkflowsPayload; boardWorkflows: BoardWorkflowsPayload;
selectedWorkflow: BoardWorkflowDefinition; selectedWorkflow: BoardWorkflowDefinition;
isAllWorkflowsSelected: boolean;
} }
interface GraphWorkflowSwitcherSlotProps { interface GraphWorkflowSwitcherSlotProps {
@@ -29,7 +31,7 @@ export function filterTasksByGraphWorkflowSelection<T extends { id: string }>(
projectId: string | undefined, projectId: string | undefined,
selection: GraphWorkflowSelection | null, selection: GraphWorkflowSelection | null,
): T[] { ): T[] {
if (!projectId || !selection) return tasks; if (!projectId || !selection || selection.isAllWorkflowsSelected) return tasks;
const workflowIds = new Set(selection.boardWorkflows.workflows.map((workflow) => workflow.id)); const workflowIds = new Set(selection.boardWorkflows.workflows.map((workflow) => workflow.id));
return tasks.filter((task) => { return tasks.filter((task) => {
const rawAssignedWorkflowId = selection.boardWorkflows.taskWorkflowIds[task.id]; const rawAssignedWorkflowId = selection.boardWorkflows.taskWorkflowIds[task.id];
@@ -55,6 +57,7 @@ export function GraphWorkflowSwitcherSlot({
workflowMode, workflowMode,
workflowOptions, workflowOptions,
selectedWorkflow, selectedWorkflow,
isAllWorkflowsSelected,
setSelectedWorkflowId, setSelectedWorkflowId,
refreshBoardWorkflows, refreshBoardWorkflows,
} = useBoardWorkflows({ projectId }); } = useBoardWorkflows({ projectId });
@@ -87,8 +90,8 @@ export function GraphWorkflowSwitcherSlot({
const selection = useMemo<GraphWorkflowSelection | null>(() => { const selection = useMemo<GraphWorkflowSelection | null>(() => {
if (!workflowMode || !boardWorkflows || !selectedWorkflow) return null; if (!workflowMode || !boardWorkflows || !selectedWorkflow) return null;
return { boardWorkflows, selectedWorkflow }; return { boardWorkflows, selectedWorkflow, isAllWorkflowsSelected };
}, [boardWorkflows, selectedWorkflow, workflowMode]); }, [boardWorkflows, isAllWorkflowsSelected, selectedWorkflow, workflowMode]);
useEffect(() => { useEffect(() => {
onWorkflowSelectionChange?.(selection); onWorkflowSelectionChange?.(selection);
@@ -107,9 +110,10 @@ export function GraphWorkflowSwitcherSlot({
<div className="board-workflow-selector"> <div className="board-workflow-selector">
<WorkflowSwitcher <WorkflowSwitcher
workflows={workflowOptions} workflows={workflowOptions}
value={selectedWorkflow.id} value={isAllWorkflowsSelected ? ALL_WORKFLOWS_BOARD_VIEW_ID : selectedWorkflow.id}
onChange={setSelectedWorkflowId} onChange={setSelectedWorkflowId}
counts={EMPTY_COUNTS} counts={EMPTY_COUNTS}
aggregateOption={{ id: ALL_WORKFLOWS_BOARD_VIEW_ID, name: "All workflows" }}
onOpen={refreshBoardWorkflows} onOpen={refreshBoardWorkflows}
onEditWorkflow={onOpenWorkflowEditor} onEditWorkflow={onOpenWorkflowEditor}
onCreateWorkflow={onCreateWorkflow} onCreateWorkflow={onCreateWorkflow}

View File

@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import type { BoardWorkflowDefinition, BoardWorkflowsPayload } from "../api"; import type { BoardWorkflowDefinition, BoardWorkflowsPayload } from "../api";
import { useBoardWorkflows } from "../hooks/useBoardWorkflows"; import { useBoardWorkflows } from "../hooks/useBoardWorkflows";
import { ALL_WORKFLOWS_BOARD_VIEW_ID } from "../utils/boardWorkflowSelection";
import { useViewportMode } from "../hooks/useViewportMode"; import { useViewportMode } from "../hooks/useViewportMode";
import { WorkflowSwitcher } from "./WorkflowSwitcher"; import { WorkflowSwitcher } from "./WorkflowSwitcher";
import type { WorkflowStatusCounts } from "./workflowStatusCounts"; import type { WorkflowStatusCounts } from "./workflowStatusCounts";
@@ -9,6 +10,7 @@ import type { WorkflowStatusCounts } from "./workflowStatusCounts";
export interface HeaderWorkflowSelection { export interface HeaderWorkflowSelection {
boardWorkflows: BoardWorkflowsPayload; boardWorkflows: BoardWorkflowsPayload;
selectedWorkflow: BoardWorkflowDefinition; selectedWorkflow: BoardWorkflowDefinition;
isAllWorkflowsSelected: boolean;
} }
interface HeaderWorkflowSwitcherSlotProps { interface HeaderWorkflowSwitcherSlotProps {
@@ -37,6 +39,7 @@ export function HeaderWorkflowSwitcherSlot({
workflowMode, workflowMode,
workflowOptions, workflowOptions,
selectedWorkflow, selectedWorkflow,
isAllWorkflowsSelected,
setSelectedWorkflowId, setSelectedWorkflowId,
refreshBoardWorkflows, refreshBoardWorkflows,
} = useBoardWorkflows({ projectId }); } = useBoardWorkflows({ projectId });
@@ -69,8 +72,8 @@ export function HeaderWorkflowSwitcherSlot({
const selection = useMemo<HeaderWorkflowSelection | null>(() => { const selection = useMemo<HeaderWorkflowSelection | null>(() => {
if (!workflowMode || !boardWorkflows || !selectedWorkflow) return null; if (!workflowMode || !boardWorkflows || !selectedWorkflow) return null;
return { boardWorkflows, selectedWorkflow }; return { boardWorkflows, selectedWorkflow, isAllWorkflowsSelected };
}, [boardWorkflows, selectedWorkflow, workflowMode]); }, [boardWorkflows, isAllWorkflowsSelected, selectedWorkflow, workflowMode]);
useEffect(() => { useEffect(() => {
onWorkflowSelectionChange?.(selection); onWorkflowSelectionChange?.(selection);
@@ -89,9 +92,10 @@ export function HeaderWorkflowSwitcherSlot({
<div className="board-workflow-selector"> <div className="board-workflow-selector">
<WorkflowSwitcher <WorkflowSwitcher
workflows={workflowOptions} workflows={workflowOptions}
value={selectedWorkflow.id} value={isAllWorkflowsSelected ? ALL_WORKFLOWS_BOARD_VIEW_ID : selectedWorkflow.id}
onChange={setSelectedWorkflowId} onChange={setSelectedWorkflowId}
counts={EMPTY_COUNTS} counts={EMPTY_COUNTS}
aggregateOption={{ id: ALL_WORKFLOWS_BOARD_VIEW_ID, name: "All workflows" }}
onOpen={refreshBoardWorkflows} onOpen={refreshBoardWorkflows}
onEditWorkflow={onOpenWorkflowEditor} onEditWorkflow={onOpenWorkflowEditor}
onCreateWorkflow={onCreateWorkflow} onCreateWorkflow={onCreateWorkflow}

View File

@@ -20,6 +20,7 @@ import { isTaskStuck } from "../utils/taskStuck";
import type { ToastType } from "../hooks/useToast"; import type { ToastType } from "../hooks/useToast";
import { useViewportMode } from "../hooks/useViewportMode"; import { useViewportMode } from "../hooks/useViewportMode";
import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/projectStorage"; import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/projectStorage";
import { ALL_WORKFLOWS_BOARD_VIEW_ID } from "../utils/boardWorkflowSelection";
import { getUnifiedTaskProgress } from "../utils/taskProgress"; import { getUnifiedTaskProgress } from "../utils/taskProgress";
import { useConfirm } from "../hooks/useConfirm"; import { useConfirm } from "../hooks/useConfirm";
import { extractDependencyDeleteConflict, extractLineageDeleteConflict } from "../utils/taskDelete"; import { extractDependencyDeleteConflict, extractLineageDeleteConflict } from "../utils/taskDelete";
@@ -371,6 +372,7 @@ export function ListView({
workflowOptions, workflowOptions,
selectedWorkflow, selectedWorkflow,
selectedWorkflowId, selectedWorkflowId,
isAllWorkflowsSelected,
setSelectedWorkflowId, setSelectedWorkflowId,
refreshBoardWorkflows, refreshBoardWorkflows,
setBoardWorkflowsState, setBoardWorkflowsState,
@@ -607,8 +609,28 @@ export function ListView({
const listColumns = useMemo<BoardWorkflowColumn[]>(() => { const listColumns = useMemo<BoardWorkflowColumn[]>(() => {
if (!workflowMode || !selectedWorkflow) return LEGACY_LIST_COLUMNS; if (!workflowMode || !selectedWorkflow) return LEGACY_LIST_COLUMNS;
return selectedWorkflow.columns.filter((column) => !column.flags.hiddenFromBoard); if (!isAllWorkflowsSelected || !boardWorkflows) {
}, [selectedWorkflow, workflowMode]); 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 columnNameById = useMemo(() => {
const map = new Map<ColumnId, string>(); const map = new Map<ColumnId, string>();
@@ -644,25 +666,41 @@ export function ListView({
}, [columnFlagsById, workflowMode]); }, [columnFlagsById, workflowMode]);
const selectedWorkflowTaskIds = useMemo(() => { const selectedWorkflowTaskIds = useMemo(() => {
if (!workflowMode || !boardWorkflows || !selectedWorkflow) return null; if (!workflowMode || !boardWorkflows || !selectedWorkflow || isAllWorkflowsSelected) return null;
const ids = new Set<string>(); const ids = new Set<string>();
const workflowIds = new Set(boardWorkflows.workflows.map((workflow) => workflow.id));
for (const task of tasks) { 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); if (workflowId === selectedWorkflow.id) ids.add(task.id);
} }
return ids; return ids;
}, [boardWorkflows, selectedWorkflow, tasks, workflowMode]); }, [boardWorkflows, isAllWorkflowsSelected, selectedWorkflow, tasks, workflowMode]);
const workflowStatusCounts = useMemo( const workflowStatusCounts = useMemo(
() => computeWorkflowStatusCounts(tasks, boardWorkflows), () => computeWorkflowStatusCounts(tasks, boardWorkflows),
[boardWorkflows, tasks], [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(() => { 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) const target = listColumns.find((column) => column.flags.intake && !column.flags.archived)
?? listColumns.find((column) => !column.flags.archived); ?? listColumns.find((column) => !column.flags.archived);
return target?.id; return target?.id;
}, [listColumns]); }, [boardWorkflows, createTargetWorkflowId, listColumns, workflowMode]);
/** /**
* FNXC:WorkflowList 2026-06-21-21:37: * FNXC:WorkflowList 2026-06-21-21:37:
@@ -698,8 +736,8 @@ export function ListView({
const handleListQuickCreate = useCallback(async (input: TaskCreateInput) => { const handleListQuickCreate = useCallback(async (input: TaskCreateInput) => {
const create = onQuickCreate ?? (async () => addToast(t("listView.taskCreationUnavailable", "Task creation not available"), "error")); const create = onQuickCreate ?? (async () => addToast(t("listView.taskCreationUnavailable", "Task creation not available"), "error"));
if (workflowMode && selectedWorkflow && createTargetColumn) { if (workflowMode && createTargetWorkflowId && createTargetColumn) {
const workflowId = typeof input.workflowId === "string" ? input.workflowId : selectedWorkflow.id; const workflowId = typeof input.workflowId === "string" && input.workflowId !== ALL_WORKFLOWS_BOARD_VIEW_ID ? input.workflowId : createTargetWorkflowId;
const targetColumn = resolveListQuickCreateTarget(workflowId, input.column) ?? createTargetColumn; const targetColumn = resolveListQuickCreateTarget(workflowId, input.column) ?? createTargetColumn;
const created = await create({ const created = await create({
...input, ...input,
@@ -714,13 +752,13 @@ export function ListView({
return created; return created;
} }
return create(input); 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: 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. 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 // Column display labels
const COLUMN_LABELS_MAP: Record<ListColumn, string> = { const COLUMN_LABELS_MAP: Record<ListColumn, string> = {
@@ -2033,9 +2071,10 @@ export function ListView({
<div className="list-workflow-control"> <div className="list-workflow-control">
<WorkflowSwitcher <WorkflowSwitcher
workflows={workflowOptions} workflows={workflowOptions}
value={selectedWorkflow.id} value={isAllWorkflowsSelected ? ALL_WORKFLOWS_BOARD_VIEW_ID : selectedWorkflow.id}
onChange={setSelectedWorkflowId} onChange={setSelectedWorkflowId}
counts={workflowStatusCounts} counts={workflowStatusCounts}
aggregateOption={{ id: ALL_WORKFLOWS_BOARD_VIEW_ID, name: "All workflows" }}
onOpen={refreshBoardWorkflows} onOpen={refreshBoardWorkflows}
label={t("listView.workflowLabel", "Workflow")} label={t("listView.workflowLabel", "Workflow")}
onEditWorkflow={onOpenWorkflowEditor} onEditWorkflow={onOpenWorkflowEditor}
@@ -2366,7 +2405,7 @@ export function ListView({
onSubtaskBreakdown={onSubtaskBreakdown} onSubtaskBreakdown={onSubtaskBreakdown}
workflowId={listQuickEntryWorkflowId} workflowId={listQuickEntryWorkflowId}
workflowOptions={workflowMode ? workflowOptions : undefined} workflowOptions={workflowMode ? workflowOptions : undefined}
defaultWorkflowId={workflowMode ? selectedWorkflow?.id ?? boardWorkflows?.defaultWorkflowId ?? null : undefined} defaultWorkflowId={workflowMode ? createTargetWorkflowId ?? boardWorkflows?.defaultWorkflowId ?? null : undefined}
projectId={projectId} projectId={projectId}
autoExpand={false} autoExpand={false}
defaultExpanded={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: FNXC:PlanningWorkflowSwitcher 2026-06-25-00:00:
@@ -7,8 +7,9 @@ Planning keeps this compatibility wrapper while the neutral HeaderWorkflowSwitch
interface PlanningWorkflowSwitcherSlotProps { interface PlanningWorkflowSwitcherSlotProps {
projectId?: string; projectId?: string;
onOpenWorkflowEditor?: () => void; onOpenWorkflowEditor?: (workflowId?: string) => void;
onCreateWorkflow?: () => void; onCreateWorkflow?: () => void;
onWorkflowSelectionChange?: (selection: HeaderWorkflowSelection | null) => void;
} }
export function PlanningWorkflowSwitcherSlot(props: PlanningWorkflowSwitcherSlotProps) { export function PlanningWorkflowSwitcherSlot(props: PlanningWorkflowSwitcherSlotProps) {

View File

@@ -1,6 +1,7 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { BoardWorkflowDefinition, BoardWorkflowsPayload } from "../../api"; import type { BoardWorkflowDefinition, BoardWorkflowsPayload } from "../../api";
import { ALL_WORKFLOWS_BOARD_VIEW_ID } from "../../utils/boardWorkflowSelection";
import { filterTasksByGraphWorkflowSelection, GraphWorkflowSwitcherSlot, type GraphWorkflowSelection } from "../GraphWorkflowSwitcherSlot"; import { filterTasksByGraphWorkflowSelection, GraphWorkflowSwitcherSlot, type GraphWorkflowSelection } from "../GraphWorkflowSwitcherSlot";
const fetchBoardWorkflowsMock = vi.fn(); const fetchBoardWorkflowsMock = vi.fn();
@@ -74,6 +75,7 @@ describe("filterTasksByGraphWorkflowSelection", () => {
}, },
}), }),
selectedWorkflow: DEFAULT_WORKFLOW, selectedWorkflow: DEFAULT_WORKFLOW,
isAllWorkflowsSelected: false,
}; };
expect(filterTasksByGraphWorkflowSelection(tasks, "project-graph", selection).map((task) => task.id)).toEqual([ 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", () => { it("preserves unfiltered graph tasks without a project or workflow payload", () => {
const tasks = [{ id: "FN-a" }, { id: "FN-b" }]; const tasks = [{ id: "FN-a" }, { id: "FN-b" }];
const selection: GraphWorkflowSelection = { const selection: GraphWorkflowSelection = {
boardWorkflows: workflowPayload({ taskWorkflowIds: { "FN-b": CUSTOM_WORKFLOW.id } }), boardWorkflows: workflowPayload({ taskWorkflowIds: { "FN-b": CUSTOM_WORKFLOW.id } }),
selectedWorkflow: CUSTOM_WORKFLOW, selectedWorkflow: CUSTOM_WORKFLOW,
isAllWorkflowsSelected: false,
}; };
expect(filterTasksByGraphWorkflowSelection(tasks, undefined, selection)).toBe(tasks); expect(filterTasksByGraphWorkflowSelection(tasks, undefined, selection)).toBe(tasks);
@@ -112,6 +126,7 @@ describe("GraphWorkflowSwitcherSlot", () => {
expect(onWorkflowSelectionChange).toHaveBeenLastCalledWith({ expect(onWorkflowSelectionChange).toHaveBeenLastCalledWith({
boardWorkflows: workflowPayload(), boardWorkflows: workflowPayload(),
selectedWorkflow: DEFAULT_WORKFLOW, 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 () => { it("forwards dropdown edit workflow ids to the graph editor launcher", async () => {
appendHeaderWorkflowSlot(); appendHeaderWorkflowSlot();
const onOpenWorkflowEditor = vi.fn(); 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { BoardWorkflowDefinition, BoardWorkflowsPayload } from "../../api"; import type { BoardWorkflowDefinition, BoardWorkflowsPayload } from "../../api";
import { HeaderWorkflowSwitcherSlot, type HeaderWorkflowSelection } from "../HeaderWorkflowSwitcherSlot"; import { HeaderWorkflowSwitcherSlot, type HeaderWorkflowSelection } from "../HeaderWorkflowSwitcherSlot";
import { ALL_WORKFLOWS_BOARD_VIEW_ID } from "../../utils/boardWorkflowSelection";
import { PlanningWorkflowSwitcherSlot } from "../PlanningWorkflowSwitcherSlot"; import { PlanningWorkflowSwitcherSlot } from "../PlanningWorkflowSwitcherSlot";
const fetchBoardWorkflowsMock = vi.fn(); 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); 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 () => { it("forwards dropdown edit workflow ids from the shared header slot", async () => {
const onOpenWorkflowEditor = vi.fn(); const onOpenWorkflowEditor = vi.fn();
renderWithHeader(<HeaderWorkflowSwitcherSlot projectId="project-header-edit" onOpenWorkflowEditor={onOpenWorkflowEditor} />); 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 { ListView } from "../ListView";
import type { Task, TaskDetail } from "@fusion/core"; import type { Task, TaskDetail } from "@fusion/core";
import { scopedKey } from "../../utils/projectStorage"; 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"; import { loadAllAppCss } from "../../test/cssFixture";
// Mock the API // Mock the API
@@ -1143,6 +1143,45 @@ describe("ListView", () => {
mobileSpy.mockRestore(); 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 () => { it("shows workflow edit and New actions inside the dropdown", async () => {
const onCreateWorkflow = vi.fn(); const onCreateWorkflow = vi.fn();
const onOpenWorkflowEditor = vi.fn(); const onOpenWorkflowEditor = vi.fn();

View File

@@ -175,6 +175,7 @@ export function MainContent({
_WorkflowEditorView, _WorkflowEditorView,
}: MainContentProps) { }: MainContentProps) {
const [missionWorkflowId, setMissionWorkflowId] = useState<string | null>(null); const [missionWorkflowId, setMissionWorkflowId] = useState<string | null>(null);
const [planningHeaderWorkflowId, setPlanningHeaderWorkflowId] = useState<string | null>(null);
if (showBackendConnectionErrorPage) { if (showBackendConnectionErrorPage) {
return ( return (
@@ -364,11 +365,14 @@ export function MainContent({
{/* {/*
FNXC:MissionWorkflows 2026-06-25-00:00: 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. 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 <HeaderWorkflowSwitcherSlot
projectId={currentProject?.id} projectId={currentProject?.id}
onOpenWorkflowEditor={openWorkflowEditorWithNav} onOpenWorkflowEditor={openWorkflowEditorWithNav}
onWorkflowSelectionChange={(selection) => setMissionWorkflowId(selection?.selectedWorkflow.id ?? null)} onWorkflowSelectionChange={(selection) => setMissionWorkflowId(selection && !selection.isAllWorkflowsSelected ? selection.selectedWorkflow.id : null)}
/> />
<MissionManager <MissionManager
isInline={true} isInline={true}
@@ -584,8 +588,15 @@ export function MainContent({
{/* {/*
FNXC:Navigation 2026-06-22-00:00: 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. 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 <PlanningModeModal
isOpen={true} isOpen={true}
onClose={closePlanningView} onClose={closePlanningView}
@@ -594,7 +605,7 @@ export function MainContent({
tasks={tasks} tasks={tasks}
initialPlan={modalManager.planningInitialPlan ?? undefined} initialPlan={modalManager.planningInitialPlan ?? undefined}
projectId={currentProject?.id} projectId={currentProject?.id}
workflowId={modalManager.planningWorkflowId} workflowId={modalManager.planningWorkflowId ?? planningHeaderWorkflowId}
resumeSessionId={modalManager.planningResumeSessionId} resumeSessionId={modalManager.planningResumeSessionId}
presentation="embedded" presentation="embedded"
/> />

View File

@@ -1,5 +1,6 @@
import type { Task } from "@fusion/core"; import type { Task } from "@fusion/core";
import type { BoardWorkflowColumn, BoardWorkflowsPayload } from "../api"; import type { BoardWorkflowColumn, BoardWorkflowsPayload } from "../api";
import { ALL_WORKFLOWS_BOARD_VIEW_ID } from "../utils/boardWorkflowSelection";
export interface WorkflowStatusCounts { export interface WorkflowStatusCounts {
todo: number; 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; if (!tasks?.length) return countsByWorkflow;
for (const task of tasks) { for (const task of tasks) {
@@ -93,12 +96,14 @@ export function computeWorkflowStatusCounts(
const counts = countsByWorkflow.get(workflow.id) ?? EMPTY_COUNTS(); const counts = countsByWorkflow.get(workflow.id) ?? EMPTY_COUNTS();
counts[bucket] += 1; counts[bucket] += 1;
aggregateCounts[bucket] += 1;
if (MERGING_STATUSES.has(task.status ?? "")) { if (MERGING_STATUSES.has(task.status ?? "")) {
/* /*
FNXC:WorkflowSwitcher 2026-06-22-20:30: 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. 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; counts.merging += 1;
aggregateCounts.merging += 1;
} }
countsByWorkflow.set(workflow.id, counts); countsByWorkflow.set(workflow.id, counts);
} }

View File

@@ -10,7 +10,8 @@ import {
writeBoardWorkflowsCache as defaultWriteBoardWorkflowsCache, writeBoardWorkflowsCache as defaultWriteBoardWorkflowsCache,
} from "../utils/boardWorkflowsCache"; } from "../utils/boardWorkflowsCache";
import { import {
readBoardWorkflowSelection, ALL_WORKFLOWS_BOARD_VIEW_ID,
readBoardWorkflowViewSelection,
removeBoardWorkflowSelection, removeBoardWorkflowSelection,
writeBoardWorkflowSelection, writeBoardWorkflowSelection,
} from "../utils/boardWorkflowSelection"; } from "../utils/boardWorkflowSelection";
@@ -45,9 +46,11 @@ export interface UseBoardWorkflowsResult {
workflowMode: boolean; workflowMode: boolean;
/** Workflows sorted with the default first, then alphabetical. Empty unless in workflow mode. */ /** Workflows sorted with the default first, then alphabetical. Empty unless in workflow mode. */
workflowOptions: BoardWorkflowDefinition[]; 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; selectedWorkflow: BoardWorkflowDefinition | null;
selectedWorkflowId: string | null; selectedWorkflowId: string | null;
/** True when the dashboard-only aggregate workflow view is selected. */
isAllWorkflowsSelected: boolean;
setSelectedWorkflowId: Dispatch<SetStateAction<string | null>>; setSelectedWorkflowId: Dispatch<SetStateAction<string | null>>;
/** Force a fresh fetch (used on switcher open, since task assignment changes emit no workflow SSE). */ /** Force a fresh fetch (used on switcher open, since task assignment changes emit no workflow SSE). */
refreshBoardWorkflows: () => void; refreshBoardWorkflows: () => void;
@@ -73,7 +76,7 @@ export function useBoardWorkflows(params: UseBoardWorkflowsParams): UseBoardWork
return cached ? { projectId, payload: cached } : null; return cached ? { projectId, payload: cached } : null;
}); });
const boardWorkflows = boardWorkflowsState?.projectId === projectId && boardWorkflowsState ? boardWorkflowsState.payload : 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 storedSelectionRef = useRef<string | null>(selectedWorkflowId);
const setSelectedWorkflowId = useCallback<Dispatch<SetStateAction<string | null>>>((nextSelection) => { 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). // Re-hydrate from the per-project cache on project change (and gate change).
useEffect(() => { useEffect(() => {
const cached = shouldHydrateCache ? readBoardWorkflowsCache(projectId) : null; const cached = shouldHydrateCache ? readBoardWorkflowsCache(projectId) : null;
const storedSelection = readBoardWorkflowSelection(projectId); const storedSelection = readBoardWorkflowViewSelection(projectId);
storedSelectionRef.current = storedSelection; storedSelectionRef.current = storedSelection;
setSelectedWorkflowIdState(storedSelection); setSelectedWorkflowIdState(storedSelection);
setBoardWorkflowsState(cached ? { projectId, payload: cached } : null); setBoardWorkflowsState(cached ? { projectId, payload: cached } : null);
@@ -153,6 +156,8 @@ export function useBoardWorkflows(params: UseBoardWorkflowsParams): UseBoardWork
}); });
}, [boardWorkflows, workflowMode]); }, [boardWorkflows, workflowMode]);
const isAllWorkflowsSelected = selectedWorkflowId === ALL_WORKFLOWS_BOARD_VIEW_ID;
const selectedWorkflow = useMemo<BoardWorkflowDefinition | null>(() => { const selectedWorkflow = useMemo<BoardWorkflowDefinition | null>(() => {
if (!workflowMode) return null; if (!workflowMode) return null;
return workflowOptions.find((workflow) => workflow.id === selectedWorkflowId) return workflowOptions.find((workflow) => workflow.id === selectedWorkflowId)
@@ -184,6 +189,10 @@ export function useBoardWorkflows(params: UseBoardWorkflowsParams): UseBoardWork
return; return;
} }
if (isAllWorkflowsSelected) {
return;
}
if (selectedWorkflow && selectedWorkflow.id !== selectedWorkflowId) { if (selectedWorkflow && selectedWorkflow.id !== selectedWorkflowId) {
const shouldRepairStoredSelection = storedSelectionRef.current !== null; const shouldRepairStoredSelection = storedSelectionRef.current !== null;
setSelectedWorkflowIdState(selectedWorkflow.id); setSelectedWorkflowIdState(selectedWorkflow.id);
@@ -192,7 +201,7 @@ export function useBoardWorkflows(params: UseBoardWorkflowsParams): UseBoardWork
storedSelectionRef.current = selectedWorkflow.id; storedSelectionRef.current = selectedWorkflow.id;
} }
} }
}, [boardWorkflows, projectId, selectedWorkflow, selectedWorkflowId, workflowMode, workflowOptions.length]); }, [boardWorkflows, isAllWorkflowsSelected, projectId, selectedWorkflow, selectedWorkflowId, workflowMode, workflowOptions.length]);
return { return {
boardWorkflows, boardWorkflows,
@@ -200,6 +209,7 @@ export function useBoardWorkflows(params: UseBoardWorkflowsParams): UseBoardWork
workflowOptions, workflowOptions,
selectedWorkflow, selectedWorkflow,
selectedWorkflowId, selectedWorkflowId,
isAllWorkflowsSelected,
setSelectedWorkflowId, setSelectedWorkflowId,
refreshBoardWorkflows, refreshBoardWorkflows,
setBoardWorkflowsState, 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. * 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: * 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 { export function readBoardWorkflowViewSelection(projectId?: string): string | null {
try { try {