diff --git a/apps/web/components/task/task-top-bar.tsx b/apps/web/components/task/task-top-bar.tsx index a375824d97..e9190cee81 100644 --- a/apps/web/components/task/task-top-bar.tsx +++ b/apps/web/components/task/task-top-bar.tsx @@ -70,7 +70,7 @@ const TaskTopBar = memo(function TaskTopBar({ return (
-
+
{workflowSteps && workflowSteps.length > 0 && ( { + cleanup(); + vi.clearAllMocks(); +}); + +// useToolbarCollapsed is mocked because the test DOM can't measure offsetWidth. +const collapsedMock = vi.fn(() => false); +vi.mock("@/hooks/use-toolbar-collapsed", () => ({ + useToolbarCollapsed: () => collapsedMock(), +})); + +vi.mock("@/components/state-provider", () => ({ + useAppStore: () => undefined, +})); +vi.mock("@/lib/state/context-files-store", () => ({ + useContextFilesStore: () => vi.fn(), +})); +vi.mock("@/lib/state/layout-store", () => ({ + useLayoutStore: () => vi.fn(), +})); +vi.mock("@/lib/state/dockview-store", () => ({ + useDockviewStore: () => vi.fn(), +})); + +const STEPS: WorkflowStepperStep[] = [ + { id: "a", name: "Spec", color: "#111", position: 0 }, + { id: "b", name: "Work", color: "#222", position: 1 }, + { id: "c", name: "Review", color: "#333", position: 2 }, +]; + +describe("WorkflowStepper", () => { + it("renders every step when there is room (not collapsed)", () => { + collapsedMock.mockReturnValue(false); + render(); + + expect(screen.getByTestId("workflow-stepper")).toBeTruthy(); + expect(screen.queryByTestId("workflow-stepper-minimal")).toBeNull(); + // All steps render under the persistent outer container. + expect(screen.getByTestId("workflow-step-Spec")).toBeTruthy(); + expect(screen.getByTestId("workflow-step-Work")).toBeTruthy(); + expect(screen.getByTestId("workflow-step-Review")).toBeTruthy(); + }); + + it("collapses to only the current step when space runs out", () => { + collapsedMock.mockReturnValue(true); + render(); + + // Outer container persists across variants (stable e2e locator); minimal child marks collapsed state. + expect(screen.getByTestId("workflow-stepper")).toBeTruthy(); + expect(screen.getByTestId("workflow-stepper-minimal")).toBeTruthy(); + + // Current step keeps its test id + aria-current in either variant. + const current = screen.getByTestId("workflow-step-Work"); + expect(current.getAttribute("aria-current")).toBe("step"); + expect(screen.queryByTestId("workflow-step-Spec")).toBeNull(); + expect(screen.queryByTestId("workflow-step-Review")).toBeNull(); + + // Position indicator reflects the current step out of the total. + expect(screen.getByText("2/3")).toBeTruthy(); + }); + + it("falls back to the first step when collapsed with no current step", () => { + collapsedMock.mockReturnValue(true); + render(); + + // Fallback step isn't the real current step, so it must not claim aria-current. + expect(screen.getByTestId("workflow-step-Spec").getAttribute("aria-current")).toBeNull(); + expect(screen.getByText("1/3")).toBeTruthy(); + }); + + it("shows the archived badge instead of a step when collapsed and archived", () => { + collapsedMock.mockReturnValue(true); + render(); + + expect(screen.getByText("Archived")).toBeTruthy(); + // Archived badge carries the minimal test id for collapsed-mode detection. + expect(screen.getByTestId("workflow-stepper-minimal")).toBeTruthy(); + expect(screen.queryByTestId("workflow-step-Work")).toBeNull(); + }); + + it("renders nothing when there are no steps", () => { + collapsedMock.mockReturnValue(false); + const { container } = render(); + expect(container.innerHTML).toBe(""); + }); +}); diff --git a/apps/web/components/task/workflow-stepper.tsx b/apps/web/components/task/workflow-stepper.tsx index fff950ecb2..939152eb96 100644 --- a/apps/web/components/task/workflow-stepper.tsx +++ b/apps/web/components/task/workflow-stepper.tsx @@ -1,6 +1,6 @@ "use client"; -import { memo, useCallback, useMemo, useState } from "react"; +import { memo, useCallback, useMemo, useRef, useState } from "react"; import { cn } from "@kandev/ui/lib/utils"; import { HoverCard, HoverCardContent, HoverCardTrigger } from "@kandev/ui/hover-card"; import { Button } from "@kandev/ui/button"; @@ -11,6 +11,7 @@ import { useAppStore } from "@/components/state-provider"; import { useContextFilesStore } from "@/lib/state/context-files-store"; import { useLayoutStore } from "@/lib/state/layout-store"; import { useDockviewStore } from "@/lib/state/dockview-store"; +import { useToolbarCollapsed } from "@/hooks/use-toolbar-collapsed"; import type { KanbanStepEvents } from "@/lib/state/slices/kanban/types"; type Step = { @@ -102,38 +103,103 @@ const WorkflowStepper = memo(function WorkflowStepper({ [taskId, workflowId, disablePlanMode], ); + // Collapse to a minimal view when the full stepper can't fit (w-full keeps the measurement track-driven). + const containerRef = useRef(null); + const isCollapsed = useToolbarCollapsed(containerRef); + if (sortedSteps.length === 0) return null; return (
- {sortedSteps.map((step, index) => ( - - ))} - {isArchived && ( + ) : ( <> -
- - Archived - +
+ {sortedSteps.map((step, index) => ( + + ))} +
+ {isArchived && ( + <> +
+ + Archived + + + )} )}
); }); +/** Minimal stepper: current step only (or archived badge), keeping the per-step test id + aria-current. */ +function MinimalWorkflowStepper({ + sortedSteps, + currentIndex, + isArchived, +}: { + sortedSteps: Step[]; + currentIndex: number; + isArchived?: boolean; +}) { + if (isArchived) { + return ( + + Archived + + ); + } + + const current = currentIndex >= 0 ? sortedSteps[currentIndex] : sortedSteps[0]; + if (!current) return null; + + return ( +
+
= 0 ? "step" : undefined} + className="flex min-w-0 items-center gap-1.5 text-xs" + > + + + {current.name} + +
+ {sortedSteps.length > 1 && ( + + {(currentIndex >= 0 ? currentIndex : 0) + 1}/{sortedSteps.length} + + )} +
+ ); +} + /** Check if a step can be moved to */ function canMoveToStep(params: { isArchived: boolean | undefined;