diff --git a/apps/web/components/task/chat/chat-input-area.test.tsx b/apps/web/components/task/chat/chat-input-area.test.tsx index 4b4861cc4f..feda2cd56d 100644 --- a/apps/web/components/task/chat/chat-input-area.test.tsx +++ b/apps/web/components/task/chat/chat-input-area.test.tsx @@ -1,32 +1,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - act, - cleanup, - fireEvent, - render, - renderHook, - screen, - waitFor, -} from "@testing-library/react"; +import { act, cleanup, renderHook } from "@testing-library/react"; -const archiveAndSwitchMock = vi.fn(); const toastMock = vi.fn(); const handleSendMessageMock = vi.fn(); -const taskPRsByTaskId = vi.hoisted(() => ({ - value: { - "task-1": [{ pr_number: 42, state: "merged" }], - } as Record>, -})); + +const mockState = {}; vi.mock("@/components/state-provider", () => ({ useAppStore: (selector: (state: typeof mockState) => unknown) => selector(mockState), useAppStoreApi: () => ({ getState: () => mockState }), })); -vi.mock("@/hooks/use-task-actions", () => ({ - useArchiveAndSwitchTask: () => archiveAndSwitchMock, -})); - vi.mock("@/components/toast-provider", () => ({ useToast: () => ({ toast: toastMock }), })); @@ -48,6 +32,11 @@ vi.mock("@/components/task/chat/todo-indicator", () => ({ TodoIndicator: () => null, })); +vi.mock("./pr-archive-banners", () => ({ + PRMergedBanner: () => null, + PRClosedBanner: () => null, +})); + vi.mock("@/hooks/use-keyboard-shortcut", () => ({ useKeyboardShortcut: () => undefined, })); @@ -77,30 +66,10 @@ vi.mock("@/lib/ws/connection", () => ({ getWebSocketClient: () => ({ send: vi.fn() }), })); -vi.mock("@/lib/local-storage", async (importOriginal) => ({ - ...(await importOriginal()), - markPRClosedBannerDismissed: vi.fn(), - markPRMergedBannerDismissed: vi.fn(), - wasPRClosedBannerDismissed: () => false, - wasPRMergedBannerDismissed: () => false, -})); - -const mockState = { - taskPRs: { - get byTaskId() { - return taskPRsByTaskId.value; - }, - }, -}; - -import { PRMergedBanner, useSubmitHandler } from "./chat-input-area"; +import { useSubmitHandler } from "./chat-input-area"; beforeEach(() => { - archiveAndSwitchMock.mockResolvedValue(undefined); handleSendMessageMock.mockResolvedValue(undefined); - taskPRsByTaskId.value = { - "task-1": [{ pr_number: 42, state: "merged" }], - }; }); afterEach(() => { @@ -109,31 +78,6 @@ afterEach(() => { vi.clearAllMocks(); }); -describe("PRMergedBanner", () => { - it("archives without showing a success toast", async () => { - render(); - - fireEvent.click(screen.getByTestId("pr-merged-archive-button")); - - await waitFor(() => expect(archiveAndSwitchMock).toHaveBeenCalledWith("task-1")); - expect(toastMock).not.toHaveBeenCalled(); - }); - - it("keeps the failure toast when archive fails", async () => { - archiveAndSwitchMock.mockRejectedValueOnce(new Error("archive failed")); - render(); - - fireEvent.click(screen.getByTestId("pr-merged-archive-button")); - - await waitFor(() => - expect(toastMock).toHaveBeenCalledWith({ - description: "Failed to archive task", - variant: "error", - }), - ); - }); -}); - describe("useSubmitHandler", () => { function panelState(overrides = {}) { return { diff --git a/apps/web/components/task/chat/chat-input-area.tsx b/apps/web/components/task/chat/chat-input-area.tsx index bf9894cf01..c7448a2034 100644 --- a/apps/web/components/task/chat/chat-input-area.tsx +++ b/apps/web/components/task/chat/chat-input-area.tsx @@ -1,10 +1,11 @@ "use client"; import { useCallback, useState, type ReactNode } from "react"; -import { IconArrowRight, IconGitMerge, IconGitPullRequestClosed, IconX } from "@tabler/icons-react"; +import { IconArrowRight } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { TodoIndicator } from "./todo-indicator"; +import { PRMergedBanner, PRClosedBanner } from "./pr-archive-banners"; import { PRStatusChip } from "@/components/github/pr-status-chip"; import { ShareButton, shareableSessionStateClient } from "@/components/task/share/share-button"; import { getWebSocketClient } from "@/lib/ws/connection"; @@ -28,14 +29,7 @@ import { } from "@/lib/state/slices/comments/format"; import { usePlanActions } from "@/hooks/domains/kanban/use-plan-actions"; import { useExecutorEnvironmentAvailability } from "@/hooks/domains/session/use-executor-environment-availability"; -import { useArchiveAndSwitchTask } from "@/hooks/use-task-actions"; import { useToast } from "@/components/toast-provider"; -import { - markPRClosedBannerDismissed, - markPRMergedBannerDismissed, - wasPRClosedBannerDismissed, - wasPRMergedBannerDismissed, -} from "@/lib/local-storage"; import type { DiffComment } from "@/lib/diff/types"; import type { useChatPanelState } from "./use-chat-panel-state"; @@ -267,135 +261,6 @@ export function useChatPanelHandlers( return { handleCancelTurn }; } -// Shared archive-task action for the terminal-state banners: archives the task, -// switches to the next one, and only toasts on failure. -function useArchiveTaskAction(taskId: string) { - const archiveAndSwitch = useArchiveAndSwitchTask(); - const { toast } = useToast(); - return useCallback(async () => { - try { - await archiveAndSwitch(taskId); - } catch { - toast({ description: "Failed to archive task", variant: "error" }); - } - }, [taskId, archiveAndSwitch, toast]); -} - -// Presentational banner shared by PRMergedBanner / PRClosedBanner — an icon, a -// message, and Archive + Dismiss controls. Colors/icon/testIds are supplied by -// the caller so the two variants stay visually distinct. -function ArchiveDismissBanner({ - testIdPrefix, - icon, - text, - containerClass, - archiveClass, - dismissClass, - onArchive, - onDismiss, -}: { - testIdPrefix: string; - icon: ReactNode; - text: string; - containerClass: string; - archiveClass: string; - dismissClass: string; - onArchive: () => void; - onDismiss: () => void; -}) { - return ( -
- {icon} - {text} - - -
- ); -} - -export function PRMergedBanner({ taskId }: { taskId: string }) { - const taskPRs = useAppStore((state) => state.taskPRs.byTaskId[taskId]); - const [dismissed, setDismissed] = useState(() => wasPRMergedBannerDismissed(taskId)); - const handleArchive = useArchiveTaskAction(taskId); - - const handleDismiss = useCallback(() => { - markPRMergedBannerDismissed(taskId); - setDismissed(true); - }, [taskId]); - - // Multi-repo: only show "ready to archive" once every PR is merged. A - // single merged repo with others still open means the task isn't done yet. - const allMerged = !!taskPRs && taskPRs.length > 0 && taskPRs.every((pr) => pr.state === "merged"); - if (!allMerged || dismissed) return null; - - const bannerText = - taskPRs.length === 1 - ? `PR #${taskPRs[0].pr_number} has been merged. You can archive this task.` - : `All ${taskPRs.length} PRs have been merged. You can archive this task.`; - - return ( - } - text={bannerText} - containerClass="flex flex-1 items-center gap-2 rounded-md bg-purple-500/10 px-2 py-1 text-purple-600 dark:text-purple-400" - archiveClass="underline underline-offset-2 hover:text-purple-700 dark:hover:text-purple-300 cursor-pointer" - dismissClass="p-0.5 hover:bg-purple-500/10 rounded cursor-pointer" - onArchive={handleArchive} - onDismiss={handleDismiss} - /> - ); -} - -export function PRClosedBanner({ taskId }: { taskId: string }) { - const taskPRs = useAppStore((state) => state.taskPRs.byTaskId[taskId]); - const [dismissed, setDismissed] = useState(() => wasPRClosedBannerDismissed(taskId)); - const handleArchive = useArchiveTaskAction(taskId); - - const handleDismiss = useCallback(() => { - markPRClosedBannerDismissed(taskId); - setDismissed(true); - }, [taskId]); - - // Mirror the merged banner's all-or-nothing rule: show only once every PR is - // closed-without-merging. A mix of merged + closed shows neither banner. - const allClosed = !!taskPRs && taskPRs.length > 0 && taskPRs.every((pr) => pr.state === "closed"); - if (!allClosed || dismissed) return null; - - const bannerText = - taskPRs.length === 1 - ? `PR #${taskPRs[0].pr_number} was closed without merging. You can archive this task.` - : `All ${taskPRs.length} PRs were closed without merging. You can archive this task.`; - - return ( - } - text={bannerText} - containerClass="flex flex-1 items-center gap-2 rounded-md bg-red-500/10 px-2 py-1 text-red-600 dark:text-red-400" - archiveClass="underline underline-offset-2 hover:text-red-700 dark:hover:text-red-300 cursor-pointer" - dismissClass="p-0.5 hover:bg-red-500/10 rounded cursor-pointer" - onArchive={handleArchive} - onDismiss={handleDismiss} - /> - ); -} - type TodoDisplayItem = { text: string; done?: boolean; diff --git a/apps/web/components/task/chat/pr-archive-banners.test.tsx b/apps/web/components/task/chat/pr-archive-banners.test.tsx new file mode 100644 index 0000000000..941aed8aa4 --- /dev/null +++ b/apps/web/components/task/chat/pr-archive-banners.test.tsx @@ -0,0 +1,183 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; + +const archiveAndSwitchMock = vi.fn(); +const toastMock = vi.fn(); +const mockGetSubtaskCount = vi.fn(); +const taskPRsByTaskId = vi.hoisted(() => ({ + value: { + "task-1": [{ pr_number: 42, state: "merged" }], + } as Record>, +})); + +const mockState = { + taskPRs: { + get byTaskId() { + return taskPRsByTaskId.value; + }, + }, + kanban: { + tasks: [{ id: "task-1", title: "Task One", primaryExecutorType: "worktree" }], + }, + kanbanMulti: { snapshots: {} }, +}; + +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (state: typeof mockState) => unknown) => selector(mockState), + useAppStoreApi: () => ({ getState: () => mockState }), +})); + +vi.mock("@/hooks/use-task-actions", () => ({ + useArchiveAndSwitchTask: () => archiveAndSwitchMock, +})); + +vi.mock("@/components/toast-provider", () => ({ + useToast: () => ({ toast: toastMock }), +})); + +vi.mock("@/lib/api", () => ({ + getSubtaskCount: (...args: unknown[]) => mockGetSubtaskCount(...args), +})); + +vi.mock("@/lib/local-storage", async (importOriginal) => ({ + ...(await importOriginal()), + markPRClosedBannerDismissed: vi.fn(), + markPRMergedBannerDismissed: vi.fn(), + wasPRClosedBannerDismissed: () => false, + wasPRMergedBannerDismissed: () => false, +})); + +import { PRClosedBanner, PRMergedBanner } from "./pr-archive-banners"; + +const MERGED_ARCHIVE_BUTTON = "pr-merged-archive-button"; +const MERGED_ARCHIVE_CONFIRM = "pr-merged-archive-confirm"; + +beforeEach(() => { + archiveAndSwitchMock.mockResolvedValue(undefined); + mockGetSubtaskCount.mockResolvedValue({ count: 0 }); + taskPRsByTaskId.value = { + "task-1": [{ pr_number: 42, state: "merged" }], + }; +}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + vi.clearAllMocks(); +}); + +describe("PRMergedBanner", () => { + it("opens the confirmation dialog instead of archiving directly", async () => { + render(); + + fireEvent.click(screen.getByTestId(MERGED_ARCHIVE_BUTTON)); + + expect(await screen.findByTestId(MERGED_ARCHIVE_CONFIRM)).toBeTruthy(); + expect(screen.getByText(/Are you sure you want to archive "Task One"\?/)).toBeTruthy(); + expect(archiveAndSwitchMock).not.toHaveBeenCalled(); + }); + + it("archives after confirming, without a success toast", async () => { + render(); + + fireEvent.click(screen.getByTestId(MERGED_ARCHIVE_BUTTON)); + fireEvent.click(await screen.findByTestId(MERGED_ARCHIVE_CONFIRM)); + + await waitFor(() => + expect(archiveAndSwitchMock).toHaveBeenCalledWith("task-1", { cascade: false }), + ); + expect(toastMock).not.toHaveBeenCalled(); + }); + + it("does not archive when the dialog is cancelled", async () => { + render(); + + fireEvent.click(screen.getByTestId(MERGED_ARCHIVE_BUTTON)); + fireEvent.click(await screen.findByText("Cancel")); + + await waitFor(() => expect(screen.queryByTestId(MERGED_ARCHIVE_CONFIRM)).toBeNull()); + expect(archiveAndSwitchMock).not.toHaveBeenCalled(); + expect(screen.getByTestId("pr-merged-banner")).toBeTruthy(); + }); + + it("keeps the failure toast when archive fails", async () => { + archiveAndSwitchMock.mockRejectedValueOnce(new Error("archive failed")); + render(); + + fireEvent.click(screen.getByTestId(MERGED_ARCHIVE_BUTTON)); + fireEvent.click(await screen.findByTestId(MERGED_ARCHIVE_CONFIRM)); + + await waitFor(() => + expect(toastMock).toHaveBeenCalledWith({ + description: "Failed to archive task", + variant: "error", + }), + ); + }); + + it("passes cascade=true through when the subtask checkbox is ticked", async () => { + mockGetSubtaskCount.mockResolvedValue({ count: 2 }); + render(); + + fireEvent.click(screen.getByTestId(MERGED_ARCHIVE_BUTTON)); + fireEvent.click(await screen.findByTestId("archive-cascade-checkbox")); + fireEvent.click(screen.getByTestId(MERGED_ARCHIVE_CONFIRM)); + + await waitFor(() => + expect(archiveAndSwitchMock).toHaveBeenCalledWith("task-1", { cascade: true }), + ); + }); + + it("disables the confirm button while an archive is in flight", async () => { + let resolveArchive: () => void = () => {}; + archiveAndSwitchMock.mockImplementation( + () => new Promise((resolve) => (resolveArchive = resolve)), + ); + render(); + + fireEvent.click(screen.getByTestId(MERGED_ARCHIVE_BUTTON)); + fireEvent.click(await screen.findByTestId(MERGED_ARCHIVE_CONFIRM)); + await waitFor(() => expect(archiveAndSwitchMock).toHaveBeenCalledTimes(1)); + + // Reopen while the async archive is still pending: confirm must be disabled. + fireEvent.click(screen.getByTestId(MERGED_ARCHIVE_BUTTON)); + const confirm = await screen.findByTestId(MERGED_ARCHIVE_CONFIRM); + expect(confirm.disabled).toBe(true); + fireEvent.click(confirm); + expect(archiveAndSwitchMock).toHaveBeenCalledTimes(1); + + resolveArchive(); + await waitFor(() => + expect(screen.getByTestId(MERGED_ARCHIVE_CONFIRM).disabled).toBe(false), + ); + }); + + it("falls back to a generic title when the task is not in the store", async () => { + taskPRsByTaskId.value = { + "task-2": [{ pr_number: 7, state: "merged" }], + }; + render(); + + fireEvent.click(screen.getByTestId(MERGED_ARCHIVE_BUTTON)); + + expect(await screen.findByTestId(MERGED_ARCHIVE_CONFIRM)).toBeTruthy(); + expect(screen.getByText(/Are you sure you want to archive "this task"\?/)).toBeTruthy(); + }); +}); + +describe("PRClosedBanner", () => { + it("archives through the confirmation dialog", async () => { + taskPRsByTaskId.value = { + "task-1": [{ pr_number: 42, state: "closed" }], + }; + render(); + + fireEvent.click(screen.getByTestId("pr-closed-archive-button")); + expect(archiveAndSwitchMock).not.toHaveBeenCalled(); + fireEvent.click(await screen.findByTestId("pr-closed-archive-confirm")); + + await waitFor(() => + expect(archiveAndSwitchMock).toHaveBeenCalledWith("task-1", { cascade: false }), + ); + }); +}); diff --git a/apps/web/components/task/chat/pr-archive-banners.tsx b/apps/web/components/task/chat/pr-archive-banners.tsx new file mode 100644 index 0000000000..a134759e8f --- /dev/null +++ b/apps/web/components/task/chat/pr-archive-banners.tsx @@ -0,0 +1,182 @@ +"use client"; + +import { useCallback, useState, type ReactNode } from "react"; +import { IconGitMerge, IconGitPullRequestClosed, IconX } from "@tabler/icons-react"; +import { TaskArchiveConfirmDialog } from "@/components/task/task-archive-confirm-dialog"; +import { useAppStore, useAppStoreApi } from "@/components/state-provider"; +import { useArchiveAndSwitchTask } from "@/hooks/use-task-actions"; +import { useToast } from "@/components/toast-provider"; +import { findTaskInSnapshots } from "@/lib/kanban/find-task"; +import { + markPRClosedBannerDismissed, + markPRMergedBannerDismissed, + wasPRClosedBannerDismissed, + wasPRMergedBannerDismissed, +} from "@/lib/local-storage"; + +type ArchiveTarget = { title: string; executorType?: string | null }; + +// Archiving from the terminal-state banners goes through the same confirmation +// dialog as every other archive surface. Only failures toast; on success the +// archive-and-switch flow moves the user to the next task. +function useBannerArchiveConfirm(taskId: string) { + const store = useAppStoreApi(); + const archiveAndSwitch = useArchiveAndSwitchTask(); + const { toast } = useToast(); + const [target, setTarget] = useState(null); + const [isPending, setIsPending] = useState(false); + + const requestArchive = useCallback(() => { + const state = store.getState(); + const task = findTaskInSnapshots(taskId, state.kanbanMulti.snapshots, state.kanban.tasks); + setTarget({ title: task?.title ?? "this task", executorType: task?.primaryExecutorType }); + }, [store, taskId]); + + const closeConfirm = useCallback(() => setTarget(null), []); + + const confirmArchive = useCallback( + async ({ cascade }: { cascade: boolean }) => { + setIsPending(true); + try { + await archiveAndSwitch(taskId, { cascade }); + } catch { + toast({ description: "Failed to archive task", variant: "error" }); + } finally { + setIsPending(false); + } + }, + [archiveAndSwitch, taskId, toast], + ); + + return { target, requestArchive, closeConfirm, confirmArchive, isPending }; +} + +// Presentational banner shared by PRMergedBanner / PRClosedBanner — an icon, a +// message, and Archive + Dismiss controls. Colors/icon/testIds are supplied by +// the caller so the two variants stay visually distinct. The Archive control +// opens the shared archive confirmation dialog rather than archiving directly. +function ArchiveDismissBanner({ + testIdPrefix, + icon, + text, + containerClass, + archiveClass, + dismissClass, + taskId, + onDismiss, +}: { + testIdPrefix: string; + icon: ReactNode; + text: string; + containerClass: string; + archiveClass: string; + dismissClass: string; + taskId: string; + onDismiss: () => void; +}) { + const { target, requestArchive, closeConfirm, confirmArchive, isPending } = + useBannerArchiveConfirm(taskId); + return ( + <> +
+ {icon} + {text} + + +
+ { + if (!open) closeConfirm(); + }} + taskTitle={target?.title ?? ""} + taskId={taskId} + executorType={target?.executorType} + isArchiving={isPending} + onConfirm={confirmArchive} + confirmTestId={`${testIdPrefix}-archive-confirm`} + /> + + ); +} + +export function PRMergedBanner({ taskId }: { taskId: string }) { + const taskPRs = useAppStore((state) => state.taskPRs.byTaskId[taskId]); + const [dismissed, setDismissed] = useState(() => wasPRMergedBannerDismissed(taskId)); + + const handleDismiss = useCallback(() => { + markPRMergedBannerDismissed(taskId); + setDismissed(true); + }, [taskId]); + + // Multi-repo: only show "ready to archive" once every PR is merged. A + // single merged repo with others still open means the task isn't done yet. + const allMerged = !!taskPRs && taskPRs.length > 0 && taskPRs.every((pr) => pr.state === "merged"); + if (!allMerged || dismissed) return null; + + const bannerText = + taskPRs.length === 1 + ? `PR #${taskPRs[0].pr_number} has been merged. You can archive this task.` + : `All ${taskPRs.length} PRs have been merged. You can archive this task.`; + + return ( + } + text={bannerText} + containerClass="flex flex-1 items-center gap-2 rounded-md bg-purple-500/10 px-2 py-1 text-purple-600 dark:text-purple-400" + archiveClass="underline underline-offset-2 hover:text-purple-700 dark:hover:text-purple-300 cursor-pointer" + dismissClass="p-0.5 hover:bg-purple-500/10 rounded cursor-pointer" + taskId={taskId} + onDismiss={handleDismiss} + /> + ); +} + +export function PRClosedBanner({ taskId }: { taskId: string }) { + const taskPRs = useAppStore((state) => state.taskPRs.byTaskId[taskId]); + const [dismissed, setDismissed] = useState(() => wasPRClosedBannerDismissed(taskId)); + + const handleDismiss = useCallback(() => { + markPRClosedBannerDismissed(taskId); + setDismissed(true); + }, [taskId]); + + // Mirror the merged banner's all-or-nothing rule: show only once every PR is + // closed-without-merging. A mix of merged + closed shows neither banner. + const allClosed = !!taskPRs && taskPRs.length > 0 && taskPRs.every((pr) => pr.state === "closed"); + if (!allClosed || dismissed) return null; + + const bannerText = + taskPRs.length === 1 + ? `PR #${taskPRs[0].pr_number} was closed without merging. You can archive this task.` + : `All ${taskPRs.length} PRs were closed without merging. You can archive this task.`; + + return ( + } + text={bannerText} + containerClass="flex flex-1 items-center gap-2 rounded-md bg-red-500/10 px-2 py-1 text-red-600 dark:text-red-400" + archiveClass="underline underline-offset-2 hover:text-red-700 dark:hover:text-red-300 cursor-pointer" + dismissClass="p-0.5 hover:bg-red-500/10 rounded cursor-pointer" + taskId={taskId} + onDismiss={handleDismiss} + /> + ); +} diff --git a/apps/web/components/task/passthrough-toolbar.test.tsx b/apps/web/components/task/passthrough-toolbar.test.tsx index 177c4f6887..d9ae399cb4 100644 --- a/apps/web/components/task/passthrough-toolbar.test.tsx +++ b/apps/web/components/task/passthrough-toolbar.test.tsx @@ -111,7 +111,7 @@ vi.mock("@/components/github/pr-status-chip", () => ({ PRStatusChip: () => null, })); -vi.mock("./chat/chat-input-area", () => ({ +vi.mock("./chat/pr-archive-banners", () => ({ PRMergedBanner: () => null, })); diff --git a/apps/web/components/task/passthrough-toolbar.tsx b/apps/web/components/task/passthrough-toolbar.tsx index d97bfcdca3..267802404f 100644 --- a/apps/web/components/task/passthrough-toolbar.tsx +++ b/apps/web/components/task/passthrough-toolbar.tsx @@ -21,7 +21,7 @@ import { Button } from "@kandev/ui/button"; import { Textarea } from "@kandev/ui/textarea"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { PRStatusChip } from "@/components/github/pr-status-chip"; -import { PRMergedBanner } from "./chat/chat-input-area"; +import { PRMergedBanner } from "./chat/pr-archive-banners"; import { type ChatInputContainerHandle } from "./chat/chat-input-container"; import { useChatPanelState } from "./chat/use-chat-panel-state"; import { useAppStore } from "@/components/state-provider"; diff --git a/apps/web/e2e/pages/session-page.ts b/apps/web/e2e/pages/session-page.ts index 5564702b5c..6f3632b989 100644 --- a/apps/web/e2e/pages/session-page.ts +++ b/apps/web/e2e/pages/session-page.ts @@ -58,6 +58,9 @@ export class SessionPage { prMergedArchiveButton() { return this.page.getByTestId("pr-merged-archive-button"); } + prMergedArchiveConfirmButton() { + return this.page.getByTestId("pr-merged-archive-confirm"); + } prMergedDismissButton() { return this.page.getByTestId("pr-merged-dismiss-button"); } @@ -67,6 +70,9 @@ export class SessionPage { prClosedArchiveButton() { return this.page.getByTestId("pr-closed-archive-button"); } + prClosedArchiveConfirmButton() { + return this.page.getByTestId("pr-closed-archive-confirm"); + } prClosedDismissButton() { return this.page.getByTestId("pr-closed-dismiss-button"); } diff --git a/apps/web/e2e/tests/chat/chat-status-bar.spec.ts b/apps/web/e2e/tests/chat/chat-status-bar.spec.ts index 03687025ad..b3b13e7328 100644 --- a/apps/web/e2e/tests/chat/chat-status-bar.spec.ts +++ b/apps/web/e2e/tests/chat/chat-status-bar.spec.ts @@ -454,8 +454,18 @@ test.describe("Chat status bar", () => { const urlBeforeArchive = testPage.url(); - // Click archive in the PR merged banner + // Cancelling the confirmation dialog must not archive anything await session.prMergedArchiveButton().click(); + await expect(session.prMergedArchiveConfirmButton()).toBeVisible({ timeout: 10_000 }); + await testPage.getByRole("alertdialog").getByRole("button", { name: "Cancel" }).click(); + await expect(session.prMergedArchiveConfirmButton()).not.toBeVisible(); + await expect(session.prMergedBanner()).toBeVisible(); + expect(testPage.url()).toBe(urlBeforeArchive); + + // Click archive in the PR merged banner, then confirm in the dialog + await session.prMergedArchiveButton().click(); + await expect(session.prMergedArchiveConfirmButton()).toBeVisible({ timeout: 10_000 }); + await session.prMergedArchiveConfirmButton().click(); // Should switch to task B await expect(session.taskInSidebar("Archive Banner Task A")).not.toBeVisible({