diff --git a/apps/web/components/task/workflow-move-preview.test.tsx b/apps/web/components/task/workflow-move-preview.test.tsx index a66efc8310..2ab9186c18 100644 --- a/apps/web/components/task/workflow-move-preview.test.tsx +++ b/apps/web/components/task/workflow-move-preview.test.tsx @@ -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( + , + ); + + 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( + , + ); + + expect(screen.getByTestId("workflow-move-preview-loading").textContent).toContain( + "A verificar a sessão...", + ); + + rerender( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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(); diff --git a/apps/web/components/task/workflow-move-preview.tsx b/apps/web/components/task/workflow-move-preview.tsx index 55025e3d26..045a47f02c 100644 --- a/apps/web/components/task/workflow-move-preview.tsx +++ b/apps/web/components/task/workflow-move-preview.tsx @@ -278,6 +278,74 @@ function WorkflowMovePreviewDetails({ ); } +type CompactWorkflowMovePreviewProps = Omit & { + expanded: boolean; +}; + +export function CompactWorkflowMovePreview({ + state, + isTouchSurface, + expanded, +}: CompactWorkflowMovePreviewProps) { + const { t } = useTranslation(); + if (state.status === "idle") return null; + if (state.status === "loading") { + return ( +
+ {t("task:workflowMovePreviewChecking")} +
+ ); + } + if (state.status === "error" || !state.preview) { + return ( +
+ {t("task:workflowMovePreviewUnavailable")} + +
+ ); + } + 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 ( +
+
+ {summary} +
+ {expanded && } +
+ ); +} + export function WorkflowMovePreviewDisclosure({ state, isTouchSurface, diff --git a/apps/web/components/task/workflow-step-disclosure-actions.tsx b/apps/web/components/task/workflow-step-disclosure-actions.tsx index f41ed54776..30baee1195 100644 --- a/apps/web/components/task/workflow-step-disclosure-actions.tsx +++ b/apps/web/components/task/workflow-step-disclosure-actions.tsx @@ -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 (
+ -
); diff --git a/apps/web/components/task/workflow-step-disclosure-move-controls.tsx b/apps/web/components/task/workflow-step-disclosure-move-controls.tsx index d9ee5acf6b..ea5140a65a 100644 --- a/apps/web/components/task/workflow-step-disclosure-move-controls.tsx +++ b/apps/web/components/task/workflow-step-disclosure-move-controls.tsx @@ -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 ( <> -
+
+ {heading}
+ {showOptions && (
)} - ); } diff --git a/apps/web/components/task/workflow-step-disclosure.tsx b/apps/web/components/task/workflow-step-disclosure.tsx index 5f89982f82..cfbfe7e2dd 100644 --- a/apps/web/components/task/workflow-step-disclosure.tsx +++ b/apps/web/components/task/workflow-step-disclosure.tsx @@ -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 (
{sortedSteps.map((step, index) => { const isCurrent = index === currentIndex; @@ -498,40 +501,32 @@ function StepDisclosureRow({ onMove: DisclosureMove; }) { const { t } = useTranslation(); + const heading = ( +
+ + + {step.name} + + +
+ ); return (
-
-
- - - {step.name} - - -
- {isCurrent ? ( - - {t("task:currentStep")} - - ) : null} -
- {canMove && !isCurrent && ( + {canMove && !isCurrent ? ( + ) : ( +
+ {heading} + {isCurrent && ( + + {t("task:currentStep")} + + )} +
)} {progress && ( { 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( + , + ); + + fireEvent.mouseEnter(screen.getByRole("button", { name: TRIGGER_LABEL })); + + const row = screen.getByTestId("workflow-step-disclosure-row-c"); + expect( + Array.from(row.querySelectorAll("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( + , + ); + + 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", () => { diff --git a/apps/web/e2e/tests/workflow/mobile-workflow-move-preview.spec.ts b/apps/web/e2e/tests/workflow/mobile-workflow-move-preview.spec.ts index c1d0e0e82b..d4eefcea65 100644 --- a/apps/web/e2e/tests/workflow/mobile-workflow-move-preview.spec.ts +++ b/apps/web/e2e/tests/workflow/mobile-workflow-move-preview.spec.ts @@ -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(); diff --git a/apps/web/e2e/tests/workflow/workflow-step-progress.spec.ts b/apps/web/e2e/tests/workflow/workflow-step-progress.spec.ts index be056102ca..d031ead210 100644 --- a/apps/web/e2e/tests/workflow/workflow-step-progress.spec.ts +++ b/apps/web/e2e/tests/workflow/workflow-step-progress.spec.ts @@ -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(); diff --git a/docs/plans/workflow-move-preview/task-02-compact-preview.md b/docs/plans/workflow-move-preview/task-02-compact-preview.md index d9f35352f6..18af88cc9b 100644 --- a/docs/plans/workflow-move-preview/task-02-compact-preview.md +++ b/docs/plans/workflow-move-preview/task-02-compact-preview.md @@ -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`. diff --git a/docs/public/tasks-and-workflows.md b/docs/public/tasks-and-workflows.md index f6d63b1f80..bd462b4911 100644 --- a/docs/public/tasks-and-workflows.md +++ b/docs/public/tasks-and-workflows.md @@ -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