fix: improve condensed workflow hover interaction (#3746)
This commit is contained in:
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user