diff --git a/apps/web/components/confirmation/action-confirm-popover.tsx b/apps/web/components/confirmation/action-confirm-popover.tsx index bc81f71771..367c16915a 100644 --- a/apps/web/components/confirmation/action-confirm-popover.tsx +++ b/apps/web/components/confirmation/action-confirm-popover.tsx @@ -13,6 +13,7 @@ import { export type ActionConfirmPopoverProps = { open: boolean; + disabled?: boolean; anchorRef: RefObject; focusBoundaryRef?: RefObject; title: ReactNode; @@ -36,6 +37,7 @@ export type ActionConfirmPopoverProps = { */ export function ActionConfirmPopover({ open, + disabled = false, anchorRef, focusBoundaryRef, title, @@ -77,6 +79,7 @@ export function ActionConfirmPopover({ }; const handleConfirm = () => { + if (disabled) return; if (!isConnected(anchorRef.current)) { handleOpenChange(false); return; @@ -104,6 +107,7 @@ export function ActionConfirmPopover({ confirmAriaLabel={confirmAriaLabel} confirmTestId={confirmTestId} testId={testId} + disabled={disabled} cancelRef={cancelRef} focusBoundaryRef={focusBoundaryRef} confirmedRef={confirmedRef} @@ -125,6 +129,7 @@ type ActionConfirmPopoverContentProps = { confirmAriaLabel?: string; confirmTestId?: string; testId: string; + disabled: boolean; cancelRef: RefObject; focusBoundaryRef?: RefObject; confirmedRef: { current: boolean }; @@ -143,6 +148,7 @@ function ActionConfirmPopoverContent({ confirmAriaLabel, confirmTestId, testId, + disabled, cancelRef, focusBoundaryRef, confirmedRef, @@ -184,6 +190,7 @@ function ActionConfirmPopoverContent({ ref={cancelRef} type="button" variant="outline" + disabled={disabled} className="min-h-11 px-3 transition-[color,background-color,border-color,transform] duration-100 active:scale-[0.96]" onClick={onCancel} > @@ -192,6 +199,7 @@ function ActionConfirmPopoverContent({ + + + + + ); +} diff --git a/apps/web/components/task/task-plan-revision-restore-actions.tsx b/apps/web/components/task/task-plan-revision-restore-actions.tsx new file mode 100644 index 0000000000..11628710d3 --- /dev/null +++ b/apps/web/components/task/task-plan-revision-restore-actions.tsx @@ -0,0 +1,118 @@ +"use client"; + +import { useRef } from "react"; +import { IconRestore } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { useTranslation } from "react-i18next"; + +import { ActionConfirmPopover } from "@/components/confirmation/action-confirm-popover"; +import { InlineConfirmActions } from "@/components/confirmation/inline-confirm-actions"; +import type { TaskPlanRevision } from "@/lib/types/http"; + +type RevisionRestoreProps = { + revision: TaskPlanRevision; + isCurrent: boolean; + isSaving: boolean; + rowConfirmTarget: TaskPlanRevision | null; + isFinePointer: boolean; + onRevertCancel: () => void; + onRevert: (revision: TaskPlanRevision) => Promise; +}; + +type RevisionRestoreActionProps = RevisionRestoreProps & { + onRevertRequest: (revision: TaskPlanRevision) => void; +}; + +export function RevisionRestoreAction({ + revision, + isCurrent, + isSaving, + rowConfirmTarget, + isFinePointer, + onRevertRequest, + onRevertCancel, + onRevert, +}: RevisionRestoreActionProps) { + const { t } = useTranslation(); + const restoreButtonRef = useRef(null); + const isConfirming = rowConfirmTarget?.id === revision.id; + const version = revision.revision_number; + const restoreLabel = t("task:restoreVersion", { version }); + + if (isCurrent) return null; + + return ( + <> + {(!isConfirming || isFinePointer) && ( + + )} + {isFinePointer && ( + { + if (!nextOpen) onRevertCancel(); + }} + onCancel={onRevertCancel} + onConfirm={() => onRevert(revision)} + /> + )} + + ); +} + +export function RevisionInlineConfirmation({ + revision, + isCurrent, + isSaving, + isFinePointer, + rowConfirmTarget, + onRevertCancel, + onRevert, +}: RevisionRestoreProps) { + const { t } = useTranslation(); + if (isCurrent || isFinePointer || rowConfirmTarget?.id !== revision.id) return null; + + const version = revision.revision_number; + const restoreLabel = t("task:restoreVersion", { version }); + return ( +
+ onRevert(revision)} + /> +
+ ); +} diff --git a/apps/web/components/task/task-plan-revisions.test.tsx b/apps/web/components/task/task-plan-revisions.test.tsx new file mode 100644 index 0000000000..5501ff96cb --- /dev/null +++ b/apps/web/components/task/task-plan-revisions.test.tsx @@ -0,0 +1,177 @@ +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { TaskPlanRevision } from "@/lib/types/http"; + +import { TaskPlanRevisions } from "./task-plan-revisions"; + +const mocks = vi.hoisted(() => ({ + breakpoint: { isFinePointer: true }, + toast: { success: vi.fn(), error: vi.fn() }, +})); + +const restorePopoverTestId = "plan-revision-restore-confirm-popover"; +const restoreInlineTestId = "plan-revision-restore-inline-confirmation"; +const restoreButtonTestId = "plan-revision-revert-button"; +const restoreConfirmTestId = "plan-revision-restore-confirm"; + +vi.mock("@/hooks/use-responsive-breakpoint", () => ({ + useResponsiveBreakpoint: () => mocks.breakpoint, +})); + +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (state: unknown) => unknown) => + selector({ tasks: { activeSessionId: null }, taskSessions: { items: {} } }), +})); + +vi.mock("@/lib/toast/sonner", () => ({ toast: mocks.toast })); +vi.mock("@/components/agent-logo", () => ({ AgentLogo: () => null })); +vi.mock("./task-plan-preview-dialog", () => ({ PlanRevisionPreviewDialog: () => null })); +vi.mock("./task-plan-diff-dialog", () => ({ PlanRevisionDiffDialog: () => null })); + +function revision(revisionNumber: number): TaskPlanRevision { + return { + id: `revision-${revisionNumber}`, + task_id: "task-1", + revision_number: revisionNumber, + title: `Version ${revisionNumber}`, + content: `Plan ${revisionNumber}`, + author_kind: "user", + author_name: "user", + created_at: "2026-08-20T10:00:00.000Z", + updated_at: "2026-08-20T10:00:00.000Z", + }; +} + +function renderHistory(onRevert = vi.fn().mockResolvedValue(revision(3))) { + const props = { + taskId: "task-1", + revisions: [revision(2), revision(1)], + isLoading: false, + onOpen: vi.fn(), + onRevert, + loadRevisionContent: vi.fn().mockResolvedValue("content"), + previewRevisionId: null, + setPreviewRevision: vi.fn(), + comparePair: [null, null] as [string | null, string | null], + toggleCompareSelection: vi.fn(), + clearComparePair: vi.fn(), + }; + const view = render(); + fireEvent.click(screen.getByTestId("plan-rewind-button")); + return { + setSaving: (isSaving: boolean) => + view.rerender(), + }; +} + +function olderRevisionRow() { + return screen + .getAllByTestId("plan-revision-row") + .find((row) => row.getAttribute("data-revision-number") === "1") as HTMLElement; +} + +describe("TaskPlanRevisions restore confirmation", () => { + beforeEach(() => { + mocks.breakpoint.isFinePointer = true; + mocks.toast.success.mockReset(); + mocks.toast.error.mockReset(); + }); + + afterEach(cleanup); + + it("anchors desktop confirmation to the revision action and preserves cancel", async () => { + const onRevert = vi.fn().mockResolvedValue(revision(3)); + renderHistory(onRevert); + + const row = olderRevisionRow(); + const restore = within(row).getByTestId(restoreButtonTestId); + fireEvent.click(restore); + + const confirmation = await screen.findByTestId(restorePopoverTestId); + expect(confirmation.textContent).toContain("Restore to version 1?"); + expect(screen.queryByTestId("plan-revert-confirm-dialog")).toBeNull(); + + fireEvent.click(within(confirmation).getByRole("button", { name: "Cancel" })); + + await waitFor(() => expect(screen.queryByTestId(restorePopoverTestId)).toBeNull()); + expect(onRevert).not.toHaveBeenCalled(); + expect(document.activeElement).toBe(restore); + }); + + it("confirms the named desktop revision exactly once", async () => { + const onRevert = vi.fn().mockResolvedValue(revision(3)); + renderHistory(onRevert); + + const row = olderRevisionRow(); + fireEvent.click(within(row).getByTestId(restoreButtonTestId)); + const confirmation = await screen.findByTestId(restorePopoverTestId); + fireEvent.click(within(confirmation).getByTestId(restoreConfirmTestId)); + + await waitFor(() => expect(onRevert).toHaveBeenCalledTimes(1)); + expect(onRevert).toHaveBeenCalledWith("revision-1"); + expect(mocks.toast.success).toHaveBeenCalledWith("Plan restored to v1"); + }); + + it("morphs the phone row into 44px inline actions without an overlay", async () => { + mocks.breakpoint.isFinePointer = false; + renderHistory(); + + const row = olderRevisionRow(); + fireEvent.click(within(row).getByTestId(restoreButtonTestId)); + + const confirmation = await screen.findByTestId(restoreInlineTestId); + expect(confirmation.textContent).toContain("v1"); + expect(screen.queryByTestId(restorePopoverTestId)).toBeNull(); + expect(within(confirmation).getByRole("button", { name: "Restore v1" }).className).toContain( + "h-11", + ); + expect(within(confirmation).getByRole("button", { name: "Restore v1" }).className).toContain( + "min-w-11", + ); + }); + + it("clears the phone confirmation when plan history closes", async () => { + mocks.breakpoint.isFinePointer = false; + renderHistory(); + + fireEvent.click(within(olderRevisionRow()).getByTestId(restoreButtonTestId)); + await screen.findByTestId(restoreInlineTestId); + + fireEvent.click(screen.getByTestId("plan-rewind-button")); + await waitFor(() => expect(screen.queryByTestId("plan-revisions-popover")).toBeNull()); + fireEvent.click(screen.getByTestId("plan-rewind-button")); + + await screen.findByTestId("plan-revisions-popover"); + expect(screen.queryByTestId(restoreInlineTestId)).toBeNull(); + }); + + it.each([ + { surface: "desktop", isFinePointer: true, testId: restorePopoverTestId }, + { + surface: "phone", + isFinePointer: false, + testId: restoreInlineTestId, + }, + ])( + "disables open $surface confirmation actions when saving starts", + async ({ isFinePointer, testId }) => { + mocks.breakpoint.isFinePointer = isFinePointer; + const onRevert = vi.fn().mockResolvedValue(revision(3)); + const { setSaving } = renderHistory(onRevert); + + fireEvent.click(within(olderRevisionRow()).getByTestId(restoreButtonTestId)); + const confirmation = await screen.findByTestId(testId); + setSaving(true); + + expect( + (within(confirmation).getByRole("button", { name: "Cancel" }) as HTMLButtonElement) + .disabled, + ).toBe(true); + const confirm = within(confirmation).getByTestId(restoreConfirmTestId); + expect(confirm).toHaveProperty("disabled", true); + fireEvent.click(confirm); + expect(onRevert).not.toHaveBeenCalled(); + }, + ); +}); diff --git a/apps/web/components/task/task-plan-revisions.tsx b/apps/web/components/task/task-plan-revisions.tsx index 25a2354755..82832449f6 100644 --- a/apps/web/components/task/task-plan-revisions.tsx +++ b/apps/web/components/task/task-plan-revisions.tsx @@ -1,25 +1,23 @@ "use client"; -import { useState, useCallback, useEffect, useMemo, useRef, type ReactNode } from "react"; +import { useState, useCallback, useEffect, useMemo, useRef } from "react"; import { IconHistory, IconUser, IconRestore, IconLoader2 } from "@tabler/icons-react"; import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover"; import { Button } from "@kandev/ui/button"; import { Badge } from "@kandev/ui/badge"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@kandev/ui/dialog"; import { toast } from "@/lib/toast/sonner"; import type { TaskPlanRevision } from "@/lib/types/http"; import { formatPreciseTime } from "@/lib/utils"; import { AgentLogo } from "@/components/agent-logo"; import { useAppStore } from "@/components/state-provider"; +import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; import { PlanRevisionPreviewDialog } from "./task-plan-preview-dialog"; import { PlanRevisionDiffDialog } from "./task-plan-diff-dialog"; +import { RevertConfirmDialog } from "./task-plan-revert-confirm-dialog"; +import { + RevisionInlineConfirmation, + RevisionRestoreAction, +} from "./task-plan-revision-restore-actions"; import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; @@ -51,11 +49,43 @@ type TaskPlanRevisionsProps = { const USER_DISPLAY_NAME_KEY = "common:you"; export function TaskPlanRevisions(props: TaskPlanRevisionsProps) { + const { t } = useTranslation(); + const { isFinePointer } = useResponsiveBreakpoint(); const [open, setOpen] = useState(false); const [confirmTarget, setConfirmTarget] = useState(null); + const [rowConfirmTarget, setRowConfirmTarget] = useState(null); const [diffOpen, setDiffOpen] = useState(false); const agentName = useActiveAgentBackendName(); - const handleOpenChange = useTriggerOnFirstOpen(setOpen, props.onOpen); + const triggerOpenChange = useTriggerOnFirstOpen(setOpen, props.onOpen); + const handleOpenChange = useCallback( + (nextOpen: boolean) => { + if (!nextOpen) setRowConfirmTarget(null); + triggerOpenChange(nextOpen); + }, + [triggerOpenChange], + ); + const closePopover = useCallback(() => handleOpenChange(false), [handleOpenChange]); + + const handleRevert = useCallback( + async (revision: TaskPlanRevision) => { + try { + const result = await props.onRevert(revision.id); + if (result) { + toast.success(t("task:planRestoredToV", { revisionnumber: revision.revision_number })); + closePopover(); + props.setPreviewRevision(null); + setDiffOpen(false); + } else { + // `revertTo` (the hook impl) swallows errors and returns null, so + // surface the failure instead of closing silently. + toast.error(t("task:failedToRestorePlan")); + } + } catch (err) { + toast.error(err instanceof Error ? err.message : t("task:failedToRestorePlan")); + } + }, + [closePopover, props.onRevert, props.setPreviewRevision, t], + ); const previewRevision = useMemo( () => props.revisions.find((r) => r.id === props.previewRevisionId) ?? null, @@ -76,7 +106,11 @@ export function TaskPlanRevisions(props: TaskPlanRevisionsProps) { open={open} onOpenChange={handleOpenChange} agentName={agentName} - confirmTargetSetter={setConfirmTarget} + rowConfirmTarget={rowConfirmTarget} + isFinePointer={isFinePointer} + onRowRevertRequest={setRowConfirmTarget} + onRowRevertCancel={() => setRowConfirmTarget(null)} + onRowRevert={handleRevert} {...props} /> setOpen(false)} /> ); @@ -120,16 +153,25 @@ function RevisionsPopover({ open, onOpenChange, agentName, - confirmTargetSetter, + rowConfirmTarget, + isFinePointer, + onRowRevertRequest, + onRowRevertCancel, + onRowRevert, revisions, isLoading, + isSaving, setPreviewRevision, disabled = false, }: TaskPlanRevisionsProps & { open: boolean; onOpenChange: (next: boolean) => void; agentName: string | null; - confirmTargetSetter: (rev: TaskPlanRevision | null) => void; + rowConfirmTarget: TaskPlanRevision | null; + isFinePointer: boolean; + onRowRevertRequest: (revision: TaskPlanRevision) => void; + onRowRevertCancel: () => void; + onRowRevert: (revision: TaskPlanRevision) => Promise; }) { const { t } = useTranslation(); const hasRevisions = revisions.length > 0; @@ -165,8 +207,13 @@ function RevisionsPopover({ setPreviewRevision(rev.id)} /> @@ -184,13 +231,12 @@ type DialogStackProps = { isSaving: boolean; loadRevisionContent: (revisionId: string) => Promise; headRevision: TaskPlanRevision | null; - onRevert: (revisionId: string) => Promise; + onRevert: (revision: TaskPlanRevision) => Promise; setConfirmTarget: (rev: TaskPlanRevision | null) => void; setDiffOpen: (v: boolean) => void; setPreviewRevision: (id: string | null) => void; clearComparePair: () => void; toggleCompareSelection: (id: string) => void; - closePopover: () => void; }; function RevisionsDialogStack({ @@ -208,7 +254,6 @@ function RevisionsDialogStack({ setPreviewRevision, clearComparePair, toggleCompareSelection, - closePopover, }: DialogStackProps) { const { t } = useTranslation(); const isPreviewCurrent = previewRevision !== null && previewRevision.id === headRevision?.id; @@ -219,28 +264,6 @@ function RevisionsDialogStack({ return revisions.find((r) => r.revision_number < previewRevision.revision_number) ?? null; }, [revisions, previewRevision]); - const handleRevert = useCallback( - async (revision: TaskPlanRevision) => { - try { - const result = await onRevert(revision.id); - if (result) { - toast.success(t("task:planRestoredToV", { revisionnumber: revision.revision_number })); - closePopover(); - setPreviewRevision(null); - setDiffOpen(false); - } else { - // `revertTo` (the hook impl) swallows errors and returns null, so - // we surface the failure here too — without this branch the dialog - // closes silently on failure and the user has no feedback. - toast.error(t("task:failedToRestorePlan")); - } - } catch (err) { - toast.error(err instanceof Error ? err.message : t("task:failedToRestorePlan")); - } - }, - [onRevert, setPreviewRevision, setDiffOpen, closePopover], - ); - const seedComparePair = useCallback( (a: TaskPlanRevision, b: TaskPlanRevision) => { // Replace any existing pair with [a, b] so the diff opens immediately. @@ -297,7 +320,7 @@ function RevisionsDialogStack({ onConfirm={async () => { if (!confirmTarget) return; try { - await handleRevert(confirmTarget); + await onRevert(confirmTarget); } finally { setConfirmTarget(null); } @@ -331,14 +354,24 @@ function useActiveAgentBackendName(): string | null { function RevisionList({ revisions, isLoading, + isSaving, agentName, - onRevertClick, + rowConfirmTarget, + isFinePointer, + onRevertRequest, + onRevertCancel, + onRevert, onRowClick, }: { revisions: TaskPlanRevision[]; isLoading: boolean; + isSaving: boolean; agentName: string | null; - onRevertClick: (rev: TaskPlanRevision) => void; + rowConfirmTarget: TaskPlanRevision | null; + isFinePointer: boolean; + onRevertRequest: (revision: TaskPlanRevision) => void; + onRevertCancel: () => void; + onRevert: (revision: TaskPlanRevision) => Promise; onRowClick: (rev: TaskPlanRevision) => void; }) { const { t } = useTranslation(); @@ -356,8 +389,13 @@ function RevisionList({ key={rev.id} revision={rev} isCurrent={i === 0} + isSaving={isSaving} agentName={agentName} - onRevertClick={onRevertClick} + rowConfirmTarget={rowConfirmTarget} + isFinePointer={isFinePointer} + onRevertRequest={onRevertRequest} + onRevertCancel={onRevertCancel} + onRevert={onRevert} onRowClick={onRowClick} /> ))} @@ -393,19 +431,75 @@ function RevisionAuthor({ ); } +type RevisionRowProps = { + revision: TaskPlanRevision; + isCurrent: boolean; + isSaving: boolean; + agentName: string | null; + rowConfirmTarget: TaskPlanRevision | null; + isFinePointer: boolean; + onRevertRequest: (revision: TaskPlanRevision) => void; + onRevertCancel: () => void; + onRevert: (revision: TaskPlanRevision) => Promise; + onRowClick: (rev: TaskPlanRevision) => void; +}; + function RevisionRow({ revision, isCurrent, + isSaving, agentName, - onRevertClick, + rowConfirmTarget, + isFinePointer, + onRevertRequest, + onRevertCancel, + onRevert, onRowClick, -}: { - revision: TaskPlanRevision; - isCurrent: boolean; - agentName: string | null; - onRevertClick: (rev: TaskPlanRevision) => void; - onRowClick: (rev: TaskPlanRevision) => void; -}) { +}: RevisionRowProps) { + return ( +
  • +
    + + +
    + +
  • + ); +} + +function RevisionRowBody({ + revision, + isCurrent, + agentName, + onRowClick, +}: Pick) { const { t } = useTranslation(); // Force re-render every 30s so the precise timestamp ("5m ago", "Today, // 14:32", …) refreshes as the revision ages — `formatPreciseTime` derives @@ -418,115 +512,37 @@ function RevisionRow({ const timestamp = formatPreciseTime(revision.updated_at); return ( -
  • onRowClick(revision)} + className="flex-1 min-w-0 text-left cursor-pointer" + data-testid="plan-revision-row-body" > - - {!isCurrent && ( - + + {t("task:restoredFromEarlierVersion")} + )} -
  • - ); -} - -function RevertConfirmDialog({ - target, - isSaving, - onCancel, - onConfirm, -}: { - target: TaskPlanRevision | null; - isSaving: boolean; - onCancel: () => void; - onConfirm: () => void | Promise; -}): ReactNode { - const { t } = useTranslation(); - const open = target !== null; - return ( - { - if (!v) onCancel(); - }} - > - - - - {t("task:restoreToVersionConfirm", { version: target?.revision_number })} - - - {t("task:restoreToVersionDescription", { version: target?.revision_number })} - - - - - - - - + ); } diff --git a/apps/web/e2e/pages/session-page.ts b/apps/web/e2e/pages/session-page.ts index 3a95bd04a9..d16cb7ea1f 100644 --- a/apps/web/e2e/pages/session-page.ts +++ b/apps/web/e2e/pages/session-page.ts @@ -1788,6 +1788,35 @@ export class SessionPage { return this.page.getByTestId("plan-revert-confirm-dialog"); } + /** Desktop row-local restore confirmation popover. */ + revertConfirmPopover(): Locator { + return this.page.getByTestId("plan-revision-restore-confirm-popover"); + } + + revertConfirmPopoverOk(): Locator { + return this.revertConfirmPopover().getByTestId("plan-revision-restore-confirm"); + } + + revertConfirmPopoverCancel(): Locator { + return this.revertConfirmPopover().getByRole("button", { name: "Cancel", exact: true }); + } + + /** Phone row-local restore confirmation. */ + revertInlineConfirmation(row: Locator): Locator { + return row.getByTestId("plan-revision-restore-inline-confirmation"); + } + + revertInlineConfirm(row: Locator): Locator { + return row.getByTestId("plan-revision-restore-confirm"); + } + + revertInlineCancel(row: Locator): Locator { + return this.revertInlineConfirmation(row).getByRole("button", { + name: "Cancel", + exact: true, + }); + } + revertConfirmOk(): Locator { return this.page.getByTestId("plan-revert-confirm-ok"); } @@ -1803,7 +1832,10 @@ export class SessionPage { /** Open the rewind popover and wait for it to render. No-op when already open. */ async openRewind(): Promise { - if (await this.revisionsPopover().isVisible()) return; + // Radix keeps the closed content in the DOM during its exit transition, + // and mobile can report that content as visible while the trigger is + // already closed. The trigger state is the authoritative open signal. + if ((await this.rewindButton().getAttribute("aria-expanded")) === "true") return; await this.rewindButton().click(); await expect(this.revisionsPopover()).toBeVisible({ timeout: 5_000 }); } @@ -1812,8 +1844,8 @@ export class SessionPage { async revertToRevision(n: number): Promise { await this.openRewind(); await this.revertButton(this.revisionRow(n)).click(); - await expect(this.revertConfirmDialog()).toBeVisible({ timeout: 5_000 }); - await this.revertConfirmOk().click(); + await expect(this.revertConfirmPopover()).toBeVisible({ timeout: 5_000 }); + await this.revertConfirmPopoverOk().click(); } // --- Plan revision preview & compare (Phase 6) --- diff --git a/apps/web/e2e/tests/task/mobile-plan-restore.spec.ts b/apps/web/e2e/tests/task/mobile-plan-restore.spec.ts new file mode 100644 index 0000000000..79f13d25b4 --- /dev/null +++ b/apps/web/e2e/tests/task/mobile-plan-restore.spec.ts @@ -0,0 +1,104 @@ +import { type Page } from "@playwright/test"; +import { test, expect } from "../../fixtures/test-base"; +import type { SeedData } from "../../fixtures/test-base"; +import type { ApiClient } from "../../helpers/api-client"; +import { SessionPage } from "../../pages/session-page"; + +function mcpWrite(content: string): string { + const escaped = JSON.stringify(content).slice(1, -1); + return `e2e:mcp:kandev:create_task_plan_kandev({"task_id":"{task_id}","content":"${escaped}"})`; +} + +function planRevisionScript(): string { + return [ + 'e2e:thinking("Seeding mobile plan revisions...")', + mcpWrite("Mobile draft A"), + "e2e:delay(2500)", + mcpWrite("Mobile draft B"), + 'e2e:message("Mobile revisions seeded.")', + ].join("\n"); +} + +async function seedMobilePlanTask( + testPage: Page, + apiClient: ApiClient, + seedData: SeedData, +): Promise { + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "Mobile plan restore confirmation", + seedData.agentProfileId, + { + description: planRevisionScript(), + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + + await testPage.goto(`/t/${task.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + await expect(session.chat.getByText("Mobile revisions seeded.", { exact: true })).toBeVisible({ + timeout: 45_000, + }); + await session.waitForChatIdle({ timeout: 45_000 }); + + await session.togglePlanMode(); + await testPage.getByRole("button", { name: "Plan", exact: true }).tap(); + await expect(session.planPanel).toBeVisible({ timeout: 10_000 }); + await expect(session.planPanel).toContainText("Mobile draft B", { timeout: 15_000 }); + return session; +} + +test.describe("Mobile plan restore confirmation", () => { + test("keeps restore confirmation inline and creates a new head revision", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(150_000); + let nativeDialogOpened = false; + testPage.on("dialog", async (dialog) => { + nativeDialogOpened = true; + await dialog.dismiss(); + }); + + const session = await seedMobilePlanTask(testPage, apiClient, seedData); + await session.rewindButton().tap(); + await expect(session.revisionsPopover()).toBeVisible({ timeout: 5_000 }); + await expect(session.revisionRows()).toHaveCount(2, { timeout: 10_000 }); + + const row = session.revisionRow(1); + await session.revertButton(row).tap(); + + const inlineConfirmation = session.revertInlineConfirmation(row); + await expect(inlineConfirmation).toBeVisible(); + await expect(inlineConfirmation).toContainText("v1"); + await expect(session.revertConfirmPopover()).toHaveCount(0); + + const restoreBox = await session.revertInlineConfirm(row).boundingBox(); + expect(restoreBox).not.toBeNull(); + expect(restoreBox!.height).toBeGreaterThanOrEqual(44); + expect( + await testPage.evaluate(() => { + const root = document.scrollingElement ?? document.documentElement; + return root.scrollWidth <= root.clientWidth + 1; + }), + ).toBe(true); + + await session.revertInlineCancel(row).tap(); + await expect(inlineConfirmation).toBeHidden(); + await expect(session.revertButton(row)).toBeVisible(); + + await session.revertButton(row).tap(); + await session.revertInlineConfirm(row).tap(); + await expect(session.planPanel).toContainText("Mobile draft A", { timeout: 15_000 }); + expect(nativeDialogOpened).toBe(false); + + await session.openRewind(); + await expect(session.revisionRows()).toHaveCount(3, { timeout: 10_000 }); + await expect(session.revisionRow(3).getByTestId("plan-revision-current-badge")).toBeVisible(); + await expect(session.revisionRow(3).getByTestId("plan-revision-revert-marker")).toBeVisible(); + }); +}); diff --git a/apps/web/e2e/tests/task/plan-checkpointing.spec.ts b/apps/web/e2e/tests/task/plan-checkpointing.spec.ts index f614216adf..adfb4ca2dd 100644 --- a/apps/web/e2e/tests/task/plan-checkpointing.spec.ts +++ b/apps/web/e2e/tests/task/plan-checkpointing.spec.ts @@ -282,7 +282,7 @@ test.describe("Plan checkpointing — rewind UI", () => { await expect(session.revisionRow(3).getByTestId("plan-revision-revert-marker")).toBeVisible(); }); - test("revert confirm dialog: cancel leaves revisions untouched", async ({ + test("revert confirmation: cancel leaves revisions untouched", async ({ testPage, apiClient, seedData, @@ -305,11 +305,11 @@ test.describe("Plan checkpointing — rewind UI", () => { await session.openRewind(); await expectRevisionCount(session, 2); - // Click revert on v1 then cancel the dialog. + // Click revert on v1 then cancel the row-local confirmation. await session.revertButton(session.revisionRow(1)).click(); - await expect(session.revertConfirmDialog()).toBeVisible({ timeout: 5_000 }); - await session.revertConfirmCancel().click(); - await expect(session.revertConfirmDialog()).toBeHidden({ timeout: 5_000 }); + await expect(session.revertConfirmPopover()).toBeVisible({ timeout: 5_000 }); + await session.revertConfirmPopoverCancel().click(); + await expect(session.revertConfirmPopover()).toBeHidden({ timeout: 5_000 }); // HEAD is unchanged and the two revisions are still there. await expect(session.planPanel.getByText("Second", { exact: false })).toBeVisible();