FN-6848: refresh workflow dropdown counts on open

Refresh workflow dropdown counts from the latest board-workflows data whenever users open the selector.

- Refactor Board and ListView workflow payload fetching into reusable refresh callbacks shared by mount, visibility, focus, SSE, and dropdown-open events.
- Add a WorkflowSwitcher onOpen hook that fires only on closed-to-open click or keyboard transitions.
- Cover Board, ListView, and WorkflowSwitcher refresh behavior with regression tests and document the refreshed dropdown counts.

Files changed:
 docs/dashboard-guide.md                            |  2 +-
 packages/dashboard/app/components/Board.tsx        | 49 +++++++++++---------
 packages/dashboard/app/components/ListView.tsx     | 49 +++++++++++---------
 .../dashboard/app/components/WorkflowSwitcher.tsx  | 38 +++++++++++++---
 .../app/components/__tests__/Board.test.tsx        | 14 ++++++
 .../app/components/__tests__/ListView.test.tsx     | 39 ++++++++++++++++
 .../components/__tests__/WorkflowSwitcher.test.tsx | 53 ++++++++++++++++++++++
 7 files changed, 195 insertions(+), 49 deletions(-)

Fusion-Task-Id: FN-6848

Fusion-Task-Lineage: bb83cee9-4184-4094-89d6-f2cf2a4fbd46
This commit is contained in:
gsxdsm
2026-06-21 11:41:13 -07:00
parent 1c6fe544e2
commit 2f7905386f
7 changed files with 195 additions and 49 deletions

View File

