diff --git a/apps/app/src/components/dialogs/ThreadArchiveDialog.test.tsx b/apps/app/src/components/dialogs/ThreadArchiveDialog.test.tsx
new file mode 100644
index 00000000000..e6950930eb1
--- /dev/null
+++ b/apps/app/src/components/dialogs/ThreadArchiveDialog.test.tsx
@@ -0,0 +1,83 @@
+// @vitest-environment jsdom
+
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { makeThread } from "@bb/test-helpers/domain-fixtures";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { ThreadArchiveDialog } from "./ThreadArchiveDialog";
+
+afterEach(() => {
+ cleanup();
+});
+
+function renderDialog({
+ childThreadCount,
+ status = "idle",
+}: {
+ childThreadCount?: number;
+ status?: "idle" | "starting" | "active" | "stopping";
+} = {}) {
+ const onArchive = vi.fn();
+ const onOpenChange = vi.fn();
+ const thread = makeThread({ status });
+ const view = render(
+ ,
+ );
+ return { onArchive, onOpenChange, thread, view };
+}
+
+describe("ThreadArchiveDialog", () => {
+ it("announces the cascade with singular and plural child counts", () => {
+ const { view } = renderDialog({ childThreadCount: 1 });
+ expect(
+ screen.getByText(/1 child thread will be archived too\./),
+ ).toBeTruthy();
+
+ view.unmount();
+ renderDialog({ childThreadCount: 3 });
+ expect(
+ screen.getByText(/3 child threads will be archived too\./),
+ ).toBeTruthy();
+ });
+
+ it("omits the cascade sentence when the thread has no children", () => {
+ renderDialog();
+
+ expect(screen.queryByText(/will be archived too/)).toBeNull();
+ expect(
+ screen.getByText(
+ "Archived threads stay available and can be unarchived.",
+ ),
+ ).toBeTruthy();
+ });
+
+ it.each(["starting", "active", "stopping"] as const)(
+ "warns that current work will stop for a %s thread",
+ (status) => {
+ renderDialog({ status });
+ expect(screen.getByText(/This will stop current work\./)).toBeTruthy();
+ },
+ );
+
+ it("omits the active-work warning for an idle thread", () => {
+ renderDialog();
+ expect(screen.queryByText(/This will stop current work\./)).toBeNull();
+ });
+
+ it("archives only when confirmation is accepted", () => {
+ const { onArchive, onOpenChange, thread } = renderDialog({
+ childThreadCount: 2,
+ });
+
+ fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
+ expect(onArchive).not.toHaveBeenCalled();
+ expect(onOpenChange).toHaveBeenCalledWith(false);
+
+ fireEvent.click(screen.getByRole("button", { name: "Archive thread" }));
+ expect(onArchive).toHaveBeenCalledWith({ thread, childThreadCount: 2 });
+ });
+});
diff --git a/apps/app/src/components/dialogs/ThreadArchiveDialog.tsx b/apps/app/src/components/dialogs/ThreadArchiveDialog.tsx
new file mode 100644
index 00000000000..4874938a633
--- /dev/null
+++ b/apps/app/src/components/dialogs/ThreadArchiveDialog.tsx
@@ -0,0 +1,97 @@
+import type { Thread } from "@bb/domain";
+import { Button } from "@bb/shared-ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@bb/shared-ui/dialog";
+
+export interface ThreadArchiveDialogTarget {
+ thread: Thread;
+ childThreadCount?: number;
+}
+
+interface ThreadArchiveDialogProps {
+ target: ThreadArchiveDialogTarget | null;
+ pending: boolean;
+ onOpenChange: (open: boolean) => void;
+ onArchive: (target: ThreadArchiveDialogTarget) => void;
+}
+
+export function ThreadArchiveDialog({
+ target,
+ pending,
+ onOpenChange,
+ onArchive,
+}: ThreadArchiveDialogProps) {
+ return (
+
+ );
+}
+
+interface ThreadArchiveDialogContentProps {
+ target: ThreadArchiveDialogTarget;
+ pending: boolean;
+ onOpenChange: (open: boolean) => void;
+ onArchive: (target: ThreadArchiveDialogTarget) => void;
+}
+
+export function ThreadArchiveDialogContent({
+ target,
+ pending,
+ onOpenChange,
+ onArchive,
+}: ThreadArchiveDialogContentProps) {
+ const childThreadCount = target.childThreadCount ?? 0;
+ const active =
+ target.thread.status === "starting" ||
+ target.thread.status === "active" ||
+ target.thread.status === "stopping";
+ const sentences = [
+ active ? "This will stop current work." : null,
+ childThreadCount > 0
+ ? `${childThreadCount} child ${childThreadCount === 1 ? "thread" : "threads"} will be archived too.`
+ : null,
+ "Archived threads stay available and can be unarchived.",
+ ].filter((sentence): sentence is string => sentence !== null);
+
+ return (
+ <>
+
+ Archive thread?
+ {sentences.join(" ")}
+
+
+
+
+
+ >
+ );
+}
diff --git a/apps/app/src/components/thread/ThreadActionsMenu.test.tsx b/apps/app/src/components/thread/ThreadActionsMenu.test.tsx
index 70e1506dd52..61158215314 100644
--- a/apps/app/src/components/thread/ThreadActionsMenu.test.tsx
+++ b/apps/app/src/components/thread/ThreadActionsMenu.test.tsx
@@ -30,7 +30,7 @@ import { useSidebarRename } from "../sidebar/SidebarInlineRename";
const moveThreadToSection = vi.hoisted(() => vi.fn());
const copyToClipboardWithToast = vi.hoisted(() => vi.fn());
const threadActions = vi.hoisted(() => ({
- archiveThreadAndChildren: vi.fn(),
+ requestArchive: vi.fn(),
requestDelete: vi.fn(),
requestRename: vi.fn(),
togglePin: vi.fn(),
diff --git a/apps/app/src/components/thread/ThreadActionsMenu.tsx b/apps/app/src/components/thread/ThreadActionsMenu.tsx
index 9264c34b43a..1210b4887db 100644
--- a/apps/app/src/components/thread/ThreadActionsMenu.tsx
+++ b/apps/app/src/components/thread/ThreadActionsMenu.tsx
@@ -184,7 +184,7 @@ function ThreadActionsMenuItems({
surface,
}: ThreadActionsMenuItemsProps) {
const {
- archiveThreadAndChildren,
+ requestArchive,
requestRename,
requestDelete,
togglePin,
@@ -307,7 +307,9 @@ function ThreadActionsMenuItems({
unarchiveThread(thread);
return;
}
- archiveThreadAndChildren(thread);
+ window.setTimeout(() => {
+ requestArchive(thread);
+ }, 0);
}}
>
{isArchived ? "Unarchive" : "Archive"}
@@ -353,7 +355,7 @@ export function ThreadArchiveQuickAction({
className?: string;
disabled?: boolean;
}) {
- const { archiveThreadAndChildren, unarchiveThread } = useThreadActions();
+ const { requestArchive, unarchiveThread } = useThreadActions();
const isArchived = thread.archivedAt != null;
const label = isArchived ? "Unarchive" : "Archive";
return (
@@ -373,7 +375,7 @@ export function ThreadArchiveQuickAction({
unarchiveThread(thread);
return;
}
- archiveThreadAndChildren(thread);
+ requestArchive(thread);
}}
>
({
closePanesForThreads: vi.fn(),
- dialogOnClose: vi.fn(),
- dialogOnOpen: vi.fn(),
- dialogOnOpenChange: vi.fn(),
mutation: vi.fn(),
navigate: vi.fn(),
pathname: "/",
@@ -94,15 +91,6 @@ vi.mock("@/lib/sdk", () => ({
},
}));
-vi.mock("@/hooks/useDialogState", () => ({
- useDialogState: () => ({
- onClose: mocks.dialogOnClose,
- onOpen: mocks.dialogOnOpen,
- onOpenChange: mocks.dialogOnOpenChange,
- target: null,
- }),
-}));
-
vi.mock("@/hooks/useRouteState", () => ({
useRouteState: () => ({ threadId: mocks.viewedThreadId }),
}));
@@ -121,9 +109,9 @@ function makeThread(overrides: Partial = {}): Thread {
}
function ArchiveButton({ thread }: { thread: Thread }) {
- const { archiveThreadAndChildren } = useThreadActions();
+ const { requestArchive } = useThreadActions();
return (
-