feat: localize plan restore confirmation (#2889)
This commit is contained in:
@@ -13,6 +13,7 @@ import {
|
|||||||
|
|
||||||
export type ActionConfirmPopoverProps = {
|
export type ActionConfirmPopoverProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
anchorRef: RefObject<HTMLElement | null>;
|
anchorRef: RefObject<HTMLElement | null>;
|
||||||
focusBoundaryRef?: RefObject<HTMLElement | null>;
|
focusBoundaryRef?: RefObject<HTMLElement | null>;
|
||||||
title: ReactNode;
|
title: ReactNode;
|
||||||
@@ -36,6 +37,7 @@ export type ActionConfirmPopoverProps = {
|
|||||||
*/
|
*/
|
||||||
export function ActionConfirmPopover({
|
export function ActionConfirmPopover({
|
||||||
open,
|
open,
|
||||||
|
disabled = false,
|
||||||
anchorRef,
|
anchorRef,
|
||||||
focusBoundaryRef,
|
focusBoundaryRef,
|
||||||
title,
|
title,
|
||||||
@@ -77,6 +79,7 @@ export function ActionConfirmPopover({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleConfirm = () => {
|
const handleConfirm = () => {
|
||||||
|
if (disabled) return;
|
||||||
if (!isConnected(anchorRef.current)) {
|
if (!isConnected(anchorRef.current)) {
|
||||||
handleOpenChange(false);
|
handleOpenChange(false);
|
||||||
return;
|
return;
|
||||||
@@ -104,6 +107,7 @@ export function ActionConfirmPopover({
|
|||||||
confirmAriaLabel={confirmAriaLabel}
|
confirmAriaLabel={confirmAriaLabel}
|
||||||
confirmTestId={confirmTestId}
|
confirmTestId={confirmTestId}
|
||||||
testId={testId}
|
testId={testId}
|
||||||
|
disabled={disabled}
|
||||||
cancelRef={cancelRef}
|
cancelRef={cancelRef}
|
||||||
focusBoundaryRef={focusBoundaryRef}
|
focusBoundaryRef={focusBoundaryRef}
|
||||||
confirmedRef={confirmedRef}
|
confirmedRef={confirmedRef}
|
||||||
@@ -125,6 +129,7 @@ type ActionConfirmPopoverContentProps = {
|
|||||||
confirmAriaLabel?: string;
|
confirmAriaLabel?: string;
|
||||||
confirmTestId?: string;
|
confirmTestId?: string;
|
||||||
testId: string;
|
testId: string;
|
||||||
|
disabled: boolean;
|
||||||
cancelRef: RefObject<HTMLButtonElement | null>;
|
cancelRef: RefObject<HTMLButtonElement | null>;
|
||||||
focusBoundaryRef?: RefObject<HTMLElement | null>;
|
focusBoundaryRef?: RefObject<HTMLElement | null>;
|
||||||
confirmedRef: { current: boolean };
|
confirmedRef: { current: boolean };
|
||||||
@@ -143,6 +148,7 @@ function ActionConfirmPopoverContent({
|
|||||||
confirmAriaLabel,
|
confirmAriaLabel,
|
||||||
confirmTestId,
|
confirmTestId,
|
||||||
testId,
|
testId,
|
||||||
|
disabled,
|
||||||
cancelRef,
|
cancelRef,
|
||||||
focusBoundaryRef,
|
focusBoundaryRef,
|
||||||
confirmedRef,
|
confirmedRef,
|
||||||
@@ -184,6 +190,7 @@ function ActionConfirmPopoverContent({
|
|||||||
ref={cancelRef}
|
ref={cancelRef}
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
disabled={disabled}
|
||||||
className="min-h-11 px-3 transition-[color,background-color,border-color,transform] duration-100 active:scale-[0.96]"
|
className="min-h-11 px-3 transition-[color,background-color,border-color,transform] duration-100 active:scale-[0.96]"
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
>
|
>
|
||||||
@@ -192,6 +199,7 @@ function ActionConfirmPopoverContent({
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
|
disabled={disabled}
|
||||||
aria-label={confirmAriaLabel}
|
aria-label={confirmAriaLabel}
|
||||||
data-testid={confirmTestId}
|
data-testid={confirmTestId}
|
||||||
className="min-h-11 px-3 transition-[color,background-color,border-color,transform] duration-100 active:scale-[0.96]"
|
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 = {
|
export type InlineConfirmActionsProps = {
|
||||||
density?: "compact" | "touch";
|
density?: "compact" | "touch";
|
||||||
|
disabled?: boolean;
|
||||||
testId?: string;
|
testId?: string;
|
||||||
ariaLabel?: string;
|
ariaLabel?: string;
|
||||||
description?: ReactNode;
|
description?: ReactNode;
|
||||||
@@ -22,6 +23,7 @@ export type InlineConfirmActionsProps = {
|
|||||||
*/
|
*/
|
||||||
export function InlineConfirmActions({
|
export function InlineConfirmActions({
|
||||||
density = "compact",
|
density = "compact",
|
||||||
|
disabled = false,
|
||||||
testId = "inline-confirm-actions",
|
testId = "inline-confirm-actions",
|
||||||
ariaLabel,
|
ariaLabel,
|
||||||
description,
|
description,
|
||||||
@@ -43,6 +45,7 @@ export function InlineConfirmActions({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleConfirm = () => {
|
const handleConfirm = () => {
|
||||||
|
if (disabled) return;
|
||||||
onClose?.();
|
onClose?.();
|
||||||
setConfirmed(true);
|
setConfirmed(true);
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
@@ -82,6 +85,7 @@ export function InlineConfirmActions({
|
|||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
disabled={disabled}
|
||||||
className={`${actionClass} transition-[color,background-color,border-color,transform] duration-100 active:scale-[0.96]`}
|
className={`${actionClass} transition-[color,background-color,border-color,transform] duration-100 active:scale-[0.96]`}
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
>
|
>
|
||||||
@@ -91,6 +95,7 @@ export function InlineConfirmActions({
|
|||||||
type="button"
|
type="button"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
disabled={disabled}
|
||||||
aria-label={confirmAriaLabel}
|
aria-label={confirmAriaLabel}
|
||||||
data-testid={confirmTestId}
|
data-testid={confirmTestId}
|
||||||
className={`${actionClass} transition-[color,background-color,border-color,transform] duration-100 active:scale-[0.96]`}
|
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";
|
"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 { IconHistory, IconUser, IconRestore, IconLoader2 } from "@tabler/icons-react";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover";
|
||||||
import { Button } from "@kandev/ui/button";
|
import { Button } from "@kandev/ui/button";
|
||||||
import { Badge } from "@kandev/ui/badge";
|
import { Badge } from "@kandev/ui/badge";
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@kandev/ui/dialog";
|
|
||||||
import { toast } from "@/lib/toast/sonner";
|
import { toast } from "@/lib/toast/sonner";
|
||||||
import type { TaskPlanRevision } from "@/lib/types/http";
|
import type { TaskPlanRevision } from "@/lib/types/http";
|
||||||
import { formatPreciseTime } from "@/lib/utils";
|
import { formatPreciseTime } from "@/lib/utils";
|
||||||
import { AgentLogo } from "@/components/agent-logo";
|
import { AgentLogo } from "@/components/agent-logo";
|
||||||
import { useAppStore } from "@/components/state-provider";
|
import { useAppStore } from "@/components/state-provider";
|
||||||
|
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
||||||
import { PlanRevisionPreviewDialog } from "./task-plan-preview-dialog";
|
import { PlanRevisionPreviewDialog } from "./task-plan-preview-dialog";
|
||||||
import { PlanRevisionDiffDialog } from "./task-plan-diff-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 { useTranslation } from "react-i18next";
|
||||||
import type { TFunction } from "i18next";
|
import type { TFunction } from "i18next";
|
||||||
|
|
||||||
@@ -51,11 +49,43 @@ type TaskPlanRevisionsProps = {
|
|||||||
const USER_DISPLAY_NAME_KEY = "common:you";
|
const USER_DISPLAY_NAME_KEY = "common:you";
|
||||||
|
|
||||||
export function TaskPlanRevisions(props: TaskPlanRevisionsProps) {
|
export function TaskPlanRevisions(props: TaskPlanRevisionsProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { isFinePointer } = useResponsiveBreakpoint();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [confirmTarget, setConfirmTarget] = useState<TaskPlanRevision | null>(null);
|
const [confirmTarget, setConfirmTarget] = useState<TaskPlanRevision | null>(null);
|
||||||
|
const [rowConfirmTarget, setRowConfirmTarget] = useState<TaskPlanRevision | null>(null);
|
||||||
const [diffOpen, setDiffOpen] = useState(false);
|
const [diffOpen, setDiffOpen] = useState(false);
|
||||||
const agentName = useActiveAgentBackendName();
|
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(
|
const previewRevision = useMemo(
|
||||||
() => props.revisions.find((r) => r.id === props.previewRevisionId) ?? null,
|
() => props.revisions.find((r) => r.id === props.previewRevisionId) ?? null,
|
||||||
@@ -76,7 +106,11 @@ export function TaskPlanRevisions(props: TaskPlanRevisionsProps) {
|
|||||||
open={open}
|
open={open}
|
||||||
onOpenChange={handleOpenChange}
|
onOpenChange={handleOpenChange}
|
||||||
agentName={agentName}
|
agentName={agentName}
|
||||||
confirmTargetSetter={setConfirmTarget}
|
rowConfirmTarget={rowConfirmTarget}
|
||||||
|
isFinePointer={isFinePointer}
|
||||||
|
onRowRevertRequest={setRowConfirmTarget}
|
||||||
|
onRowRevertCancel={() => setRowConfirmTarget(null)}
|
||||||
|
onRowRevert={handleRevert}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
<RevisionsDialogStack
|
<RevisionsDialogStack
|
||||||
@@ -88,13 +122,12 @@ export function TaskPlanRevisions(props: TaskPlanRevisionsProps) {
|
|||||||
isSaving={props.isSaving}
|
isSaving={props.isSaving}
|
||||||
loadRevisionContent={props.loadRevisionContent}
|
loadRevisionContent={props.loadRevisionContent}
|
||||||
headRevision={headRevision}
|
headRevision={headRevision}
|
||||||
onRevert={props.onRevert}
|
onRevert={handleRevert}
|
||||||
setConfirmTarget={setConfirmTarget}
|
setConfirmTarget={setConfirmTarget}
|
||||||
setDiffOpen={setDiffOpen}
|
setDiffOpen={setDiffOpen}
|
||||||
setPreviewRevision={props.setPreviewRevision}
|
setPreviewRevision={props.setPreviewRevision}
|
||||||
clearComparePair={props.clearComparePair}
|
clearComparePair={props.clearComparePair}
|
||||||
toggleCompareSelection={props.toggleCompareSelection}
|
toggleCompareSelection={props.toggleCompareSelection}
|
||||||
closePopover={() => setOpen(false)}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -120,16 +153,25 @@ function RevisionsPopover({
|
|||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
agentName,
|
agentName,
|
||||||
confirmTargetSetter,
|
rowConfirmTarget,
|
||||||
|
isFinePointer,
|
||||||
|
onRowRevertRequest,
|
||||||
|
onRowRevertCancel,
|
||||||
|
onRowRevert,
|
||||||
revisions,
|
revisions,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
isSaving,
|
||||||
setPreviewRevision,
|
setPreviewRevision,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}: TaskPlanRevisionsProps & {
|
}: TaskPlanRevisionsProps & {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (next: boolean) => void;
|
onOpenChange: (next: boolean) => void;
|
||||||
agentName: string | null;
|
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 { t } = useTranslation();
|
||||||
const hasRevisions = revisions.length > 0;
|
const hasRevisions = revisions.length > 0;
|
||||||
@@ -165,8 +207,13 @@ function RevisionsPopover({
|
|||||||
<RevisionList
|
<RevisionList
|
||||||
revisions={revisions}
|
revisions={revisions}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
isSaving={isSaving}
|
||||||
agentName={agentName}
|
agentName={agentName}
|
||||||
onRevertClick={confirmTargetSetter}
|
rowConfirmTarget={rowConfirmTarget}
|
||||||
|
isFinePointer={isFinePointer}
|
||||||
|
onRevertRequest={onRowRevertRequest}
|
||||||
|
onRevertCancel={onRowRevertCancel}
|
||||||
|
onRevert={onRowRevert}
|
||||||
onRowClick={(rev) => setPreviewRevision(rev.id)}
|
onRowClick={(rev) => setPreviewRevision(rev.id)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -184,13 +231,12 @@ type DialogStackProps = {
|
|||||||
isSaving: boolean;
|
isSaving: boolean;
|
||||||
loadRevisionContent: (revisionId: string) => Promise<string>;
|
loadRevisionContent: (revisionId: string) => Promise<string>;
|
||||||
headRevision: TaskPlanRevision | null;
|
headRevision: TaskPlanRevision | null;
|
||||||
onRevert: (revisionId: string) => Promise<TaskPlanRevision | null>;
|
onRevert: (revision: TaskPlanRevision) => Promise<void>;
|
||||||
setConfirmTarget: (rev: TaskPlanRevision | null) => void;
|
setConfirmTarget: (rev: TaskPlanRevision | null) => void;
|
||||||
setDiffOpen: (v: boolean) => void;
|
setDiffOpen: (v: boolean) => void;
|
||||||
setPreviewRevision: (id: string | null) => void;
|
setPreviewRevision: (id: string | null) => void;
|
||||||
clearComparePair: () => void;
|
clearComparePair: () => void;
|
||||||
toggleCompareSelection: (id: string) => void;
|
toggleCompareSelection: (id: string) => void;
|
||||||
closePopover: () => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function RevisionsDialogStack({
|
function RevisionsDialogStack({
|
||||||
@@ -208,7 +254,6 @@ function RevisionsDialogStack({
|
|||||||
setPreviewRevision,
|
setPreviewRevision,
|
||||||
clearComparePair,
|
clearComparePair,
|
||||||
toggleCompareSelection,
|
toggleCompareSelection,
|
||||||
closePopover,
|
|
||||||
}: DialogStackProps) {
|
}: DialogStackProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const isPreviewCurrent = previewRevision !== null && previewRevision.id === headRevision?.id;
|
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;
|
return revisions.find((r) => r.revision_number < previewRevision.revision_number) ?? null;
|
||||||
}, [revisions, previewRevision]);
|
}, [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(
|
const seedComparePair = useCallback(
|
||||||
(a: TaskPlanRevision, b: TaskPlanRevision) => {
|
(a: TaskPlanRevision, b: TaskPlanRevision) => {
|
||||||
// Replace any existing pair with [a, b] so the diff opens immediately.
|
// Replace any existing pair with [a, b] so the diff opens immediately.
|
||||||
@@ -297,7 +320,7 @@ function RevisionsDialogStack({
|
|||||||
onConfirm={async () => {
|
onConfirm={async () => {
|
||||||
if (!confirmTarget) return;
|
if (!confirmTarget) return;
|
||||||
try {
|
try {
|
||||||
await handleRevert(confirmTarget);
|
await onRevert(confirmTarget);
|
||||||
} finally {
|
} finally {
|
||||||
setConfirmTarget(null);
|
setConfirmTarget(null);
|
||||||
}
|
}
|
||||||
@@ -331,14 +354,24 @@ function useActiveAgentBackendName(): string | null {
|
|||||||
function RevisionList({
|
function RevisionList({
|
||||||
revisions,
|
revisions,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
isSaving,
|
||||||
agentName,
|
agentName,
|
||||||
onRevertClick,
|
rowConfirmTarget,
|
||||||
|
isFinePointer,
|
||||||
|
onRevertRequest,
|
||||||
|
onRevertCancel,
|
||||||
|
onRevert,
|
||||||
onRowClick,
|
onRowClick,
|
||||||
}: {
|
}: {
|
||||||
revisions: TaskPlanRevision[];
|
revisions: TaskPlanRevision[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
isSaving: boolean;
|
||||||
agentName: string | null;
|
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;
|
onRowClick: (rev: TaskPlanRevision) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -356,8 +389,13 @@ function RevisionList({
|
|||||||
key={rev.id}
|
key={rev.id}
|
||||||
revision={rev}
|
revision={rev}
|
||||||
isCurrent={i === 0}
|
isCurrent={i === 0}
|
||||||
|
isSaving={isSaving}
|
||||||
agentName={agentName}
|
agentName={agentName}
|
||||||
onRevertClick={onRevertClick}
|
rowConfirmTarget={rowConfirmTarget}
|
||||||
|
isFinePointer={isFinePointer}
|
||||||
|
onRevertRequest={onRevertRequest}
|
||||||
|
onRevertCancel={onRevertCancel}
|
||||||
|
onRevert={onRevert}
|
||||||
onRowClick={onRowClick}
|
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({
|
function RevisionRow({
|
||||||
revision,
|
revision,
|
||||||
isCurrent,
|
isCurrent,
|
||||||
|
isSaving,
|
||||||
agentName,
|
agentName,
|
||||||
onRevertClick,
|
rowConfirmTarget,
|
||||||
|
isFinePointer,
|
||||||
|
onRevertRequest,
|
||||||
|
onRevertCancel,
|
||||||
|
onRevert,
|
||||||
onRowClick,
|
onRowClick,
|
||||||
}: {
|
}: RevisionRowProps) {
|
||||||
revision: TaskPlanRevision;
|
return (
|
||||||
isCurrent: boolean;
|
<li
|
||||||
agentName: string | null;
|
className="px-3 py-2.5 hover:bg-accent/30"
|
||||||
onRevertClick: (rev: TaskPlanRevision) => void;
|
data-testid="plan-revision-row"
|
||||||
onRowClick: (rev: TaskPlanRevision) => void;
|
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();
|
const { t } = useTranslation();
|
||||||
// Force re-render every 30s so the precise timestamp ("5m ago", "Today,
|
// Force re-render every 30s so the precise timestamp ("5m ago", "Today,
|
||||||
// 14:32", …) refreshes as the revision ages — `formatPreciseTime` derives
|
// 14:32", …) refreshes as the revision ages — `formatPreciseTime` derives
|
||||||
@@ -418,115 +512,37 @@ function RevisionRow({
|
|||||||
const timestamp = formatPreciseTime(revision.updated_at);
|
const timestamp = formatPreciseTime(revision.updated_at);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li
|
<button
|
||||||
// items-center keeps the Restore button vertically centered against the
|
type="button"
|
||||||
// row body, even when the body grows taller (timestamp + restored-from
|
onClick={() => onRowClick(revision)}
|
||||||
// marker on revert rows).
|
className="flex-1 min-w-0 text-left cursor-pointer"
|
||||||
className="px-3 py-2.5 flex items-center gap-3 hover:bg-accent/30"
|
data-testid="plan-revision-row-body"
|
||||||
data-testid="plan-revision-row"
|
|
||||||
data-revision-id={revision.id}
|
|
||||||
data-revision-number={revision.revision_number}
|
|
||||||
>
|
>
|
||||||
<button
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
type="button"
|
<span className="text-xs font-semibold">v{revision.revision_number}</span>
|
||||||
onClick={() => onRowClick(revision)}
|
<RevisionAuthor revision={revision} agentName={agentName} />
|
||||||
className="flex-1 min-w-0 text-left cursor-pointer"
|
{isCurrent && (
|
||||||
data-testid="plan-revision-row-body"
|
<Badge
|
||||||
>
|
variant="secondary"
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
className="h-4 text-[10px] px-1.5"
|
||||||
<span className="text-xs font-semibold">v{revision.revision_number}</span>
|
data-testid="plan-revision-current-badge"
|
||||||
<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"
|
|
||||||
>
|
>
|
||||||
<IconRestore className="h-3 w-3" />
|
{t("task:current")}
|
||||||
{t("task:restoredFromEarlierVersion")}
|
</Badge>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</button>
|
</div>
|
||||||
{!isCurrent && (
|
<div className="text-[11px] text-muted-foreground mt-1" data-testid="plan-revision-time">
|
||||||
<Button
|
{timestamp}
|
||||||
size="sm"
|
</div>
|
||||||
variant="ghost"
|
{revision.revert_of_revision_id && (
|
||||||
className="h-7 px-2 text-xs cursor-pointer shrink-0 gap-1"
|
<div
|
||||||
onClick={(e) => {
|
className="text-[11px] text-muted-foreground mt-1 flex items-center gap-1"
|
||||||
e.stopPropagation();
|
data-testid="plan-revision-revert-marker"
|
||||||
onRevertClick(revision);
|
|
||||||
}}
|
|
||||||
data-testid="plan-revision-revert-button"
|
|
||||||
>
|
>
|
||||||
<IconRestore className="h-3.5 w-3.5" />
|
<IconRestore className="h-3 w-3" />
|
||||||
{t("task:restore")}
|
{t("task:restoredFromEarlierVersion")}
|
||||||
</Button>
|
</div>
|
||||||
)}
|
)}
|
||||||
</li>
|
</button>
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1788,6 +1788,35 @@ export class SessionPage {
|
|||||||
return this.page.getByTestId("plan-revert-confirm-dialog");
|
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 {
|
revertConfirmOk(): Locator {
|
||||||
return this.page.getByTestId("plan-revert-confirm-ok");
|
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. */
|
/** Open the rewind popover and wait for it to render. No-op when already open. */
|
||||||
async openRewind(): Promise<void> {
|
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 this.rewindButton().click();
|
||||||
await expect(this.revisionsPopover()).toBeVisible({ timeout: 5_000 });
|
await expect(this.revisionsPopover()).toBeVisible({ timeout: 5_000 });
|
||||||
}
|
}
|
||||||
@@ -1812,8 +1844,8 @@ export class SessionPage {
|
|||||||
async revertToRevision(n: number): Promise<void> {
|
async revertToRevision(n: number): Promise<void> {
|
||||||
await this.openRewind();
|
await this.openRewind();
|
||||||
await this.revertButton(this.revisionRow(n)).click();
|
await this.revertButton(this.revisionRow(n)).click();
|
||||||
await expect(this.revertConfirmDialog()).toBeVisible({ timeout: 5_000 });
|
await expect(this.revertConfirmPopover()).toBeVisible({ timeout: 5_000 });
|
||||||
await this.revertConfirmOk().click();
|
await this.revertConfirmPopoverOk().click();
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Plan revision preview & compare (Phase 6) ---
|
// --- 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();
|
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,
|
testPage,
|
||||||
apiClient,
|
apiClient,
|
||||||
seedData,
|
seedData,
|
||||||
@@ -305,11 +305,11 @@ test.describe("Plan checkpointing — rewind UI", () => {
|
|||||||
await session.openRewind();
|
await session.openRewind();
|
||||||
await expectRevisionCount(session, 2);
|
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 session.revertButton(session.revisionRow(1)).click();
|
||||||
await expect(session.revertConfirmDialog()).toBeVisible({ timeout: 5_000 });
|
await expect(session.revertConfirmPopover()).toBeVisible({ timeout: 5_000 });
|
||||||
await session.revertConfirmCancel().click();
|
await session.revertConfirmPopoverCancel().click();
|
||||||
await expect(session.revertConfirmDialog()).toBeHidden({ timeout: 5_000 });
|
await expect(session.revertConfirmPopover()).toBeHidden({ timeout: 5_000 });
|
||||||
|
|
||||||
// HEAD is unchanged and the two revisions are still there.
|
// HEAD is unchanged and the two revisions are still there.
|
||||||
await expect(session.planPanel.getByText("Second", { exact: false })).toBeVisible();
|
await expect(session.planPanel.getByText("Second", { exact: false })).toBeVisible();
|
||||||
|
|||||||
Reference in New Issue
Block a user