@@ -79,7 +79,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 shows the workflow name and chevron only; compact Todo / In Progress / Done counts derived from workflow column flags (excluding archived columns) 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. Each option row also exposes an inline edit action, and a persistent **New workflow** footer stays visible below the scrollable option list. 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 name and chevron only; compact Todo / In Progress / Done counts derived from workflow column flags (excluding archived 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. Each option row also exposes an inline edit action, and a persistent **New workflow** footer stays visible below the scrollable option list. 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.
![Board view](./screenshots/dashboard-overview.png)

View File

@@ -390,34 +390,40 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
setBoardWorkflowsState(cached ? { projectId, payload: cached } : null);
}, [projectId, shouldHydrateBoardWorkflowsCache]);
/*
FNXC:WorkflowControls 2026-06-21-00:00:
Opening the workflow switcher must refresh the board-workflows payload because task workflow assignment changes do not emit workflow definition SSE events.
Share this path with mount, visibility/focus, and workflow-definition SSE refetches so the stale-response guard and cache writes remain identical.
*/
const refreshBoardWorkflows = useCallback(() => {
const seq = ++boardWorkflowsFetchSeqRef.current;
fetchBoardWorkflows(projectId)
.then((payload) => {
if (seq === boardWorkflowsFetchSeqRef.current) {
setBoardWorkflowsState({ projectId, payload });
writeBoardWorkflowsCache(projectId, payload);
}
})
.catch(() => {
if (seq === boardWorkflowsFetchSeqRef.current) {
setBoardWorkflowsState({ projectId, payload: { flagEnabled: false, defaultWorkflowId: "builtin:coding", workflows: [], taskWorkflowIds: {} } });
}
});
}, [projectId]);
useEffect(() => {
const runFetch = () => {
const seq = ++boardWorkflowsFetchSeqRef.current;
fetchBoardWorkflows(projectId)
.then((payload) => {
if (seq === boardWorkflowsFetchSeqRef.current) {
setBoardWorkflowsState({ projectId, payload });
writeBoardWorkflowsCache(projectId, payload);
}
})
.catch(() => {
if (seq === boardWorkflowsFetchSeqRef.current) {
setBoardWorkflowsState({ projectId, payload: { flagEnabled: false, defaultWorkflowId: "builtin:coding", workflows: [], taskWorkflowIds: {} } });
}
});
};
runFetch();
refreshBoardWorkflows();
const onVisible = () => {
if (typeof document === "undefined" || document.visibilityState === "visible") runFetch();
if (typeof document === "undefined" || document.visibilityState === "visible") refreshBoardWorkflows();
};
if (typeof document !== "undefined") document.addEventListener("visibilitychange", onVisible);
if (typeof window !== "undefined") window.addEventListener("focus", onVisible);
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
const unsubscribe = subscribeSse(`/api/events${query}`, {
events: {
"workflow:created": runFetch,
"workflow:updated": runFetch,
"workflow:deleted": runFetch,
"workflow:created": refreshBoardWorkflows,
"workflow:updated": refreshBoardWorkflows,
"workflow:deleted": refreshBoardWorkflows,
},
});
return () => {
@@ -427,7 +433,7 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
if (typeof window !== "undefined") window.removeEventListener("focus", onVisible);
unsubscribe();
};
}, [projectId]);
}, [projectId, refreshBoardWorkflows]);
const handlePromote = useCallback(async (taskId: string) => {
await promoteTask(taskId, projectId);
@@ -576,6 +582,7 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
value={selectedWorkflow.id}
onChange={setSelectedWorkflowId}
counts={workflowStatusCounts}
onOpen={refreshBoardWorkflows}
onEditWorkflow={onOpenWorkflowEditor}
onCreateWorkflow={onCreateWorkflow}
/>

View File

@@ -430,34 +430,40 @@ export function ListView({
setBoardWorkflowsState(cached ? { projectId, payload: cached } : null);
}, [projectId, shouldHydrateBoardWorkflowsCache]);
/*
FNXC:WorkflowControls 2026-06-21-00:00:
Opening the workflow switcher must refresh the board-workflows payload because task workflow assignment changes do not emit workflow definition SSE events.
Share this path with mount, visibility/focus, and workflow-definition SSE refetches so desktop sidebar and mobile toolbar counts cannot drift.
*/
const refreshBoardWorkflows = useCallback(() => {
const seq = ++boardWorkflowsFetchSeqRef.current;
fetchBoardWorkflows(projectId)
.then((payload) => {
if (seq === boardWorkflowsFetchSeqRef.current) {
setBoardWorkflowsState({ projectId, payload });
writeBoardWorkflowsCache(projectId, payload);
}
})
.catch(() => {
if (seq === boardWorkflowsFetchSeqRef.current) {
setBoardWorkflowsState({ projectId, payload: { flagEnabled: false, defaultWorkflowId: "builtin:coding", workflows: [], taskWorkflowIds: {} } });
}
});
}, [projectId]);
useEffect(() => {
const runFetch = () => {
const seq = ++boardWorkflowsFetchSeqRef.current;
fetchBoardWorkflows(projectId)
.then((payload) => {
if (seq === boardWorkflowsFetchSeqRef.current) {
setBoardWorkflowsState({ projectId, payload });
writeBoardWorkflowsCache(projectId, payload);
}
})
.catch(() => {
if (seq === boardWorkflowsFetchSeqRef.current) {
setBoardWorkflowsState({ projectId, payload: { flagEnabled: false, defaultWorkflowId: "builtin:coding", workflows: [], taskWorkflowIds: {} } });
}
});
};
runFetch();
refreshBoardWorkflows();
const onVisible = () => {
if (typeof document === "undefined" || document.visibilityState === "visible") runFetch();
if (typeof document === "undefined" || document.visibilityState === "visible") refreshBoardWorkflows();
};
if (typeof document !== "undefined") document.addEventListener("visibilitychange", onVisible);
if (typeof window !== "undefined") window.addEventListener("focus", onVisible);
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
const unsubscribe = subscribeSse(`/api/events${query}`, {
events: {
"workflow:created": runFetch,
"workflow:updated": runFetch,
"workflow:deleted": runFetch,
"workflow:created": refreshBoardWorkflows,
"workflow:updated": refreshBoardWorkflows,
"workflow:deleted": refreshBoardWorkflows,
},
});
return () => {
@@ -466,7 +472,7 @@ export function ListView({
if (typeof window !== "undefined") window.removeEventListener("focus", onVisible);
unsubscribe();
};
}, [projectId]);
}, [projectId, refreshBoardWorkflows]);
// Persist selection to localStorage
useEffect(() => {
@@ -1630,6 +1636,7 @@ export function ListView({
value={selectedWorkflow.id}
onChange={setSelectedWorkflowId}
counts={workflowStatusCounts}
onOpen={refreshBoardWorkflows}
label={t("listView.workflowLabel", "Workflow")}
onEditWorkflow={onOpenWorkflowEditor}
onCreateWorkflow={onCreateWorkflow}

View File

@@ -12,6 +12,8 @@ export interface WorkflowSwitcherProps {
value: string;
onChange: (id: string) => void;
counts: Map<string, WorkflowStatusCounts>;
/** Fired each time the dropdown transitions from closed to open so consumers can refresh count data. */
onOpen?: () => void;
label?: string;
onEditWorkflow?: (workflowId: string) => void;
onCreateWorkflow?: () => void;
@@ -42,8 +44,12 @@ function getCounts(counts: Map<string, WorkflowStatusCounts>, workflowId: string
* FNXC:WorkflowSwitcher 2026-06-20-15:34:
* Workflow edit and creation affordances moved into the shared dropdown so Board and ListView cannot leave separate toolbar icon shells behind.
* Each option row owns a sibling edit button, and New workflow remains visible in a non-scrolling footer while long workflow lists scroll.
*
* FNXC:WorkflowSwitcher 2026-06-21-00:00:
* Opening the dropdown must refresh workflow count data because task-to-workflow assignments do not emit board-workflows invalidation events.
* Fire onOpen only on closed-to-open transitions so consumers can refetch without close-time calls or render loops.
*/
export function WorkflowSwitcher({ workflows, value, onChange, counts, label: labelProp, onEditWorkflow, onCreateWorkflow }: WorkflowSwitcherProps) {
export function WorkflowSwitcher({ workflows, value, onChange, counts, onOpen, label: labelProp, onEditWorkflow, onCreateWorkflow }: WorkflowSwitcherProps) {
const { t } = useTranslation("app");
const label = labelProp ?? t("workflowSwitcher.label", "Workflow");
const todoLabel = t("workflowSwitcher.todo", "Todo");
@@ -62,6 +68,7 @@ export function WorkflowSwitcher({ workflows, value, onChange, counts, label: la
const triggerRef = useRef<HTMLButtonElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const onOpenRef = useRef(onOpen);
const selectedIndex = useMemo(() => Math.max(0, workflows.findIndex((workflow) => workflow.id === value)), [value, workflows]);
const selectedWorkflow = workflows[selectedIndex] ?? workflows[0] ?? null;
@@ -96,6 +103,10 @@ export function WorkflowSwitcher({ workflows, value, onChange, counts, label: la
setDropdownPosition({ top, left, width, maxHeight });
}, []);
useEffect(() => {
onOpenRef.current = onOpen;
}, [onOpen]);
useEffect(() => {
setPortalRoot(document.body);
}, []);
@@ -141,6 +152,21 @@ export function WorkflowSwitcher({ workflows, value, onChange, counts, label: la
}
}, [highlightedIndex, isOpen]);
const openDropdown = useCallback(() => {
if (isOpen) return;
onOpenRef.current?.();
setIsOpen(true);
}, [isOpen]);
const toggleDropdown = useCallback(() => {
if (isOpen) {
setIsOpen(false);
return;
}
onOpenRef.current?.();
setIsOpen(true);
}, [isOpen]);
const selectWorkflow = useCallback((workflowId: string) => {
onChange(workflowId);
setIsOpen(false);
@@ -164,7 +190,7 @@ export function WorkflowSwitcher({ workflows, value, onChange, counts, label: la
case "ArrowDown":
event.preventDefault();
if (!isOpen) {
setIsOpen(true);
openDropdown();
} else {
setHighlightedIndex((current) => (workflows.length ? (current + 1) % workflows.length : 0));
}
@@ -172,7 +198,7 @@ export function WorkflowSwitcher({ workflows, value, onChange, counts, label: la
case "ArrowUp":
event.preventDefault();
if (!isOpen) {
setIsOpen(true);
openDropdown();
} else {
setHighlightedIndex((current) => (workflows.length ? (current - 1 + workflows.length) % workflows.length : 0));
}
@@ -184,7 +210,7 @@ export function WorkflowSwitcher({ workflows, value, onChange, counts, label: la
const workflow = workflows[highlightedIndex];
if (workflow) selectWorkflow(workflow.id);
} else {
setIsOpen(true);
openDropdown();
}
break;
case "Escape":
@@ -195,7 +221,7 @@ export function WorkflowSwitcher({ workflows, value, onChange, counts, label: la
setIsOpen(false);
break;
}
}, [highlightedIndex, isOpen, selectWorkflow, workflows]);
}, [highlightedIndex, isOpen, openDropdown, selectWorkflow, workflows]);
if (!selectedWorkflow) return null;
@@ -310,7 +336,7 @@ export function WorkflowSwitcher({ workflows, value, onChange, counts, label: la
aria-expanded={isOpen}
aria-controls={isOpen ? listboxId : undefined}
aria-label={t("workflowSwitcher.triggerAria", "Select workflow. Current workflow: {{name}}", { name: selectedWorkflow.name })}
onClick={() => setIsOpen((open) => !open)}
onClick={toggleDropdown}
onKeyDown={handleKeyDown}
>
<span className="workflow-switcher-trigger-main">

View File

@@ -1300,6 +1300,20 @@ describe("Board", () => {
expect(screen.getByTestId("column-done").getAttribute("data-has-archive-all")).toBe("yes");
expect(screen.getByTestId("column-todo").getAttribute("data-has-archive-all")).toBe("no");
});
it("re-fetches board-workflows when the workflow switcher opens", async () => {
enableFlag({ "FN-1": "builtin:coding" }, [DEFAULT_WORKFLOW, CUSTOM_WORKFLOW]);
renderBoard({ projectId: "proj-1", tasks: [mkTask({ id: "FN-1", column: "todo" })] });
const trigger = await screen.findByTestId("workflow-switcher");
await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledTimes(1));
fetchBoardWorkflowsMock.mockClear();
fireEvent.click(trigger);
expect(fetchBoardWorkflowsMock).toHaveBeenCalledTimes(1);
expect(screen.getByRole("listbox", { name: "Workflow" })).toBeInTheDocument();
});
});
describe("workflow:updated SSE invalidation (#1406)", () => {

View File

@@ -658,6 +658,45 @@ describe("ListView", () => {
expect(screen.queryAllByText("Backlog")).toHaveLength(0);
});
it("re-fetches board-workflows when the workflow switcher opens", async () => {
vi.mocked(fetchBoardWorkflows).mockResolvedValue({
flagEnabled: true,
defaultWorkflowId: "builtin:coding",
workflows: [
{
id: "builtin:coding",
name: "Coding",
columns: [
{ id: "todo", name: "Todo", flags: { hold: true } },
{ id: "done", name: "Done", flags: { complete: true } },
],
},
{
id: "wf-custom",
name: "Custom Flow",
columns: [
{ id: "backlog", name: "Backlog", flags: { intake: true } },
{ id: "done", name: "Done", flags: { complete: true } },
],
},
],
taskWorkflowIds: { "FN-001": "builtin:coding" },
});
renderListView({
tasks: [createMockTask({ id: "FN-001", column: "todo", title: "Workflow task" })],
});
const trigger = await screen.findByTestId("workflow-switcher");
await waitFor(() => expect(fetchBoardWorkflows).toHaveBeenCalledTimes(1));
vi.mocked(fetchBoardWorkflows).mockClear();
fireEvent.click(trigger);
expect(fetchBoardWorkflows).toHaveBeenCalledTimes(1);
expect(screen.getByRole("listbox", { name: "Workflow" })).toBeInTheDocument();
});
it("re-homes a preserved-column task to the new workflow after workflow invalidation", async () => {
const preservedWorkflow = {
id: "wf-preserved",

View File

@@ -58,6 +58,59 @@ describe("WorkflowSwitcher", () => {
expect(screen.queryByRole("listbox", { name: "Workflow" })).not.toBeInTheDocument();
});
it("fires onOpen only on click-driven closed-to-open transitions", () => {
const onOpen = vi.fn();
render(<WorkflowSwitcher workflows={workflows} value="coding" onChange={vi.fn()} counts={countMap()} onOpen={onOpen} />);
const trigger = screen.getByTestId("workflow-switcher");
expect(onOpen).not.toHaveBeenCalled();
fireEvent.click(trigger);
expect(onOpen).toHaveBeenCalledTimes(1);
expect(screen.getByRole("listbox", { name: "Workflow" })).toBeInTheDocument();
fireEvent.click(trigger);
expect(onOpen).toHaveBeenCalledTimes(1);
expect(screen.queryByRole("listbox", { name: "Workflow" })).not.toBeInTheDocument();
fireEvent.click(trigger);
expect(onOpen).toHaveBeenCalledTimes(2);
});
it.each(["ArrowDown", "ArrowUp", "Enter", " "])("fires onOpen when %s opens the dropdown from the keyboard", (key) => {
const onOpen = vi.fn();
render(<WorkflowSwitcher workflows={workflows} value="coding" onChange={vi.fn()} counts={countMap()} onOpen={onOpen} />);
const trigger = screen.getByTestId("workflow-switcher");
expect(onOpen).not.toHaveBeenCalled();
fireEvent.keyDown(trigger, { key });
expect(onOpen).toHaveBeenCalledTimes(1);
expect(screen.getByRole("listbox", { name: "Workflow" })).toBeInTheDocument();
});
it("does not fire onOpen when Escape or outside mousedown closes and fires again after reopening", () => {
const onOpen = vi.fn();
render(<WorkflowSwitcher workflows={workflows} value="coding" onChange={vi.fn()} counts={countMap()} onOpen={onOpen} />);
const trigger = screen.getByTestId("workflow-switcher");
fireEvent.click(trigger);
expect(onOpen).toHaveBeenCalledTimes(1);
fireEvent.keyDown(trigger, { key: "Escape" });
expect(onOpen).toHaveBeenCalledTimes(1);
expect(screen.queryByRole("listbox", { name: "Workflow" })).not.toBeInTheDocument();
fireEvent.click(trigger);
expect(onOpen).toHaveBeenCalledTimes(2);
fireEvent.mouseDown(document.body);
expect(onOpen).toHaveBeenCalledTimes(2);
expect(screen.queryByRole("listbox", { name: "Workflow" })).not.toBeInTheDocument();
fireEvent.keyDown(trigger, { key: "ArrowDown" });
expect(onOpen).toHaveBeenCalledTimes(3);
});
it("calls onChange when an option is selected", () => {
const onChange = vi.fn();
render(<WorkflowSwitcher workflows={workflows} value="coding" onChange={onChange} counts={countMap()} />);