feat: localize plan restore confirmation (#2889)
This commit is contained in:
@@ -13,6 +13,7 @@ import {
|
||||
|
||||
export type ActionConfirmPopoverProps = {
|
||||
open: boolean;
|
||||
disabled?: boolean;
|
||||
anchorRef: RefObject<HTMLElement | null>;
|
||||
focusBoundaryRef?: RefObject<HTMLElement | null>;
|
||||
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<HTMLButtonElement | null>;
|
||||
focusBoundaryRef?: RefObject<HTMLElement | null>;
|
||||
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({
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
disabled={disabled}
|
||||
aria-label={confirmAriaLabel}
|
||||
data-testid={confirmTestId}
|
||||
className="min-h-11 px-3 transition-[color,background-color,border-color,transform] duration-100 active:scale-[0.96]"
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Button } from "@kandev/ui/button";
|
||||
|
||||
export type InlineConfirmActionsProps = {
|
||||
density?: "compact" | "touch";
|
||||
disabled?: boolean;
|
||||
testId?: string;
|
||||
ariaLabel?: string;
|
||||
description?: ReactNode;
|
||||
@@ -22,6 +23,7 @@ export type InlineConfirmActionsProps = {
|
||||
*/
|
||||
export function InlineConfirmActions({
|
||||
density = "compact",
|
||||
disabled = false,
|
||||
testId = "inline-confirm-actions",
|
||||
ariaLabel,
|
||||
description,
|
||||
@@ -43,6 +45,7 @@ export function InlineConfirmActions({
|
||||
}, []);
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (disabled) return;
|
||||
onClose?.();
|
||||
setConfirmed(true);
|
||||
queueMicrotask(() => {
|
||||
@@ -82,6 +85,7 @@ export function InlineConfirmActions({
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
className={`${actionClass} transition-[color,background-color,border-color,transform] duration-100 active:scale-[0.96]`}
|
||||
onClick={onCancel}
|
||||
>
|
||||
@@ -91,6 +95,7 @@ export function InlineConfirmActions({
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
aria-label={confirmAriaLabel}
|
||||
data-testid={confirmTestId}
|
||||
className={`${actionClass} transition-[color,background-color,border-color,transform] duration-100 active:scale-[0.96]`}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@kandev/ui/dialog";
|
||||
import type { TaskPlanRevision } from "@/lib/types/http";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function RevertConfirmDialog({
|
||||
target,
|
||||
isSaving,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: {
|
||||
target: TaskPlanRevision | null;
|
||||
isSaving: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void | Promise<void>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const open = target !== null;
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(v) => {
|
||||
if (!v) onCancel();
|
||||
}}
|
||||
>
|
||||
<DialogContent data-testid="plan-revert-confirm-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("task:restoreToVersionConfirm", { version: target?.revision_number })}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("task:restoreToVersionDescription", { version: target?.revision_number })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
disabled={isSaving}
|
||||
className="cursor-pointer"
|
||||
data-testid="plan-revert-confirm-cancel"
|
||||
>
|
||||
{t("common:cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onConfirm}
|
||||
disabled={isSaving}
|
||||
className="cursor-pointer"
|
||||
data-testid="plan-revert-confirm-ok"
|
||||
>
|
||||
{isSaving ? t("task:restoring") : t("task:restore")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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<void>;
|
||||
};
|
||||
|
||||
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<HTMLButtonElement>(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) && (
|
||||
<Button
|
||||
ref={restoreButtonRef}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={isSaving}
|
||||
className="h-7 px-2 text-xs cursor-pointer shrink-0 gap-1"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onRevertRequest(revision);
|
||||
}}
|
||||
data-testid="plan-revision-revert-button"
|
||||
>
|
||||
<IconRestore className="h-3.5 w-3.5" />
|
||||
{t("task:restore")}
|
||||
</Button>
|
||||
)}
|
||||
{isFinePointer && (
|
||||
<ActionConfirmPopover
|
||||
open={isConfirming}
|
||||
disabled={isSaving}
|
||||
anchorRef={restoreButtonRef}
|
||||
title={t("task:restoreToVersionConfirm", { version })}
|
||||
description={t("task:restoreToVersionDescription", { version })}
|
||||
cancelLabel={t("common:cancel")}
|
||||
confirmLabel={restoreLabel}
|
||||
confirmAriaLabel={restoreLabel}
|
||||
confirmTestId="plan-revision-restore-confirm"
|
||||
testId="plan-revision-restore-confirm-popover"
|
||||
onOpenChange={(nextOpen) => {
|
||||
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 (
|
||||
<div className="mt-2 min-w-0">
|
||||
<InlineConfirmActions
|
||||
density="touch"
|
||||
disabled={isSaving}
|
||||
testId="plan-revision-restore-inline-confirmation"
|
||||
ariaLabel={t("task:restoreToVersionConfirm", { version })}
|
||||
description={t("task:restoreToVersionDescription", { version })}
|
||||
cancelLabel={t("common:cancel")}
|
||||
confirmLabel={restoreLabel}
|
||||
confirmAriaLabel={restoreLabel}
|
||||
confirmTestId="plan-revision-restore-confirm"
|
||||
onCancel={onRevertCancel}
|
||||
onClose={onRevertCancel}
|
||||
onConfirm={() => onRevert(revision)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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(<TaskPlanRevisions {...props} isSaving={false} />);
|
||||
fireEvent.click(screen.getByTestId("plan-rewind-button"));
|
||||
return {
|
||||
setSaving: (isSaving: boolean) =>
|
||||
view.rerender(<TaskPlanRevisions {...props} isSaving={isSaving} />),
|
||||
};
|
||||
}
|
||||
|
||||
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();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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<TaskPlanRevision | null>(null);
|
||||
const [rowConfirmTarget, setRowConfirmTarget] = useState<TaskPlanRevision | null>(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}
|
||||
/>
|
||||
<RevisionsDialogStack
|
||||
@@ -88,13 +122,12 @@ export function TaskPlanRevisions(props: TaskPlanRevisionsProps) {
|
||||
isSaving={props.isSaving}
|
||||
loadRevisionContent={props.loadRevisionContent}
|
||||
headRevision={headRevision}
|
||||
onRevert={props.onRevert}
|
||||
onRevert={handleRevert}
|
||||
setConfirmTarget={setConfirmTarget}
|
||||
setDiffOpen={setDiffOpen}
|
||||
setPreviewRevision={props.setPreviewRevision}
|
||||
clearComparePair={props.clearComparePair}
|
||||
toggleCompareSelection={props.toggleCompareSelection}
|
||||
closePopover={() => 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<void>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const hasRevisions = revisions.length > 0;
|
||||
@@ -165,8 +207,13 @@ function RevisionsPopover({
|
||||
<RevisionList
|
||||
revisions={revisions}
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
agentName={agentName}
|
||||
onRevertClick={confirmTargetSetter}
|
||||
rowConfirmTarget={rowConfirmTarget}
|
||||
isFinePointer={isFinePointer}
|
||||
onRevertRequest={onRowRevertRequest}
|
||||
onRevertCancel={onRowRevertCancel}
|
||||
onRevert={onRowRevert}
|
||||
onRowClick={(rev) => setPreviewRevision(rev.id)}
|
||||
/>
|
||||
</div>
|
||||
@@ -184,13 +231,12 @@ type DialogStackProps = {
|
||||
isSaving: boolean;
|
||||
loadRevisionContent: (revisionId: string) => Promise<string>;
|
||||
headRevision: TaskPlanRevision | null;
|
||||
onRevert: (revisionId: string) => Promise<TaskPlanRevision | null>;
|
||||
onRevert: (revision: TaskPlanRevision) => Promise<void>;
|
||||
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<void>;
|
||||
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<void>;
|
||||
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 (
|
||||
<li
|
||||
className="px-3 py-2.5 hover:bg-accent/30"
|
||||
data-testid="plan-revision-row"
|
||||
data-revision-id={revision.id}
|
||||
data-revision-number={revision.revision_number}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<RevisionRowBody
|
||||
revision={revision}
|
||||
isCurrent={isCurrent}
|
||||
agentName={agentName}
|
||||
onRowClick={onRowClick}
|
||||
/>
|
||||
<RevisionRestoreAction
|
||||
revision={revision}
|
||||
isCurrent={isCurrent}
|
||||
isSaving={isSaving}
|
||||
rowConfirmTarget={rowConfirmTarget}
|
||||
isFinePointer={isFinePointer}
|
||||
onRevertRequest={onRevertRequest}
|
||||
onRevertCancel={onRevertCancel}
|
||||
onRevert={onRevert}
|
||||
/>
|
||||
</div>
|
||||
<RevisionInlineConfirmation
|
||||
revision={revision}
|
||||
isCurrent={isCurrent}
|
||||
isSaving={isSaving}
|
||||
isFinePointer={isFinePointer}
|
||||
rowConfirmTarget={rowConfirmTarget}
|
||||
onRevertCancel={onRevertCancel}
|
||||
onRevert={onRevert}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function RevisionRowBody({
|
||||
revision,
|
||||
isCurrent,
|
||||
agentName,
|
||||
onRowClick,
|
||||
}: Pick<RevisionRowProps, "revision" | "isCurrent" | "agentName" | "onRowClick">) {
|
||||
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 (
|
||||
<li
|
||||
// items-center keeps the Restore button vertically centered against the
|
||||
// row body, even when the body grows taller (timestamp + restored-from
|
||||
// marker on revert rows).
|
||||
className="px-3 py-2.5 flex items-center gap-3 hover:bg-accent/30"
|
||||
data-testid="plan-revision-row"
|
||||
data-revision-id={revision.id}
|
||||
data-revision-number={revision.revision_number}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRowClick(revision)}
|
||||
className="flex-1 min-w-0 text-left cursor-pointer"
|
||||
data-testid="plan-revision-row-body"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRowClick(revision)}
|
||||
className="flex-1 min-w-0 text-left cursor-pointer"
|
||||
data-testid="plan-revision-row-body"
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-semibold">v{revision.revision_number}</span>
|
||||
<RevisionAuthor revision={revision} agentName={agentName} />
|
||||
{isCurrent && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-4 text-[10px] px-1.5"
|
||||
data-testid="plan-revision-current-badge"
|
||||
>
|
||||
{t("task:current")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground mt-1" data-testid="plan-revision-time">
|
||||
{timestamp}
|
||||
</div>
|
||||
{revision.revert_of_revision_id && (
|
||||
<div
|
||||
className="text-[11px] text-muted-foreground mt-1 flex items-center gap-1"
|
||||
data-testid="plan-revision-revert-marker"
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-semibold">v{revision.revision_number}</span>
|
||||
<RevisionAuthor revision={revision} agentName={agentName} />
|
||||
{isCurrent && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-4 text-[10px] px-1.5"
|
||||
data-testid="plan-revision-current-badge"
|
||||
>
|
||||
<IconRestore className="h-3 w-3" />
|
||||
{t("task:restoredFromEarlierVersion")}
|
||||
</div>
|
||||
{t("task:current")}
|
||||
</Badge>
|
||||
)}
|
||||
</button>
|
||||
{!isCurrent && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 px-2 text-xs cursor-pointer shrink-0 gap-1"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRevertClick(revision);
|
||||
}}
|
||||
data-testid="plan-revision-revert-button"
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground mt-1" data-testid="plan-revision-time">
|
||||
{timestamp}
|
||||
</div>
|
||||
{revision.revert_of_revision_id && (
|
||||
<div
|
||||
className="text-[11px] text-muted-foreground mt-1 flex items-center gap-1"
|
||||
data-testid="plan-revision-revert-marker"
|
||||
>
|
||||
<IconRestore className="h-3.5 w-3.5" />
|
||||
{t("task:restore")}
|
||||
</Button>
|
||||
<IconRestore className="h-3 w-3" />
|
||||
{t("task:restoredFromEarlierVersion")}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function RevertConfirmDialog({
|
||||
target,
|
||||
isSaving,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: {
|
||||
target: TaskPlanRevision | null;
|
||||
isSaving: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void | Promise<void>;
|
||||
}): ReactNode {
|
||||
const { t } = useTranslation();
|
||||
const open = target !== null;
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(v) => {
|
||||
if (!v) onCancel();
|
||||
}}
|
||||
>
|
||||
<DialogContent data-testid="plan-revert-confirm-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("task:restoreToVersionConfirm", { version: target?.revision_number })}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("task:restoreToVersionDescription", { version: target?.revision_number })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
disabled={isSaving}
|
||||
className="cursor-pointer"
|
||||
data-testid="plan-revert-confirm-cancel"
|
||||
>
|
||||
{t("common:cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onConfirm}
|
||||
disabled={isSaving}
|
||||
className="cursor-pointer"
|
||||
data-testid="plan-revert-confirm-ok"
|
||||
>
|
||||
{isSaving ? t("task:restoring") : t("task:restore")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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) ---
|
||||
|
||||
@@ -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<SessionPage> {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user