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();
@@ -192,3 +192,14 @@ Final post-remediation verification passed on 2026-09-15:
`pnpm run i18n:check`, and `pnpm run i18n:ratchet`.
- Desktop workflow move-preview E2E: 2 tests passed.
- Mobile workflow move-preview E2E: 1 test passed.
## Pull request follow-up verification
The condensed disclosure review follow-up preserves the visual step-label hierarchy,
keeps keyboard focus order aligned with the visible actions, and narrows the compact
preview props so unsupported styling props cannot be silently ignored.
Verification passed:
- Focused Vitest: `workflow-move-preview.test.tsx` and `workflow-stepper.test.tsx`.
- Changed-file ESLint and `git diff --check`.
+7 -2
View File
@@ -55,8 +55,13 @@ the task idle without a recipient when the destination has no launch turn. The
second line shows the current model or an expected model change and counts
additional settings changes. Use the info button for the session, profile, model,
context reset, source-session disposition, and prompt dispatch information.
The preview sits in a centered footer below the move controls. The next-step
button above chat shows the same footer in its options popover or touch drawer.
In the condensed topbar workflow, each eligible step has a **Move here** button
beside its name. A single line below summarizes the session and model. Expand
the row's chevron for full transition details and one-time move options. Moving
does not require expanding the row. The current step has a highlighted background.
On touch devices, the same controls appear in the bottom drawer.
The full stepper and the next-step button above chat retain the centered preview
footer in their options popover or touch drawer.
Changing the options refreshes that preview.
The preview is advisory. **Move here** checks routing, permissions, WIP, and
current session state again when the move runs. A preview can therefore change