fix(web): confirm before archiving from PR merged/closed banners (#1619)

* fix(web): require confirmation before archiving from PR merged/closed banners

The Archive link in the PR merged/closed status-bar banners archived the
task immediately, unlike every other archive surface. Route it through the
shared TaskArchiveConfirmDialog (cleanup summary + subtask cascade), and
move the banners into pr-archive-banners.tsx since chat-input-area.tsx was
over the file-size lint limit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(web): cover cascade passthrough in banner archive confirmation

Review follow-up: the subtask cascade checkbox is an acceptance criterion
for the banner archive flow, but tests only asserted cascade: false.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(e2e): verify cancel path of PR banner archive confirmation

QA follow-up: exercise the dialog cancel path in a real browser — cancel
must close the dialog, keep the banner, and archive nothing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(web): expose pending state on banner archive confirmation

Thread isPending from useBannerArchiveConfirm into
TaskArchiveConfirmDialog as isArchiving, matching the other archive
surfaces. Guards double-submit when the dialog is reopened while an
archive is still in flight on a slow connection.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(e2e): scope archive-cancel click to the alertdialog

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
João Salavisa
2026-07-08 12:14:01 +01:00
committed by GitHub
parent 1ed2c59997
commit f7d8d5d603
8 changed files with 395 additions and 205 deletions
@@ -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<string, Array<{ pr_number: number; state: string }>>,
}));
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<typeof import("@/lib/local-storage")>()),
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(<PRMergedBanner taskId="task-1" />);
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(<PRMergedBanner taskId="task-1" />);
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 {
@@ -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 (
<div data-testid={`${testIdPrefix}-banner`} className={containerClass}>
{icon}
<span className="flex-1">{text}</span>
<button
type="button"
data-testid={`${testIdPrefix}-archive-button`}
onClick={onArchive}
className={archiveClass}
>
Archive
</button>
<button
type="button"
aria-label="Dismiss"
data-testid={`${testIdPrefix}-dismiss-button`}
onClick={onDismiss}
className={dismissClass}
>
<IconX className="h-3 w-3" />
</button>
</div>
);
}
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 (
<ArchiveDismissBanner
testIdPrefix="pr-merged"
icon={<IconGitMerge className="h-3.5 w-3.5 shrink-0" />}
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 (
<ArchiveDismissBanner
testIdPrefix="pr-closed"
icon={<IconGitPullRequestClosed className="h-3.5 w-3.5 shrink-0" />}
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;
@@ -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<string, Array<{ pr_number: number; state: string }>>,
}));
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<typeof import("@/lib/local-storage")>()),
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(<PRMergedBanner taskId="task-1" />);
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(<PRMergedBanner taskId="task-1" />);
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(<PRMergedBanner taskId="task-1" />);
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(<PRMergedBanner taskId="task-1" />);
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(<PRMergedBanner taskId="task-1" />);
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<void>((resolve) => (resolveArchive = resolve)),
);
render(<PRMergedBanner taskId="task-1" />);
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<HTMLButtonElement>(MERGED_ARCHIVE_CONFIRM);
expect(confirm.disabled).toBe(true);
fireEvent.click(confirm);
expect(archiveAndSwitchMock).toHaveBeenCalledTimes(1);
resolveArchive();
await waitFor(() =>
expect(screen.getByTestId<HTMLButtonElement>(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(<PRMergedBanner taskId="task-2" />);
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(<PRClosedBanner taskId="task-1" />);
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 }),
);
});
});
@@ -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<ArchiveTarget | null>(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 (
<>
<div data-testid={`${testIdPrefix}-banner`} className={containerClass}>
{icon}
<span className="flex-1">{text}</span>
<button
type="button"
data-testid={`${testIdPrefix}-archive-button`}
onClick={requestArchive}
className={archiveClass}
>
Archive
</button>
<button
type="button"
aria-label="Dismiss"
data-testid={`${testIdPrefix}-dismiss-button`}
onClick={onDismiss}
className={dismissClass}
>
<IconX className="h-3 w-3" />
</button>
</div>
<TaskArchiveConfirmDialog
open={target !== null}
onOpenChange={(open) => {
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 (
<ArchiveDismissBanner
testIdPrefix="pr-merged"
icon={<IconGitMerge className="h-3.5 w-3.5 shrink-0" />}
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 (
<ArchiveDismissBanner
testIdPrefix="pr-closed"
icon={<IconGitPullRequestClosed className="h-3.5 w-3.5 shrink-0" />}
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}
/>
);
}
@@ -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,
}));
@@ -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";
+6
View File
@@ -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");
}
@@ -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({