feat: localize plan restore confirmation (#2889)

This commit is contained in:
José Almeida
2026-08-22 18:48:44 +00:00
committed by GitHub
parent 3cd6c2d39f
commit 906be89036
9 changed files with 692 additions and 166 deletions
@@ -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();
},
);
});
+174 -158
View File
@@ -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>
);
}
+35 -3
View File
@@ -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();