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