diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md
index e350c0d282..caf77ccd7e 100644
--- a/docs/dashboard-guide.md
+++ b/docs/dashboard-guide.md
@@ -77,6 +77,7 @@ Features:
- Task card header meta badges group priority, fast mode, agent-created provenance, and elapsed/created-time chips into one wrapping row; agent labels prefer `sourceMetadata.agentName` over raw agent IDs
- Column ordering semantics: `todo` mirrors scheduler pickup order (priority descending, then oldest `createdAt`, then task ID); `triage`, `in-progress`, `in-review`, and `archived` remain priority-first with task-ID tie-breaks; `done` is ordered by most recent completion first (`columnMovedAt`, then `updatedAt`, then `createdAt` fallback)
- On mobile, both default and workflow-mode boards fill the project viewport while the column strip remains the internal horizontal scroller with contained edge overscroll.
+- Board and List workflow switchers use a themed dropdown instead of a native select. The closed trigger and each workflow option show compact Todo / In Progress / Done counts derived from workflow column flags, excluding archived columns.

@@ -126,7 +127,7 @@ The workflow editor opens as a full-screen modal editor for inspecting built-ins
Navigation:
- Open a task or board surface that shows the workflow selector, then choose **Manage…**.
-- From the board workflow toolbar, use the edit workflow button beside the selector to open the currently selected workflow directly when one is selected.
+- From the board workflow toolbar, use the edit workflow button beside the selector to open the currently selected workflow directly when one is selected. The board/list workflow dropdown also previews each workflow's Todo / In Progress / Done task counts inline before switching.
- Use the global **Workflow** / **Workflows** entry point from desktop header, compact header overflow, or mobile **More** navigation to browse definitions.
- From Settings moved-setting stubs, choose **Open workflow settings** to jump to the default workflow's settings values.
diff --git a/packages/dashboard/app/components/Board.tsx b/packages/dashboard/app/components/Board.tsx
index 7430d23246..0fb9e53099 100644
--- a/packages/dashboard/app/components/Board.tsx
+++ b/packages/dashboard/app/components/Board.tsx
@@ -12,6 +12,8 @@ import { MOBILE_MEDIA_QUERY } from "../hooks/useViewportMode";
import { recordResumeEvent } from "../utils/resumeInstrumentation";
import { subscribeSse } from "../sse-bus";
import { getBoardCanDropTaskRejection } from "./boardCanDropTask";
+import { WorkflowSwitcher } from "./WorkflowSwitcher";
+import { computeWorkflowStatusCounts } from "./workflowStatusCounts";
interface BoardProps {
tasks: Task[];
@@ -409,6 +411,11 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
?? null;
}, [boardWorkflows?.defaultWorkflowId, selectedWorkflowId, workflowMode, workflowOptions]);
+ const workflowStatusCounts = useMemo(
+ () => computeWorkflowStatusCounts(tasks, boardWorkflows),
+ [boardWorkflows, tasks],
+ );
+
useEffect(() => {
if (!workflowMode) {
setSelectedWorkflowId(null);
@@ -506,21 +513,14 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
{(workflowOptions.length > 1 || onCreateWorkflow || onOpenWorkflowEditor) && (
{workflowOptions.length > 1 && (
-
- Workflow
-
+ setSelectedWorkflowId(event.target.value)}
- aria-label="Select workflow"
- >
- {workflowOptions.map((workflow) => (
-
- {workflow.name}
-
- ))}
-
-
+ onChange={setSelectedWorkflowId}
+ counts={workflowStatusCounts}
+ />
+
)}
{onOpenWorkflowEditor && (
-
Fusion
+
{t("dashboard.brandName", "Fusion")}
= {
triage: "var(--triage)",
@@ -628,6 +630,11 @@ export function ListView({
return ids;
}, [boardWorkflows, selectedWorkflow, tasks, workflowMode]);
+ const workflowStatusCounts = useMemo(
+ () => computeWorkflowStatusCounts(tasks, boardWorkflows),
+ [boardWorkflows, tasks],
+ );
+
const createTargetColumn = useMemo(() => {
const target = listColumns.find((column) => column.flags.intake && !column.flags.archived)
?? listColumns.find((column) => !column.flags.archived);
@@ -1578,22 +1585,14 @@ export function ListView({
if (!showSelect && !onCreateWorkflow) return null;
return (
- {showSelect && (
-
- {t("listView.workflowLabel", "Workflow")}
- setSelectedWorkflowId(event.target.value)}
- aria-label={t("listView.workflowSelectLabel", "Select workflow")}
- >
- {workflowOptions.map((workflow) => (
-
- {workflow.name}
-
- ))}
-
-
+ {showSelect && selectedWorkflow && (
+
)}
{onCreateWorkflow && (
void;
+ counts: Map;
+ label?: string;
+}
+
+interface DropdownPosition {
+ top: number;
+ left: number;
+ width: number;
+ maxHeight: number;
+}
+
+const ZERO_COUNTS: WorkflowStatusCounts = { todo: 0, inProgress: 0, done: 0 };
+
+function getCounts(counts: Map, workflowId: string): WorkflowStatusCounts {
+ return counts.get(workflowId) ?? ZERO_COUNTS;
+}
+
+/**
+ * FNXC:WorkflowSwitcher 2026-06-20-00:09:
+ * The board/list workflow switcher must be a fully rendered themed dropdown rather than a native select so each workflow option can include compact inline Todo, In Progress, and Done counts.
+ * The component owns only presentation and accessible dropdown behavior; all status-bucket semantics stay in computeWorkflowStatusCounts so Board and ListView cannot drift.
+ */
+export function WorkflowSwitcher({ workflows, value, onChange, counts, label: labelProp }: WorkflowSwitcherProps) {
+ const { t } = useTranslation("app");
+ const label = labelProp ?? t("workflowSwitcher.label", "Workflow");
+ const todoLabel = t("workflowSwitcher.todo", "Todo");
+ const inProgressLabel = t("workflowSwitcher.inProgress", "In Progress");
+ const doneLabel = t("workflowSwitcher.done", "Done");
+ const listboxId = useId();
+
+ const [isOpen, setIsOpen] = useState(false);
+ const [highlightedIndex, setHighlightedIndex] = useState(0);
+ const [dropdownPosition, setDropdownPosition] = useState(null);
+ const [portalRoot, setPortalRoot] = useState(null);
+
+ const containerRef = useRef(null);
+ const triggerRef = useRef(null);
+ const dropdownRef = useRef(null);
+ const listRef = useRef(null);
+
+ const selectedIndex = useMemo(() => Math.max(0, workflows.findIndex((workflow) => workflow.id === value)), [value, workflows]);
+ const selectedWorkflow = workflows[selectedIndex] ?? workflows[0] ?? null;
+ const selectedCounts = selectedWorkflow ? getCounts(counts, selectedWorkflow.id) : ZERO_COUNTS;
+
+ const updateDropdownPosition = useCallback(() => {
+ const trigger = triggerRef.current;
+ if (!trigger) return;
+ const rect = trigger.getBoundingClientRect();
+ const viewportWidth = window.visualViewport?.width ?? window.innerWidth;
+ const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
+ const offsetTop = window.visualViewport?.offsetTop ?? 0;
+ const offsetLeft = window.visualViewport?.offsetLeft ?? 0;
+ const horizontalPadding = 16;
+ const verticalPadding = 16;
+ const gap = 4;
+ const preferredHeight = Math.min(viewportHeight * 0.6, 320);
+ const triggerTop = rect.top - offsetTop;
+ const triggerBottom = rect.bottom - offsetTop;
+ const triggerLeft = rect.left - offsetLeft;
+ const spaceBelow = viewportHeight - triggerBottom;
+ const spaceAbove = triggerTop;
+ const openUpward = spaceBelow < preferredHeight && spaceAbove > spaceBelow;
+ const availableHeight = Math.max((openUpward ? spaceAbove : spaceBelow) - verticalPadding - gap, 160);
+ const maxHeight = Math.max(Math.min(availableHeight, preferredHeight), 160);
+ const width = Math.min(Math.max(rect.width, 240), viewportWidth - horizontalPadding * 2);
+ const left = Math.min(Math.max(triggerLeft, horizontalPadding), viewportWidth - horizontalPadding - width) + offsetLeft;
+ const top = openUpward
+ ? Math.max(verticalPadding + offsetTop, triggerTop - maxHeight - gap + offsetTop)
+ : Math.min(triggerBottom + gap + offsetTop, viewportHeight + offsetTop - verticalPadding - maxHeight);
+
+ setDropdownPosition({ top, left, width, maxHeight });
+ }, []);
+
+ useEffect(() => {
+ setPortalRoot(document.body);
+ }, []);
+
+ useEffect(() => {
+ if (!isOpen) return;
+ setHighlightedIndex(selectedIndex);
+ updateDropdownPosition();
+ }, [isOpen, selectedIndex, updateDropdownPosition]);
+
+ useEffect(() => {
+ if (!isOpen) return;
+ const handleReposition = () => updateDropdownPosition();
+ window.addEventListener("resize", handleReposition);
+ window.addEventListener("scroll", handleReposition, true);
+ const visualViewport = window.visualViewport;
+ visualViewport?.addEventListener("resize", handleReposition);
+ visualViewport?.addEventListener("scroll", handleReposition);
+ return () => {
+ window.removeEventListener("resize", handleReposition);
+ window.removeEventListener("scroll", handleReposition, true);
+ visualViewport?.removeEventListener("resize", handleReposition);
+ visualViewport?.removeEventListener("scroll", handleReposition);
+ };
+ }, [isOpen, updateDropdownPosition]);
+
+ useEffect(() => {
+ if (!isOpen) return;
+ const handlePointerDown = (event: MouseEvent) => {
+ const target = event.target as Node;
+ if (containerRef.current?.contains(target) || dropdownRef.current?.contains(target)) return;
+ setIsOpen(false);
+ };
+ document.addEventListener("mousedown", handlePointerDown);
+ return () => document.removeEventListener("mousedown", handlePointerDown);
+ }, [isOpen]);
+
+ useEffect(() => {
+ if (!isOpen || !listRef.current) return;
+ const highlightedElement = listRef.current.querySelector(`[data-index="${highlightedIndex}"]`);
+ if (highlightedElement && typeof highlightedElement.scrollIntoView === "function") {
+ highlightedElement.scrollIntoView({ block: "nearest" });
+ }
+ }, [highlightedIndex, isOpen]);
+
+ const selectWorkflow = useCallback((workflowId: string) => {
+ onChange(workflowId);
+ setIsOpen(false);
+ triggerRef.current?.focus();
+ }, [onChange]);
+
+ const handleKeyDown = useCallback((event: KeyboardEvent) => {
+ switch (event.key) {
+ case "ArrowDown":
+ event.preventDefault();
+ if (!isOpen) {
+ setIsOpen(true);
+ } else {
+ setHighlightedIndex((current) => (workflows.length ? (current + 1) % workflows.length : 0));
+ }
+ break;
+ case "ArrowUp":
+ event.preventDefault();
+ if (!isOpen) {
+ setIsOpen(true);
+ } else {
+ setHighlightedIndex((current) => (workflows.length ? (current - 1 + workflows.length) % workflows.length : 0));
+ }
+ break;
+ case "Enter":
+ case " ":
+ event.preventDefault();
+ if (isOpen) {
+ const workflow = workflows[highlightedIndex];
+ if (workflow) selectWorkflow(workflow.id);
+ } else {
+ setIsOpen(true);
+ }
+ break;
+ case "Escape":
+ event.preventDefault();
+ setIsOpen(false);
+ break;
+ case "Tab":
+ setIsOpen(false);
+ break;
+ }
+ }, [highlightedIndex, isOpen, selectWorkflow, workflows]);
+
+ if (!selectedWorkflow) return null;
+
+ const renderCountBadges = (workflowCounts: WorkflowStatusCounts, variant: "trigger" | "option") => (
+
+ {workflowCounts.todo}
+ ·
+ {workflowCounts.inProgress}
+ ·
+ {workflowCounts.done}
+
+ );
+
+ const renderAccessibleCounts = (workflowCounts: WorkflowStatusCounts) => (
+
+ {t("workflowSwitcher.countsAria", "{{todoLabel}}: {{todo}}, {{inProgressLabel}}: {{inProgress}}, {{doneLabel}}: {{done}}", {
+ todoLabel,
+ todo: workflowCounts.todo,
+ inProgressLabel,
+ inProgress: workflowCounts.inProgress,
+ doneLabel,
+ done: workflowCounts.done,
+ })}
+
+ );
+
+ const dropdown = isOpen && portalRoot && dropdownPosition
+ ? createPortal(
+
+
+ {workflows.map((workflow, index) => {
+ const workflowCounts = getCounts(counts, workflow.id);
+ const isSelected = workflow.id === selectedWorkflow.id;
+ const isHighlighted = index === highlightedIndex;
+ return (
+ setHighlightedIndex(index)}
+ onClick={() => selectWorkflow(workflow.id)}
+ >
+ {workflow.name}
+ {renderCountBadges(workflowCounts, "option")}
+ {renderAccessibleCounts(workflowCounts)}
+
+ );
+ })}
+
+
,
+ portalRoot,
+ )
+ : null;
+
+ return (
+
+ {label}
+ setIsOpen((open) => !open)}
+ onKeyDown={handleKeyDown}
+ >
+
+ {selectedWorkflow.name}
+ {renderCountBadges(selectedCounts, "trigger")}
+ {renderAccessibleCounts(selectedCounts)}
+
+
+
+ {dropdown}
+
+ );
+}
diff --git a/packages/dashboard/app/components/__tests__/Board.test.tsx b/packages/dashboard/app/components/__tests__/Board.test.tsx
index c18e70f2a2..7fdc22679b 100644
--- a/packages/dashboard/app/components/__tests__/Board.test.tsx
+++ b/packages/dashboard/app/components/__tests__/Board.test.tsx
@@ -138,6 +138,17 @@ function renderBoard(props = {}) {
return render( );
}
+async function openWorkflowSwitcher() {
+ const trigger = await screen.findByTestId("workflow-switcher");
+ fireEvent.click(trigger);
+ return trigger;
+}
+
+async function selectWorkflow(workflowId: string) {
+ await openWorkflowSwitcher();
+ fireEvent.click(screen.getByTestId(`workflow-switcher-option-${workflowId}`));
+}
+
describe("Board", () => {
it("renders a element with class 'board'", () => {
renderBoard();
@@ -933,9 +944,25 @@ describe("Board", () => {
const toolbar = document.querySelector(".board-workflow-toolbar");
expect(toolbar).not.toBeNull();
return Array.from(toolbar?.querySelectorAll("button") ?? [])
+ .filter((button) => button.getAttribute("data-testid") !== "workflow-switcher")
.map((button) => button.getAttribute("aria-label"));
}
+ async function openWorkflowSwitcher() {
+ const trigger = await screen.findByTestId("workflow-switcher");
+ fireEvent.click(trigger);
+ return trigger;
+ }
+
+ function workflowSwitcherOptionIds() {
+ return screen.getAllByRole("option").map((option) => option.getAttribute("data-testid")?.replace("workflow-switcher-option-", ""));
+ }
+
+ async function selectWorkflow(workflowId: string) {
+ await openWorkflowSwitcher();
+ fireEvent.click(screen.getByTestId(`workflow-switcher-option-${workflowId}`));
+ }
+
it("flag OFF renders the legacy single-lane board byte-identically", async () => {
fetchBoardWorkflowsMock.mockResolvedValue({
flagEnabled: false,
@@ -962,7 +989,7 @@ describe("Board", () => {
await waitFor(() => expect(screen.getByTestId("column-todo")).toBeDefined());
expect(JSON.parse(screen.getByTestId("column-todo").getAttribute("data-tasks") || "[]").map((task: Task) => task.id)).toEqual(["FN-1"]);
expect(JSON.parse(screen.getByTestId("column-in-progress").getAttribute("data-tasks") || "[]").map((task: Task) => task.id)).toEqual(["FN-2"]);
- expect(screen.queryByLabelText("Select workflow")).toBeNull();
+ expect(screen.queryByTestId("workflow-switcher")).toBeNull();
});
it("puts create controls on the workflow intake column instead of the first visible column", async () => {
@@ -1014,7 +1041,7 @@ describe("Board", () => {
});
await waitFor(() => expect(screen.getByTestId("column-triage")).toBeDefined());
- expect(screen.queryByLabelText("Select workflow")).toBeNull();
+ expect(screen.queryByTestId("workflow-switcher")).toBeNull();
expect(workflowToolbarActionNames()).toEqual(["Edit workflows", "New workflow"]);
fireEvent.click(screen.getByRole("button", { name: "New workflow" }));
@@ -1058,7 +1085,7 @@ describe("Board", () => {
onOpenWorkflowEditor,
});
- expect(await screen.findByLabelText("Select workflow")).toBeDefined();
+ expect(await screen.findByTestId("workflow-switcher")).toBeDefined();
expect(screen.getByRole("button", { name: "New workflow" })).toBeDefined();
expect(screen.getByRole("button", { name: "Edit workflows" })).toBeDefined();
const toolbar = document.querySelector(".board-workflow-toolbar");
@@ -1085,8 +1112,12 @@ describe("Board", () => {
onCreateWorkflow,
onOpenWorkflowEditor,
});
- const selector = await screen.findByLabelText("Select workflow") as HTMLSelectElement;
- expect(selector.value).toBe("builtin:coding");
+ const selector = await screen.findByTestId("workflow-switcher");
+ expect(selector).toHaveTextContent("Coding");
+ expect(selector).toHaveTextContent("1");
+ await openWorkflowSwitcher();
+ expect(screen.getByTestId("workflow-switcher-option-wf-custom")).toHaveTextContent("2");
+ fireEvent.keyDown(selector, { key: "Escape" });
expect(workflowToolbarActionNames()).toEqual(["Edit workflows", "New workflow"]);
fireEvent.click(screen.getByRole("button", { name: "New workflow" }));
fireEvent.click(screen.getByRole("button", { name: "Edit workflows" }));
@@ -1095,7 +1126,7 @@ describe("Board", () => {
expect(JSON.parse(screen.getByTestId("column-todo").getAttribute("data-tasks") || "[]").map((task: Task) => task.id)).toEqual(["FN-1"]);
expect(screen.queryByTestId("column-intake")).toBeNull();
- fireEvent.change(selector, { target: { value: "wf-custom" } });
+ await selectWorkflow("wf-custom");
await waitFor(() => expect(screen.getByTestId("column-intake")).toBeDefined());
expect(JSON.parse(screen.getByTestId("column-intake").getAttribute("data-tasks") || "[]").map((task: Task) => task.id).sort()).toEqual(["FN-2", "FN-3"]);
expect(screen.queryByTestId("column-todo")).toBeNull();
@@ -1110,10 +1141,9 @@ describe("Board", () => {
[DEFAULT_WORKFLOW, CUSTOM_WORKFLOW],
);
renderBoard({ tasks: [mkTask({ id: "FN-2", column: "intake" })] });
- const selector = await screen.findByLabelText("Select workflow") as HTMLSelectElement;
- const options = [...selector.options].map((option) => option.value);
- expect(options).toEqual(["builtin:coding", "wf-custom"]);
- expect(selector.value).toBe("builtin:coding");
+ const selector = await openWorkflowSwitcher();
+ expect(workflowSwitcherOptionIds()).toEqual(["builtin:coding", "wf-custom"]);
+ expect(selector).toHaveTextContent("Coding");
expect(screen.queryByTestId("column-intake")).toBeNull();
});
@@ -1123,8 +1153,8 @@ describe("Board", () => {
[CUSTOM_WORKFLOW, DEFAULT_WORKFLOW],
);
renderBoard({ tasks: [mkTask({ id: "FN-1" }), mkTask({ id: "FN-2", column: "intake" })] });
- const selector = await screen.findByLabelText("Select workflow") as HTMLSelectElement;
- expect([...selector.options].map((option) => option.value)).toEqual(["builtin:coding", "wf-custom"]);
+ await openWorkflowSwitcher();
+ expect(workflowSwitcherOptionIds()).toEqual(["builtin:coding", "wf-custom"]);
});
it("renders archived cards in the selected workflow archived column", async () => {
@@ -1241,9 +1271,9 @@ describe("Board", () => {
updatedAt: "2024-01-01T00:00:00.000Z",
} as Task] });
- const selector = await screen.findByLabelText("Select workflow") as HTMLSelectElement;
+ const selector = await screen.findByTestId("workflow-switcher");
await waitFor(() => expect(JSON.parse(screen.getByTestId("column-todo").getAttribute("data-tasks") || "[]").map((task: Task) => task.id)).toEqual(["FN-1"]));
- expect(selector.value).toBe("builtin:coding");
+ expect(selector).toHaveTextContent("Coding");
await act(async () => {
sseHandlers["workflow:updated"]?.();
@@ -1252,7 +1282,7 @@ describe("Board", () => {
await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledTimes(2));
await waitFor(() => expect(JSON.parse(screen.getByTestId("column-todo").getAttribute("data-tasks") || "[]").map((task: Task) => task.id)).toEqual([]));
- fireEvent.change(selector, { target: { value: "wf-preserved" } });
+ await selectWorkflow("wf-preserved");
await waitFor(() => expect(JSON.parse(screen.getByTestId("column-todo").getAttribute("data-tasks") || "[]").map((task: Task) => task.id)).toEqual(["FN-1"]));
});
});
diff --git a/packages/dashboard/app/components/__tests__/ListView.test.tsx b/packages/dashboard/app/components/__tests__/ListView.test.tsx
index 9258c32aac..8432afa052 100644
--- a/packages/dashboard/app/components/__tests__/ListView.test.tsx
+++ b/packages/dashboard/app/components/__tests__/ListView.test.tsx
@@ -251,6 +251,17 @@ const keyDownAndFlush = async (element: Element, init: Parameters {
clickInAct(screen.getByRole("button", { name: "Bulk Edit" }));
};
@@ -686,9 +697,9 @@ describe("ListView", () => {
tasks: [createMockTask({ id: "FN-001", column: "todo", title: "Preserved workflow task" })],
});
- const selector = await screen.findByLabelText("Select workflow") as HTMLSelectElement;
+ const selector = await screen.findByTestId("workflow-switcher");
await waitFor(() => expect(screen.getByText("Preserved workflow task")).toBeInTheDocument());
- expect(selector.value).toBe("builtin:coding");
+ expect(selector).toHaveTextContent("Coding");
await act(async () => {
listViewSseHandlers["workflow:updated"]?.();
@@ -697,10 +708,67 @@ describe("ListView", () => {
await waitFor(() => expect(fetchBoardWorkflows).toHaveBeenCalledTimes(2));
await waitFor(() => expect(screen.queryByText("Preserved workflow task")).not.toBeInTheDocument());
- fireEvent.change(selector, { target: { value: "wf-preserved" } });
+ await selectWorkflow("wf-preserved");
await waitFor(() => expect(screen.getByText("Preserved workflow task")).toBeInTheDocument());
});
+ it("shows inline workflow counts in desktop and mobile switchers", async () => {
+ const workflowPayload = {
+ 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: "complete", name: "Complete", flags: { complete: true } },
+ ],
+ },
+ ],
+ taskWorkflowIds: { "FN-001": "builtin:coding", "FN-002": "wf-custom" },
+ };
+ vi.mocked(fetchBoardWorkflows).mockResolvedValue(workflowPayload);
+ const desktopSpy = mockDesktopViewport();
+ const desktop = renderListView({
+ tasks: [
+ createMockTask({ id: "FN-001", column: "triage", title: "Coding task" }),
+ createMockTask({ id: "FN-002", column: "complete", title: "Custom done task" }),
+ ],
+ });
+
+ const desktopTrigger = await screen.findByTestId("workflow-switcher");
+ expect(desktopTrigger).toHaveTextContent("Coding");
+ expect(desktopTrigger).toHaveTextContent("1");
+ await openWorkflowSwitcher();
+ expect(screen.getByTestId("workflow-switcher-option-wf-custom")).toHaveTextContent("1");
+ fireEvent.keyDown(desktopTrigger, { key: "Escape" });
+ desktop.unmount();
+ desktopSpy.mockRestore();
+
+ vi.mocked(fetchBoardWorkflows).mockResolvedValue(workflowPayload);
+ const mobileSpy = mockMobileViewport();
+ renderListView({
+ tasks: [
+ createMockTask({ id: "FN-001", column: "triage", title: "Coding task" }),
+ createMockTask({ id: "FN-002", column: "complete", title: "Custom done task" }),
+ ],
+ });
+
+ const mobileTrigger = await screen.findByTestId("workflow-switcher");
+ expect(mobileTrigger).toHaveTextContent("Coding");
+ expect(mobileTrigger).toHaveTextContent("1");
+ mobileSpy.mockRestore();
+ });
+
it("shows a new-workflow action next to the workflow selector", async () => {
const onCreateWorkflow = vi.fn();
vi.mocked(fetchBoardWorkflows).mockResolvedValue({
@@ -732,7 +800,7 @@ describe("ListView", () => {
onCreateWorkflow,
});
- await screen.findByLabelText("Select workflow");
+ await screen.findByTestId("workflow-switcher");
const createButtons = screen.getAllByRole("button", { name: "New workflow" });
expect(createButtons.length).toBeGreaterThan(0);
fireEvent.click(createButtons[0]);
@@ -2503,8 +2571,7 @@ describe("ListView Quick Entry", () => {
});
renderListView({ onQuickCreate: mockOnQuickCreate });
- const selector = await screen.findByLabelText("Select workflow") as HTMLSelectElement;
- fireEvent.change(selector, { target: { value: "builtin:coding" } });
+ await selectWorkflow("builtin:coding");
const input = screen.getByTestId("quick-entry-input");
fireEvent.change(input, { target: { value: "Built-in workflow task" } });
fireEvent.keyDown(input, { key: "Enter" });
diff --git a/packages/dashboard/app/components/__tests__/WorkflowSwitcher.test.tsx b/packages/dashboard/app/components/__tests__/WorkflowSwitcher.test.tsx
new file mode 100644
index 0000000000..4a93f49c75
--- /dev/null
+++ b/packages/dashboard/app/components/__tests__/WorkflowSwitcher.test.tsx
@@ -0,0 +1,92 @@
+import { fireEvent, render, screen, within } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import type { BoardWorkflowDefinition } from "../../api";
+import { WorkflowSwitcher } from "../WorkflowSwitcher";
+import type { WorkflowStatusCounts } from "../workflowStatusCounts";
+
+const workflows: BoardWorkflowDefinition[] = [
+ {
+ id: "coding",
+ name: "Coding",
+ columns: [],
+ },
+ {
+ id: "design",
+ name: "Design",
+ columns: [],
+ },
+];
+
+function countMap(entries: Array<[string, WorkflowStatusCounts]> = []) {
+ return new Map(entries);
+}
+
+describe("WorkflowSwitcher", () => {
+ it("renders the active workflow with compact inline counts", () => {
+ render(
+ ,
+ );
+
+ const trigger = screen.getByTestId("workflow-switcher");
+ expect(trigger).toHaveTextContent("Coding");
+ expect(trigger).toHaveTextContent("3");
+ expect(trigger).toHaveTextContent("1");
+ expect(trigger).toHaveTextContent("5");
+ expect(trigger).toHaveAccessibleName("Select workflow. Current workflow: Coding");
+ });
+
+ it("opens and closes the portaled listbox", () => {
+ render( );
+
+ fireEvent.click(screen.getByTestId("workflow-switcher"));
+ expect(screen.getByRole("listbox", { name: "Workflow" })).toBeInTheDocument();
+ expect(screen.getByTestId("workflow-switcher-option-coding")).toHaveAttribute("aria-selected", "true");
+
+ fireEvent.mouseDown(document.body);
+ expect(screen.queryByRole("listbox", { name: "Workflow" })).not.toBeInTheDocument();
+ });
+
+ it("calls onChange when an option is selected", () => {
+ const onChange = vi.fn();
+ render( );
+
+ fireEvent.click(screen.getByTestId("workflow-switcher"));
+ fireEvent.click(screen.getByTestId("workflow-switcher-option-design"));
+
+ expect(onChange).toHaveBeenCalledWith("design");
+ expect(screen.queryByRole("listbox", { name: "Workflow" })).not.toBeInTheDocument();
+ });
+
+ it("supports keyboard navigation and escape dismissal", () => {
+ const onChange = vi.fn();
+ render( );
+
+ const trigger = screen.getByTestId("workflow-switcher");
+ fireEvent.keyDown(trigger, { key: "ArrowDown" });
+ fireEvent.keyDown(trigger, { key: "ArrowDown" });
+ fireEvent.keyDown(trigger, { key: "Enter" });
+
+ expect(onChange).toHaveBeenCalledWith("design");
+
+ fireEvent.keyDown(trigger, { key: "ArrowDown" });
+ expect(screen.getByRole("listbox", { name: "Workflow" })).toBeInTheDocument();
+ fireEvent.keyDown(trigger, { key: "Escape" });
+ expect(screen.queryByRole("listbox", { name: "Workflow" })).not.toBeInTheDocument();
+ });
+
+ it("renders zero counts for workflows absent from the counts map", () => {
+ render( );
+
+ fireEvent.click(screen.getByTestId("workflow-switcher"));
+ const designOption = screen.getByTestId("workflow-switcher-option-design");
+
+ expect(within(designOption).getByText("0", { selector: ".workflow-switcher-count--todo" })).toBeInTheDocument();
+ expect(within(designOption).getByText("0", { selector: ".workflow-switcher-count--in-progress" })).toBeInTheDocument();
+ expect(within(designOption).getByText("0", { selector: ".workflow-switcher-count--done" })).toBeInTheDocument();
+ });
+});
diff --git a/packages/dashboard/app/components/__tests__/workflowStatusCounts.test.ts b/packages/dashboard/app/components/__tests__/workflowStatusCounts.test.ts
new file mode 100644
index 0000000000..c93a42d2a4
--- /dev/null
+++ b/packages/dashboard/app/components/__tests__/workflowStatusCounts.test.ts
@@ -0,0 +1,122 @@
+import { describe, expect, it } from "vitest";
+import type { Task } from "@fusion/core";
+import type { BoardWorkflowsPayload } from "../../api";
+import { computeWorkflowStatusCounts } from "../workflowStatusCounts";
+
+const boardWorkflows: BoardWorkflowsPayload = {
+ flagEnabled: true,
+ defaultWorkflowId: "default",
+ taskWorkflowIds: {},
+ workflows: [
+ {
+ id: "default",
+ name: "Default",
+ columns: [
+ { id: "todo", name: "Todo", flags: { intake: true } },
+ { id: "ready", name: "Ready", flags: {} },
+ { id: "active", name: "Active", flags: { countsTowardWip: true } },
+ { id: "review", name: "Review", flags: { countsTowardWip: true, mergeBlocker: true } },
+ { id: "done", name: "Done", flags: { complete: true } },
+ { id: "archived", name: "Archived", flags: { archived: true } },
+ ],
+ },
+ {
+ id: "design",
+ name: "Design",
+ columns: [
+ { id: "design-todo", name: "Todo", flags: { intake: true } },
+ { id: "design-active", name: "Active", flags: { countsTowardWip: true } },
+ { id: "design-done", name: "Done", flags: { complete: true } },
+ { id: "design-archived", name: "Archived", flags: { archived: true } },
+ ],
+ },
+ {
+ id: "empty",
+ name: "Empty",
+ columns: [
+ { id: "empty-todo", name: "Todo", flags: { intake: true } },
+ { id: "empty-active", name: "Active", flags: { countsTowardWip: true } },
+ { id: "empty-done", name: "Done", flags: { complete: true } },
+ ],
+ },
+ ],
+};
+
+function task(id: string, column: string): Task {
+ return {
+ id,
+ title: id,
+ description: id,
+ column,
+ dependencies: [],
+ steps: [],
+ currentStep: 0,
+ } as Task;
+}
+
+describe("computeWorkflowStatusCounts", () => {
+ it("returns an empty map when workflow metadata is unavailable", () => {
+ expect(computeWorkflowStatusCounts([task("FN-1", "todo")], null).size).toBe(0);
+ expect(computeWorkflowStatusCounts(undefined, undefined).size).toBe(0);
+ });
+
+ it("initializes every workflow with zero counts for empty and duplicate/populated states", () => {
+ const counts = computeWorkflowStatusCounts([], boardWorkflows);
+
+ expect(counts.get("default")).toEqual({ todo: 0, inProgress: 0, done: 0 });
+ expect(counts.get("design")).toEqual({ todo: 0, inProgress: 0, done: 0 });
+ expect(counts.get("empty")).toEqual({ todo: 0, inProgress: 0, done: 0 });
+ });
+
+ it("classifies todo, in-progress, and done buckets from workflow column flags", () => {
+ const counts = computeWorkflowStatusCounts(
+ [
+ task("FN-todo", "todo"),
+ task("FN-ready", "ready"),
+ task("FN-active", "active"),
+ task("FN-review", "review"),
+ task("FN-done", "done"),
+ ],
+ boardWorkflows,
+ );
+
+ expect(counts.get("default")).toEqual({ todo: 2, inProgress: 2, done: 1 });
+ });
+
+ it("falls back to the default workflow when a task has no workflow assignment", () => {
+ const counts = computeWorkflowStatusCounts([task("FN-unassigned", "done")], boardWorkflows);
+
+ expect(counts.get("default")).toEqual({ todo: 0, inProgress: 0, done: 1 });
+ });
+
+ it("counts tasks independently for their assigned workflow", () => {
+ const counts = computeWorkflowStatusCounts(
+ [task("FN-design-todo", "design-todo"), task("FN-design-active", "design-active"), task("FN-design-done", "design-done")],
+ {
+ ...boardWorkflows,
+ taskWorkflowIds: {
+ "FN-design-todo": "design",
+ "FN-design-active": "design",
+ "FN-design-done": "design",
+ },
+ },
+ );
+
+ expect(counts.get("design")).toEqual({ todo: 1, inProgress: 1, done: 1 });
+ expect(counts.get("default")).toEqual({ todo: 0, inProgress: 0, done: 0 });
+ });
+
+ it("excludes archived-column tasks and ignores unknown workflows or columns", () => {
+ const counts = computeWorkflowStatusCounts(
+ [task("FN-archived", "archived"), task("FN-unknown-column", "missing"), task("FN-unknown-workflow", "todo")],
+ {
+ ...boardWorkflows,
+ taskWorkflowIds: {
+ "FN-unknown-workflow": "missing-workflow",
+ },
+ },
+ );
+
+ expect(counts.get("default")).toEqual({ todo: 0, inProgress: 0, done: 0 });
+ });
+});
diff --git a/packages/dashboard/app/components/workflowStatusCounts.ts b/packages/dashboard/app/components/workflowStatusCounts.ts
new file mode 100644
index 0000000000..d18f5dee0d
--- /dev/null
+++ b/packages/dashboard/app/components/workflowStatusCounts.ts
@@ -0,0 +1,54 @@
+import type { Task } from "@fusion/core";
+import type { BoardWorkflowColumn, BoardWorkflowsPayload } from "../api";
+
+export interface WorkflowStatusCounts {
+ todo: number;
+ inProgress: number;
+ done: number;
+}
+
+const EMPTY_COUNTS = (): WorkflowStatusCounts => ({ todo: 0, inProgress: 0, done: 0 });
+
+/**
+ * FNXC:WorkflowSwitcher 2026-06-20-00:09:
+ * The board/list workflow dropdown must show compact Todo, In Progress, and Done task counts for every selectable workflow without duplicating logic across render surfaces.
+ * Use workflow column flags as the source of truth: archived columns are excluded, complete columns count as Done, active non-intake WIP columns count as In Progress, and all remaining visible work counts as Todo/not-yet-started.
+ */
+export function computeWorkflowStatusCounts(
+ tasks: readonly Task[] | null | undefined,
+ boardWorkflows: BoardWorkflowsPayload | null | undefined,
+): Map {
+ const countsByWorkflow = new Map();
+ if (!boardWorkflows) return countsByWorkflow;
+
+ const workflowsById = new Map(boardWorkflows.workflows.map((workflow) => [workflow.id, workflow]));
+ const columnsByWorkflowId = new Map>();
+
+ for (const workflow of boardWorkflows.workflows) {
+ countsByWorkflow.set(workflow.id, EMPTY_COUNTS());
+ columnsByWorkflowId.set(workflow.id, new Map(workflow.columns.map((column) => [column.id, column])));
+ }
+
+ if (!tasks?.length) return countsByWorkflow;
+
+ for (const task of tasks) {
+ const workflowId = boardWorkflows.taskWorkflowIds[task.id] ?? boardWorkflows.defaultWorkflowId;
+ const workflow = workflowsById.get(workflowId);
+ if (!workflow) continue;
+
+ const column = columnsByWorkflowId.get(workflow.id)?.get(task.column);
+ if (!column || column.flags.archived) continue;
+
+ const counts = countsByWorkflow.get(workflow.id) ?? EMPTY_COUNTS();
+ if (column.flags.complete) {
+ counts.done += 1;
+ } else if (column.flags.countsTowardWip && !column.flags.intake) {
+ counts.inProgress += 1;
+ } else {
+ counts.todo += 1;
+ }
+ countsByWorkflow.set(workflow.id, counts);
+ }
+
+ return countsByWorkflow;
+}
diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json
index 1561255b9b..675a073e78 100644
--- a/packages/i18n/locales/en/app.json
+++ b/packages/i18n/locales/en/app.json
@@ -1869,6 +1869,7 @@
"title": "Create room"
},
"dashboard": {
+ "brandName": "Fusion",
"initializingDashboard": "Initializing dashboard...",
"loadingMessage": "Loading Fusion dashboard",
"loadingProgress": "Dashboard loading progress",
@@ -7417,6 +7418,14 @@
"templateSectionBuiltin": "Built-in workflows",
"templateSectionYours": "Your workflows"
},
+ "workflowSwitcher": {
+ "countsAria": "{{todoLabel}}: {{todo}}, {{inProgressLabel}}: {{inProgress}}, {{doneLabel}}: {{done}}",
+ "done": "Done",
+ "inProgress": "In Progress",
+ "label": "Workflow",
+ "todo": "Todo",
+ "triggerAria": "Select workflow. Current workflow: {{name}}"
+ },
"workflowSelector": {
"switchActiveMessage": "This task has an active session. Switching workflows aborts it and re-homes the card into the new workflow's entry column. Continue?",
"switchActiveTitle": "Switch workflow?",
diff --git a/packages/i18n/locales/es/app.json b/packages/i18n/locales/es/app.json
index 07e24aa7ba..4284d55a9c 100644
--- a/packages/i18n/locales/es/app.json
+++ b/packages/i18n/locales/es/app.json
@@ -1869,6 +1869,7 @@
"title": "Crear sala"
},
"dashboard": {
+ "brandName": "Fusion",
"initializingDashboard": "Inicializando panel...",
"loadingMessage": "Cargando panel de Fusion",
"loadingProgress": "Progreso de carga del panel",
@@ -7417,6 +7418,14 @@
"templateSectionBuiltin": "Flujos de trabajo integrados",
"templateSectionYours": "Tus flujos de trabajo"
},
+ "workflowSwitcher": {
+ "countsAria": "{{todoLabel}}: {{todo}}, {{inProgressLabel}}: {{inProgress}}, {{doneLabel}}: {{done}}",
+ "done": "Done",
+ "inProgress": "In Progress",
+ "label": "Workflow",
+ "todo": "Todo",
+ "triggerAria": "Select workflow. Current workflow: {{name}}"
+ },
"workflowSelector": {
"switchActiveMessage": "",
"switchActiveTitle": "",
diff --git a/packages/i18n/locales/fr/app.json b/packages/i18n/locales/fr/app.json
index 1ed48a43fa..f57d82347b 100644
--- a/packages/i18n/locales/fr/app.json
+++ b/packages/i18n/locales/fr/app.json
@@ -1869,6 +1869,7 @@
"title": "Créer une salle"
},
"dashboard": {
+ "brandName": "Fusion",
"initializingDashboard": "Initialisation du tableau de bord...",
"loadingMessage": "Chargement du tableau de bord Fusion",
"loadingProgress": "Progression du chargement du tableau de bord",
@@ -7417,6 +7418,14 @@
"templateSectionBuiltin": "Workflows intégrés",
"templateSectionYours": "Vos workflows"
},
+ "workflowSwitcher": {
+ "countsAria": "{{todoLabel}}: {{todo}}, {{inProgressLabel}}: {{inProgress}}, {{doneLabel}}: {{done}}",
+ "done": "Done",
+ "inProgress": "In Progress",
+ "label": "Workflow",
+ "todo": "Todo",
+ "triggerAria": "Select workflow. Current workflow: {{name}}"
+ },
"workflowSelector": {
"switchActiveMessage": "",
"switchActiveTitle": "",
diff --git a/packages/i18n/locales/ko/app.json b/packages/i18n/locales/ko/app.json
index 9149e65ce8..8ae6056e12 100644
--- a/packages/i18n/locales/ko/app.json
+++ b/packages/i18n/locales/ko/app.json
@@ -1869,6 +1869,7 @@
"title": "방 만들기"
},
"dashboard": {
+ "brandName": "Fusion",
"initializingDashboard": "대시보드 초기화 중...",
"loadingMessage": "Fusion 대시보드 로드 중",
"loadingProgress": "대시보드 로딩 진행 상황",
@@ -7417,6 +7418,14 @@
"templateSectionBuiltin": "기본 제공 워크플로",
"templateSectionYours": "내 워크플로"
},
+ "workflowSwitcher": {
+ "countsAria": "{{todoLabel}}: {{todo}}, {{inProgressLabel}}: {{inProgress}}, {{doneLabel}}: {{done}}",
+ "done": "Done",
+ "inProgress": "In Progress",
+ "label": "Workflow",
+ "todo": "Todo",
+ "triggerAria": "Select workflow. Current workflow: {{name}}"
+ },
"workflowSelector": {
"switchActiveMessage": "",
"switchActiveTitle": "",
diff --git a/packages/i18n/locales/zh-CN/app.json b/packages/i18n/locales/zh-CN/app.json
index cf79840d67..050a2eaf45 100644
--- a/packages/i18n/locales/zh-CN/app.json
+++ b/packages/i18n/locales/zh-CN/app.json
@@ -1869,6 +1869,7 @@
"title": "创建房间"
},
"dashboard": {
+ "brandName": "Fusion",
"initializingDashboard": "初始化仪表板...",
"loadingMessage": "加载 Fusion 仪表板",
"loadingProgress": "仪表板加载进度",
@@ -7417,6 +7418,14 @@
"templateSectionBuiltin": "内置工作流",
"templateSectionYours": "我的工作流"
},
+ "workflowSwitcher": {
+ "countsAria": "{{todoLabel}}: {{todo}}, {{inProgressLabel}}: {{inProgress}}, {{doneLabel}}: {{done}}",
+ "done": "Done",
+ "inProgress": "In Progress",
+ "label": "Workflow",
+ "todo": "Todo",
+ "triggerAria": "Select workflow. Current workflow: {{name}}"
+ },
"workflowSelector": {
"switchActiveMessage": "",
"switchActiveTitle": "",
diff --git a/packages/i18n/locales/zh-TW/app.json b/packages/i18n/locales/zh-TW/app.json
index 084960c08b..6488b76c2b 100644
--- a/packages/i18n/locales/zh-TW/app.json
+++ b/packages/i18n/locales/zh-TW/app.json
@@ -1869,6 +1869,7 @@
"title": "建立房間"
},
"dashboard": {
+ "brandName": "Fusion",
"initializingDashboard": "初始化儀表板...",
"loadingMessage": "載入 Fusion 儀表板",
"loadingProgress": "儀表板加載進度",
@@ -7417,6 +7418,14 @@
"templateSectionBuiltin": "內建工作流程",
"templateSectionYours": "你的工作流程"
},
+ "workflowSwitcher": {
+ "countsAria": "{{todoLabel}}: {{todo}}, {{inProgressLabel}}: {{inProgress}}, {{doneLabel}}: {{done}}",
+ "done": "Done",
+ "inProgress": "In Progress",
+ "label": "Workflow",
+ "todo": "Todo",
+ "triggerAria": "Select workflow. Current workflow: {{name}}"
+ },
"workflowSelector": {
"switchActiveMessage": "",
"switchActiveTitle": "",