fix: improve condensed workflow hover interaction (#3746)

This commit is contained in:
Carlos Florêncio
2026-09-17 10:43:42 +01:00
committed by GitHub
parent b1b8857146
commit 580665318a
10 changed files with 300 additions and 53 deletions
@@ -3,10 +3,13 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { i18n } from "@/lib/i18n";
import type { WorkflowMovePreviewResponse } from "@/lib/api";
import {
CompactWorkflowMovePreview,
WorkflowMovePreviewDisclosure,
workflowMovePreviewChangeCount,
} from "./workflow-move-preview";
const PREVIEW_DETAILS_TEST_ID = "workflow-move-preview-details";
afterEach(async () => {
cleanup();
await i18n.changeLanguage("en");
@@ -60,6 +63,89 @@ function makeState(preview: WorkflowMovePreviewResponse | null) {
};
}
function makeSuccessState(preview = makePreview()) {
return {
status: "success" as const,
preview,
error: null,
retry: vi.fn(),
};
}
describe("CompactWorkflowMovePreview", () => {
it("hides the preview while it is idle", () => {
render(
<CompactWorkflowMovePreview
state={{ status: "idle", preview: null, error: null, retry: vi.fn() }}
isTouchSurface={false}
expanded={false}
/>,
);
expect(screen.queryByTestId("workflow-move-preview")).toBeNull();
expect(screen.queryByTestId("workflow-move-preview-loading")).toBeNull();
expect(screen.queryByTestId("workflow-move-preview-error")).toBeNull();
});
it("renders localized loading and retryable error states", async () => {
await i18n.changeLanguage("pt-pt");
const retry = vi.fn();
const { rerender } = render(
<CompactWorkflowMovePreview
state={{ status: "loading", preview: null, error: null, retry }}
isTouchSurface
expanded={false}
/>,
);
expect(screen.getByTestId("workflow-move-preview-loading").textContent).toContain(
"A verificar a sessão...",
);
rerender(
<CompactWorkflowMovePreview
state={{ status: "error", preview: null, error: new Error("offline"), retry }}
isTouchSurface
expanded={false}
/>,
);
expect(screen.getByTestId("workflow-move-preview-error").textContent).toContain(
"Pré-visualização indisponível",
);
fireEvent.click(screen.getByTestId("workflow-move-preview-retry"));
expect(retry).toHaveBeenCalledOnce();
});
it("shows the compact success summary without details by default", () => {
render(
<CompactWorkflowMovePreview
state={makeSuccessState()}
isTouchSurface={false}
expanded={false}
/>,
);
expect(screen.getByRole("status").textContent).toContain(
"Reuse current session · gpt-5.6-astra to gpt-5.6-luna · +2 changes",
);
expect(screen.queryByTestId(PREVIEW_DETAILS_TEST_ID)).toBeNull();
});
it("reveals the full localized details when expanded", async () => {
await i18n.changeLanguage("pt-pt");
render(
<CompactWorkflowMovePreview state={makeSuccessState()} isTouchSurface={false} expanded />,
);
expect(screen.getByTestId(PREVIEW_DETAILS_TEST_ID)).toBeTruthy();
expect(screen.getByText("Analysis conversation")).toBeTruthy();
expect(screen.getByText("Luna")).toBeTruthy();
expect(screen.getByText(/Esforço de raciocínio/)).toBeTruthy();
expect(screen.getByText(/Enviar o prompt do passo/)).toBeTruthy();
});
});
describe("WorkflowMovePreviewDisclosure", () => {
it("keeps the compact summary to the recipient and model lines", () => {
render(
@@ -70,7 +156,7 @@ describe("WorkflowMovePreviewDisclosure", () => {
expect(screen.getByText("Reuse current session")).toBeTruthy();
expect(screen.getByText("gpt-5.6-astra to gpt-5.6-luna")).toBeTruthy();
expect(screen.getByText("+2 changes")).toBeTruthy();
expect(screen.queryByTestId("workflow-move-preview-details")).toBeNull();
expect(screen.queryByTestId(PREVIEW_DETAILS_TEST_ID)).toBeNull();
});
it("shows the full planned change and recipient details on demand", () => {
@@ -80,7 +166,7 @@ describe("WorkflowMovePreviewDisclosure", () => {
fireEvent.click(screen.getByTestId("workflow-move-preview-details-toggle"));
expect(screen.getByTestId("workflow-move-preview-details")).toBeTruthy();
expect(screen.getByTestId(PREVIEW_DETAILS_TEST_ID)).toBeTruthy();
expect(screen.getByText("Analysis conversation")).toBeTruthy();
expect(screen.getByText("Luna")).toBeTruthy();
expect(screen.getByText(/Reasoning effort/)).toBeTruthy();
@@ -278,6 +278,74 @@ function WorkflowMovePreviewDetails({
);
}
type CompactWorkflowMovePreviewProps = Omit<WorkflowMovePreviewDisclosureProps, "className"> & {
expanded: boolean;
};
export function CompactWorkflowMovePreview({
state,
isTouchSurface,
expanded,
}: CompactWorkflowMovePreviewProps) {
const { t } = useTranslation();
if (state.status === "idle") return null;
if (state.status === "loading") {
return (
<div
role="status"
data-testid="workflow-move-preview-loading"
className="pl-4 text-left text-[11px] text-muted-foreground"
>
{t("task:workflowMovePreviewChecking")}
</div>
);
}
if (state.status === "error" || !state.preview) {
return (
<div
role="status"
data-testid="workflow-move-preview-error"
className="flex min-w-0 items-center gap-1 pl-4 text-left text-[11px] text-muted-foreground"
>
<span className="min-w-0 truncate">{t("task:workflowMovePreviewUnavailable")}</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={state.retry}
className={cn(
"shrink-0 cursor-pointer px-1.5 text-[11px]",
isTouchSurface ? "min-h-11" : "h-7",
)}
data-testid="workflow-move-preview-retry"
>
<IconRefresh className="h-3 w-3" aria-hidden="true" />
{t("task:workflowMovePreviewRetry")}
</Button>
</div>
);
}
const model = modelSummaryParts(t, state.preview);
const summary = [
outcomeLabel(t, state.preview),
model.label,
...(model.changeCount > 0
? [t("task:workflowMovePreviewAdditionalChanges", { count: model.changeCount })]
: []),
].join(" · ");
return (
<div
data-testid="workflow-move-preview"
className="grid min-w-0 gap-2 pl-4 text-left text-[11px] text-muted-foreground"
>
<div role="status" aria-live="polite" className="truncate" title={summary}>
{summary}
</div>
{expanded && <WorkflowMovePreviewDetails preview={state.preview} t={t} />}
</div>
);
}
export function WorkflowMovePreviewDisclosure({
state,
isTouchSurface,
@@ -1,7 +1,7 @@
"use client";
import { Button } from "@kandev/ui/button";
import { IconAdjustments, IconArrowRight } from "@tabler/icons-react";
import { IconChevronDown, IconArrowRight } from "@tabler/icons-react";
import { cn } from "@kandev/ui/lib/utils";
import { useTranslation } from "react-i18next";
import type { WorkflowMoveEntryOptions } from "@/lib/api/domains/kanban-api";
@@ -34,6 +34,18 @@ export function StepDisclosureRowActions({
return (
<div className="flex shrink-0 items-center gap-1">
<Button
type="button"
data-testid={`workflow-step-disclosure-move-${stepId}`}
size="sm"
variant="outline"
className={cn("shrink-0 cursor-pointer rounded-sm px-2.5 text-xs", buttonSizeClass)}
disabled={movePending}
onClick={() => void onMove(stepId, entryOptions ?? workflowMoveOptionsPayload(draft))}
>
{isMoving ? t("task:moving") : t("task:moveHere")}
<IconArrowRight className="h-3 w-3" />
</Button>
<Button
type="button"
data-testid={`workflow-step-disclosure-options-${stepId}`}
@@ -42,25 +54,15 @@ export function StepDisclosureRowActions({
aria-expanded={showOptions}
aria-label={t("task:workflowMoveOptions")}
className={cn(
"shrink-0 cursor-pointer rounded-sm px-2 text-muted-foreground",
"w-7 shrink-0 cursor-pointer rounded-sm p-0 text-muted-foreground [@media(pointer:coarse)]:min-w-11",
buttonSizeClass,
showOptions && "bg-muted/60 text-foreground",
)}
onClick={onToggleOptions}
>
<IconAdjustments className="h-3.5 w-3.5" />
</Button>
<Button
type="button"
data-testid={`workflow-step-disclosure-move-${stepId}`}
size="sm"
variant="default"
className={cn("shrink-0 cursor-pointer rounded-sm px-2.5 text-xs", buttonSizeClass)}
disabled={movePending}
onClick={() => void onMove(stepId, entryOptions ?? workflowMoveOptionsPayload(draft))}
>
<IconArrowRight className="h-3 w-3" />
{isMoving ? t("task:moving") : t("task:moveHere")}
<IconChevronDown
className={cn("h-3.5 w-3.5 transition-transform", showOptions && "rotate-180")}
/>
</Button>
</div>
);
@@ -1,18 +1,19 @@
"use client";
import { useState } from "react";
import { useState, type ReactNode } from "react";
import { StepDisclosureRowActions } from "./workflow-step-disclosure-actions";
import {
WorkflowMoveOptionsFields,
useWorkflowMoveOptionsForm,
workflowMoveOptionsPayload,
} from "./workflow-move-options";
import { WorkflowMovePreviewDisclosure } from "./workflow-move-preview";
import { CompactWorkflowMovePreview } from "./workflow-move-preview";
import { useWorkflowMovePreview } from "@/hooks/domains/kanban/use-workflow-move-preview";
import { useWorkflowMovePreviewRevision } from "@/hooks/domains/kanban/use-workflow-move-preview-revision";
import type { WorkflowMoveEntryOptions } from "@/lib/api/domains/kanban-api";
type StepDisclosureMoveControlsProps = {
heading: ReactNode;
stepId: string;
taskId: string;
workflowId: string;
@@ -24,6 +25,7 @@ type StepDisclosureMoveControlsProps = {
};
export function StepDisclosureMoveControls({
heading,
stepId,
taskId,
workflowId,
@@ -49,7 +51,8 @@ export function StepDisclosureMoveControls({
return (
<>
<div className="flex justify-end">
<div className="flex min-w-0 items-center gap-2">
{heading}
<StepDisclosureRowActions
stepId={stepId}
isMoving={isMoving}
@@ -62,6 +65,11 @@ export function StepDisclosureMoveControls({
onMove={onMove}
/>
</div>
<CompactWorkflowMovePreview
state={previewState}
isTouchSurface={isTouchSurface}
expanded={showOptions}
/>
{showOptions && (
<div
className="pb-1 pl-4 pr-1"
@@ -76,7 +84,6 @@ export function StepDisclosureMoveControls({
/>
</div>
)}
<WorkflowMovePreviewDisclosure state={previewState} isTouchSurface={isTouchSurface} />
</>
);
}
@@ -313,7 +313,7 @@ function CompactWorkflowDisclosureSurface({
aria-label={t("task:moveTo")}
side="bottom"
align="center"
className="w-[28rem] max-w-[calc(100vw-1rem)] p-2"
className="w-[25rem] max-w-[calc(100vw-1rem)] p-2"
onOpenAutoFocus={controls.handleOpenAutoFocus}
onCloseAutoFocus={controls.handleCloseAutoFocus}
onEscapeKeyDown={(event) => event.stopPropagation()}
@@ -422,7 +422,10 @@ function StepDisclosureBody({
return (
<div
data-testid="workflow-step-disclosure"
className="min-h-0 max-h-[70dvh] overflow-y-auto px-2 pb-[calc(1rem+env(safe-area-inset-bottom))]"
className={cn(
"min-h-0 max-h-[70dvh] space-y-1 overflow-y-auto overscroll-contain",
isTouchSurface && "px-2 pb-[calc(1rem+env(safe-area-inset-bottom))]",
)}
>
{sortedSteps.map((step, index) => {
const isCurrent = index === currentIndex;
@@ -498,40 +501,32 @@ function StepDisclosureRow({
onMove: DisclosureMove;
}) {
const { t } = useTranslation();
const heading = (
<div className="flex min-w-0 flex-1 items-center gap-2">
<StepCircleIndicator
isCurrent={isCurrent}
isCompleted={isCompleted}
isPending={progress?.isPending}
pendingLabel={
progress?.isPending ? t(workflowStepProgressTranslationKey(progress.status)) : undefined
}
/>
<span className={cn("min-w-0 truncate text-xs", getStepLabelClass(isCurrent, isCompleted))}>
{step.name}
</span>
<StepCapabilityIcons events={step.events} agentProfileId={step.agent_profile_id} />
</div>
);
return (
<div
data-testid={`workflow-step-disclosure-row-${step.id}`}
aria-current={isCurrent ? "step" : undefined}
className="flex flex-col gap-1.5 rounded-md px-2 py-1.5"
className={cn("flex flex-col gap-1 rounded-md px-2 py-2", isCurrent && "bg-primary/10")}
>
<div className="flex min-h-11 items-center gap-2">
<div className="flex min-w-0 flex-1 items-center gap-2">
<StepCircleIndicator
isCurrent={isCurrent}
isCompleted={isCompleted}
isPending={progress?.isPending}
pendingLabel={
progress?.isPending
? t(workflowStepProgressTranslationKey(progress.status))
: undefined
}
/>
<span
className={cn("min-w-0 truncate text-xs", getStepLabelClass(isCurrent, isCompleted))}
>
{step.name}
</span>
<StepCapabilityIcons events={step.events} agentProfileId={step.agent_profile_id} />
</div>
{isCurrent ? (
<span className="shrink-0 text-[11px] text-muted-foreground">
{t("task:currentStep")}
</span>
) : null}
</div>
{canMove && !isCurrent && (
{canMove && !isCurrent ? (
<StepDisclosureMoveControls
heading={heading}
stepId={step.id}
taskId={taskId}
workflowId={workflowId}
@@ -541,6 +536,15 @@ function StepDisclosureRow({
previewEnabled={previewEnabled}
onMove={onMove}
/>
) : (
<div className={cn("flex min-h-7 items-center gap-2", isTouchSurface && "min-h-11")}>
{heading}
{isCurrent && (
<span className="shrink-0 text-[11px] text-muted-foreground">
{t("task:currentStep")}
</span>
)}
</div>
)}
{progress && (
<StepProgressDetails
@@ -457,6 +457,44 @@ describe("WorkflowStepper compact disclosure options", () => {
fireEvent.click(screen.getByTestId("workflow-step-disclosure-options-c"));
expect(screen.getByTestId("workflow-step-disclosure-options-panel-c")).toBeTruthy();
});
it("keeps the direct move action before the options toggle in the DOM", () => {
collapsedMock.mockReturnValue(true);
render(
<WorkflowStepper
steps={DISCLOSURE_STEPS}
currentStepId="b"
taskId={TASK_ID}
workflowId={WORKFLOW_ID}
/>,
);
fireEvent.mouseEnter(screen.getByRole("button", { name: TRIGGER_LABEL }));
const row = screen.getByTestId("workflow-step-disclosure-row-c");
expect(
Array.from(row.querySelectorAll<HTMLButtonElement>("button")).map(
(button) => button.dataset.testid,
),
).toEqual(["workflow-step-disclosure-move-c", "workflow-step-disclosure-options-c"]);
});
it("keeps completed and future non-movable labels visually muted", () => {
collapsedMock.mockReturnValue(true);
render(
<WorkflowStepper
steps={DISCLOSURE_STEPS}
currentStepId="b"
taskId={TASK_ID}
workflowId={WORKFLOW_ID}
/>,
);
fireEvent.mouseEnter(screen.getByRole("button", { name: TRIGGER_LABEL }));
expect(screen.getByText("Spec").className).toContain("text-muted-foreground");
expect(screen.getByText("Done").className).toContain("text-muted-foreground/60");
});
});
describe("WorkflowStepper compact disclosure preview queue", () => {
@@ -54,11 +54,23 @@ test.describe("mobile: workflow move preview", () => {
await expect(preview).toContainText("Reuse current session");
await expect(preview).toContainText("mock-fast");
const detailsToggle = preview.getByTestId("workflow-move-preview-details-toggle");
const move = row.getByTestId(`workflow-step-disclosure-move-${targetStep.id}`);
await expect(move).toBeVisible();
await expect(row.getByTestId("workflow-move-preview-details")).toHaveCount(0);
const labelBox = await row.getByText(targetStep.name, { exact: true }).boundingBox();
const moveBox = await move.boundingBox();
expect(labelBox).not.toBeNull();
expect(moveBox).not.toBeNull();
expect(
Math.abs(labelBox!.y + labelBox!.height / 2 - moveBox!.y - moveBox!.height / 2),
).toBeLessThan(2);
await expect(preview).toHaveCSS("text-align", "left");
const detailsToggle = row.getByTestId(`workflow-step-disclosure-options-${targetStep.id}`);
const toggleBox = await detailsToggle.boundingBox();
expect(toggleBox).not.toBeNull();
if (!toggleBox) return;
expect(toggleBox.height).toBeGreaterThanOrEqual(44);
expect(toggleBox.width).toBeGreaterThanOrEqual(44);
await detailsToggle.tap();
await expect(row.getByTestId("workflow-move-preview-details")).toBeVisible();
@@ -1,6 +1,7 @@
import type { Locator } from "@playwright/test";
import { expect, test } from "../../fixtures/test-base";
import { SessionPage } from "../../pages/session-page";
import { waitForFiniteAnimations } from "../../helpers/animations";
type Box = { x: number; y: number; width: number; height: number };
@@ -65,6 +66,19 @@ test.describe("Workflow step progress", () => {
const targetRow = disclosure.getByTestId(`workflow-step-disclosure-row-${targetStep.id}`);
const marker = targetRow.locator("[data-marker-state]");
await expect(marker).toBeVisible();
const move = targetRow.getByTestId(`workflow-step-disclosure-move-${targetStep.id}`);
await expect(move).toBeVisible();
await waitForFiniteAnimations(testPage.locator('[data-slot="popover-content"]:visible'));
const labelBox = requireBox(
await targetRow.getByText(targetStep.name, { exact: true }).boundingBox(),
"step label",
);
const moveBox = requireBox(await move.boundingBox(), "direct move button");
expect(
Math.abs(labelBox.y + labelBox.height / 2 - moveBox.y - moveBox.height / 2),
).toBeLessThan(2);
expect(moveBox.height).toBeCloseTo(28, 0);
await expect(targetRow.getByTestId("workflow-move-preview-details")).toHaveCount(0);
const before = await marker.boundingBox();
expect(before).not.toBeNull();