FN-6894: widen workflow switcher menus

Workflow switcher dropdowns now size open menus to fit long workflow names without widening the collapsed trigger.

- Add menu width calculation that combines measured workflow names, option decorations, trigger width, and viewport bounds.
- Measure option names with a canvas when opening/repositioning the dropdown.
- Cover long-name, viewport clamp, trigger sizing, and CSS overflow behavior in WorkflowSwitcher tests.
- Document the open-listbox sizing behavior in the dashboard guide.

Files changed:
 docs/dashboard-guide.md                            |  2 +-
 .../dashboard/app/components/WorkflowSwitcher.tsx  | 49 +++++++++++++-
 .../components/__tests__/WorkflowSwitcher.test.tsx | 78 +++++++++++++++++++++-
 3 files changed, 123 insertions(+), 6 deletions(-)

Fusion-Task-Id: FN-6894

Fusion-Task-Lineage: 3f2f8129-3f85-484d-9c0f-b6ebe673b6a9
This commit is contained in:
gsxdsm
2026-06-21 19:23:51 -07:00
parent 9ce9f74b5c
commit 2db99ec6fd
3 changed files with 123 additions and 6 deletions

View File

@@ -89,7 +89,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) 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`.
- 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. 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.
![Board view](./screenshots/dashboard-overview.png)

View File

@@ -27,6 +27,36 @@ interface DropdownPosition {
}
const ZERO_COUNTS: WorkflowStatusCounts = { todo: 0, inProgress: 0, done: 0 };
const DEFAULT_MENU_HORIZONTAL_PADDING = 16;
const DEFAULT_MENU_MIN_WIDTH = 240;
/**
* FNXC:WorkflowSwitcher 2026-06-21-18:34:
* The open listbox must expose full workflow names for comparison while the collapsed trigger remains intentionally narrow and ellipsized.
* Size the menu from measured name content plus option decorations, then clamp to the viewport so the trigger width can prevent shrinking but cannot force long names to stay truncated.
* OPTION_DECORATIONS_WIDTH budgets the option row padding/gaps, three count badges plus separators, an optional btn-icon edit affordance, and scrollbar allowance from the existing token-sized CSS.
*/
export const OPTION_DECORATIONS_WIDTH = 200;
export interface ComputeMenuWidthInput {
longestNameWidth: number;
triggerWidth: number;
viewportWidth: number;
horizontalPadding?: number;
minWidth?: number;
}
export function computeMenuWidth({
longestNameWidth,
triggerWidth,
viewportWidth,
horizontalPadding = DEFAULT_MENU_HORIZONTAL_PADDING,
minWidth = DEFAULT_MENU_MIN_WIDTH,
}: ComputeMenuWidthInput): number {
const contentWidth = Math.max(0, longestNameWidth) + OPTION_DECORATIONS_WIDTH;
const desired = Math.max(triggerWidth, contentWidth, minWidth);
return Math.min(desired, viewportWidth - horizontalPadding * 2);
}
function getCounts(counts: Map<string, WorkflowStatusCounts>, workflowId: string): WorkflowStatusCounts {
return counts.get(workflowId) ?? ZERO_COUNTS;
@@ -68,12 +98,24 @@ export function WorkflowSwitcher({ workflows, value, onChange, counts, onOpen, l
const triggerRef = useRef<HTMLButtonElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const measurementCanvasRef = useRef<HTMLCanvasElement | null>(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;
const selectedCounts = selectedWorkflow ? getCounts(counts, selectedWorkflow.id) : ZERO_COUNTS;
const measureLongestOptionNameWidth = useCallback((names: string[]) => {
const trigger = triggerRef.current;
if (!trigger) return 0;
const canvas = measurementCanvasRef.current ?? document.createElement("canvas");
measurementCanvasRef.current = canvas;
const context = canvas.getContext("2d");
if (!context) return 0;
context.font = getComputedStyle(trigger).font;
return names.reduce((longestWidth, name) => Math.max(longestWidth, context.measureText(name).width), 0);
}, []);
const updateDropdownPosition = useCallback(() => {
const trigger = triggerRef.current;
if (!trigger) return;
@@ -82,7 +124,7 @@ export function WorkflowSwitcher({ workflows, value, onChange, counts, onOpen, l
const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
const offsetTop = window.visualViewport?.offsetTop ?? 0;
const offsetLeft = window.visualViewport?.offsetLeft ?? 0;
const horizontalPadding = 16;
const horizontalPadding = DEFAULT_MENU_HORIZONTAL_PADDING;
const verticalPadding = 16;
const gap = 4;
const preferredHeight = Math.min(viewportHeight * 0.6, 320);
@@ -94,14 +136,15 @@ export function WorkflowSwitcher({ workflows, value, onChange, counts, onOpen, l
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 longestNameWidth = measureLongestOptionNameWidth(workflows.map((workflow) => workflow.name));
const width = computeMenuWidth({ longestNameWidth, triggerWidth: rect.width, viewportWidth, horizontalPadding });
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 });
}, []);
}, [measureLongestOptionNameWidth, workflows]);
useEffect(() => {
onOpenRef.current = onOpen;

View File

@@ -1,8 +1,9 @@
import { readFileSync } from "node:fs";
import { fireEvent, render, screen, within } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { BoardWorkflowDefinition } from "../../api";
import { loadAllAppCssBaseOnly } from "../../test/cssFixture";
import { WorkflowSwitcher } from "../WorkflowSwitcher";
import { computeMenuWidth, OPTION_DECORATIONS_WIDTH, WorkflowSwitcher } from "../WorkflowSwitcher";
import type { WorkflowStatusCounts } from "../workflowStatusCounts";
const workflows: BoardWorkflowDefinition[] = [
@@ -27,6 +28,39 @@ function cssRuleFor(css: string, selector: string) {
return css.match(new RegExp(`${escaped}\\s*\\{([^}]*)\\}`))?.[1] ?? "";
}
function menuWidth() {
const menu = screen.getByRole("listbox", { name: "Workflow" });
return Number.parseFloat(menu.style.width);
}
beforeEach(() => {
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("computeMenuWidth", () => {
it("keeps short-name menus at or above the min width and trigger width", () => {
expect(computeMenuWidth({ longestNameWidth: 12, triggerWidth: 180, viewportWidth: 1024 })).toBe(240);
expect(computeMenuWidth({ longestNameWidth: 12, triggerWidth: 280, viewportWidth: 1024 })).toBe(280);
});
it("grows with long names plus the option decorations budget", () => {
const longestNameWidth = 420;
expect(computeMenuWidth({ longestNameWidth, triggerWidth: 180, viewportWidth: 1024 })).toBe(longestNameWidth + OPTION_DECORATIONS_WIDTH);
});
it("caps content-driven width to the padded viewport", () => {
expect(computeMenuWidth({ longestNameWidth: 1200, triggerWidth: 180, viewportWidth: 390, horizontalPadding: 16 })).toBe(358);
});
it("uses trigger dominance when the collapsed control is wider than the content budget", () => {
expect(computeMenuWidth({ longestNameWidth: 20, triggerWidth: 360, viewportWidth: 1024 })).toBe(360);
});
});
describe("WorkflowSwitcher", () => {
it("renders the active workflow without compact counts while collapsed", () => {
render(
@@ -58,6 +92,46 @@ describe("WorkflowSwitcher", () => {
expect(screen.queryByRole("listbox", { name: "Workflow" })).not.toBeInTheDocument();
});
it("widens the open listbox for long workflow names without changing the trigger sizing contract", () => {
/* Surface Enumeration: this covers short/long populated options through the shared Board/ListView switcher component seam, with CSS assertions for the collapsed trigger and mobile viewport overflow safety net. */
const ctxStub = {
font: "",
measureText: (text: string) => ({ width: text.length * 8 }),
};
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(ctxStub as CanvasRenderingContext2D);
Object.defineProperty(window, "innerWidth", { configurable: true, value: 1024 });
const { unmount } = render(<WorkflowSwitcher workflows={workflows} value="coding" onChange={vi.fn()} counts={countMap()} />);
fireEvent.click(screen.getByTestId("workflow-switcher"));
const shortWidth = menuWidth();
unmount();
render(
<WorkflowSwitcher
workflows={[
workflows[0],
{ id: "long", name: "Release Engineering Workflow With Very Long Name", columns: [] },
]}
value="coding"
onChange={vi.fn()}
counts={countMap()}
/>,
);
fireEvent.click(screen.getByTestId("workflow-switcher"));
const longWidth = menuWidth();
expect(shortWidth).toBeGreaterThanOrEqual(240);
expect(longWidth).toBeGreaterThan(shortWidth);
const css = loadAllAppCssBaseOnly();
const triggerRule = cssRuleFor(css, ".workflow-switcher-trigger");
expect(triggerRule).toMatch(/max-width:\s*calc\(var\(--space-xl\) \* 12\)/);
const currentNameRule = cssRuleFor(css, ".workflow-switcher-current-name,\n.workflow-switcher-option-name");
expect(currentNameRule).toMatch(/text-overflow:\s*ellipsis/);
const switcherCss = readFileSync("app/components/WorkflowSwitcher.css", "utf8");
expect(switcherCss).toMatch(/@media\s*\(max-width:\s*768px\)[\s\S]*max-width:\s*calc\(100vw - var\(--space-xl\)\);/);
});
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} />);