diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index cd23d62718..120e8cb64e 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -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) diff --git a/packages/dashboard/app/components/WorkflowSwitcher.tsx b/packages/dashboard/app/components/WorkflowSwitcher.tsx index b204797e19..17c54e31c9 100644 --- a/packages/dashboard/app/components/WorkflowSwitcher.tsx +++ b/packages/dashboard/app/components/WorkflowSwitcher.tsx @@ -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, 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(null); const dropdownRef = useRef(null); const listRef = useRef(null); + const measurementCanvasRef = useRef(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; diff --git a/packages/dashboard/app/components/__tests__/WorkflowSwitcher.test.tsx b/packages/dashboard/app/components/__tests__/WorkflowSwitcher.test.tsx index 26bb97851d..974338b06c 100644 --- a/packages/dashboard/app/components/__tests__/WorkflowSwitcher.test.tsx +++ b/packages/dashboard/app/components/__tests__/WorkflowSwitcher.test.tsx @@ -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(); + fireEvent.click(screen.getByTestId("workflow-switcher")); + const shortWidth = menuWidth(); + unmount(); + + render( + , + ); + 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();