fix(web): collapse dockview workflow stepper to current step when cramped (#1309)

* fix(web): collapse dockview workflow stepper to current step when cramped

On narrow windows the full workflow step indicator outgrew the top-bar
center track and rendered beneath the right-side action buttons. Switch
the grid so the breadcrumb takes only its content width and the center
track absorbs the real leftover space, then collapse the stepper to a
minimal current-step view (via the shared useToolbarCollapsed hook) only
when the full version genuinely cannot fit.

* fix(web): stabilize stepper test id and drop margin skewing collapse

Move data-testid="workflow-stepper" to the persistent outer container so
the e2e locator resolves in both expanded and collapsed modes, and remove
mx-2 from the w-full measuring container (it overflowed the wrapper and
shifted the collapse threshold by ~8px); the grid gap-2 already spaces the
columns.

* refactor(web): trim verbose stepper collapse comment

* fix(web): tag archived minimal badge with stepper test id

Give the archived branch of MinimalWorkflowStepper the same
workflow-stepper-minimal test id as the current-step branch so collapsed
mode is detectable for archived tasks, and tighten two comments.

* test(web): collapse multi-line comments in stepper test to single lines

* fix(web): drop aria-current on minimal stepper no-current fallback

When no step is current the minimal view falls back to the first step;
it should not claim aria-current="step", matching the expanded path where
no step is marked current in that case.
This commit is contained in:
José Almeida
2026-06-08 19:34:40 +01:00
committed by GitHub
parent fc4bf8a40c
commit 5d6fe29293
3 changed files with 175 additions and 19 deletions
+2 -2
View File
@@ -70,7 +70,7 @@ const TaskTopBar = memo(function TaskTopBar({
return (
<header
data-testid="task-topbar"
className="@container/topbar grid h-10 shrink-0 grid-cols-[minmax(0,1fr)_minmax(0,auto)_minmax(0,1fr)] items-center gap-2 overflow-hidden px-3 py-1 border-b border-border"
className="@container/topbar grid h-10 shrink-0 grid-cols-[minmax(0,auto)_minmax(0,1fr)_auto] items-center gap-2 overflow-hidden px-3 py-1 border-b border-border"
>
<TopBarLeft
taskId={taskId}
@@ -79,7 +79,7 @@ const TaskTopBar = memo(function TaskTopBar({
remoteExecutorType={remoteExecutorType}
isArchived={isArchived}
/>
<div className="min-w-0 justify-self-center overflow-hidden">
<div className="min-w-0 justify-self-stretch overflow-hidden">
{workflowSteps && workflowSteps.length > 0 && (
<WorkflowStepper
steps={workflowSteps}
@@ -0,0 +1,90 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { WorkflowStepper, type WorkflowStepperStep } from "./workflow-stepper";
afterEach(() => {
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(<WorkflowStepper steps={STEPS} currentStepId="b" />);
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(<WorkflowStepper steps={STEPS} currentStepId="b" />);
// 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(<WorkflowStepper steps={STEPS} currentStepId={null} />);
// 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(<WorkflowStepper steps={STEPS} currentStepId="b" isArchived />);
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(<WorkflowStepper steps={[]} currentStepId={null} />);
expect(container.innerHTML).toBe("");
});
});
+83 -17
View File
@@ -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<HTMLDivElement>(null);
const isCollapsed = useToolbarCollapsed(containerRef);
if (sortedSteps.length === 0) return null;
return (
<div
ref={containerRef}
data-testid="workflow-stepper"
className="flex items-center gap-0 overflow-x-auto flex-shrink min-w-0 mx-2"
className="flex w-full min-w-0 items-center justify-center gap-0 overflow-hidden"
>
{sortedSteps.map((step, index) => (
<WorkflowStepItem
key={step.id}
step={step}
index={index}
{isCollapsed ? (
<MinimalWorkflowStepper
sortedSteps={sortedSteps}
currentIndex={currentIndex}
isArchived={isArchived}
taskId={taskId}
workflowId={workflowId}
movingToStepId={movingToStepId}
onMove={handleMove}
/>
))}
{isArchived && (
) : (
<>
<div className="h-px w-6 shrink-0 bg-border" />
<span className="text-[11px] font-medium text-amber-500 bg-amber-500/15 px-2 py-0.5 rounded-md whitespace-nowrap">
Archived
</span>
<div className="flex items-center gap-0">
{sortedSteps.map((step, index) => (
<WorkflowStepItem
key={step.id}
step={step}
index={index}
currentIndex={currentIndex}
isArchived={isArchived}
taskId={taskId}
workflowId={workflowId}
movingToStepId={movingToStepId}
onMove={handleMove}
/>
))}
</div>
{isArchived && (
<>
<div className="h-px w-6 shrink-0 bg-border" />
<span className="text-[11px] font-medium text-amber-500 bg-amber-500/15 px-2 py-0.5 rounded-md whitespace-nowrap">
Archived
</span>
</>
)}
</>
)}
</div>
);
});
/** 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 (
<span
data-testid="workflow-stepper-minimal"
className="text-[11px] font-medium text-amber-500 bg-amber-500/15 px-2 py-0.5 rounded-md whitespace-nowrap"
>
Archived
</span>
);
}
const current = currentIndex >= 0 ? sortedSteps[currentIndex] : sortedSteps[0];
if (!current) return null;
return (
<div
data-testid="workflow-stepper-minimal"
className="flex min-w-0 items-center gap-1.5 rounded-md px-2 py-0.5"
>
<div
data-testid={`workflow-step-${current.name}`}
aria-current={currentIndex >= 0 ? "step" : undefined}
className="flex min-w-0 items-center gap-1.5 text-xs"
>
<StepCircleIndicator isCurrent isCompleted={false} />
<span className="truncate text-xs font-medium leading-none text-foreground">
{current.name}
</span>
</div>
{sortedSteps.length > 1 && (
<span className="shrink-0 text-[11px] tabular-nums leading-none text-muted-foreground">
{(currentIndex >= 0 ? currentIndex : 0) + 1}/{sortedSteps.length}
</span>
)}
</div>
);
}
/** Check if a step can be moved to */
function canMoveToStep(params: {
isArchived: boolean | undefined;