diff --git a/apps/app/src/components/dialogs/ThreadArchiveDialog.tsx b/apps/app/src/components/dialogs/ThreadArchiveDialog.tsx
new file mode 100644
index 0000000000..d916da6c26
--- /dev/null
+++ b/apps/app/src/components/dialogs/ThreadArchiveDialog.tsx
@@ -0,0 +1,39 @@
+import type { Thread } from "@bb/domain";
+import {
+ ConfirmDeleteDialog,
+ ConfirmDeleteDialogContent,
+} from "./ConfirmDeleteDialog";
+
+export interface ThreadArchiveDialogTarget {
+ thread: Thread;
+ workingThreadCount: 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 (
+
+ {target ? (
+ onArchive(target)}
+ onCancel={() => onOpenChange(false)}
+ />
+ ) : null}
+
+ );
+}
diff --git a/apps/app/src/components/thread/ThreadActionsProvider.test.tsx b/apps/app/src/components/thread/ThreadActionsProvider.test.tsx
index aa821a5a36..1a124e37d9 100644
--- a/apps/app/src/components/thread/ThreadActionsProvider.test.tsx
+++ b/apps/app/src/components/thread/ThreadActionsProvider.test.tsx
@@ -13,12 +13,14 @@ import {
} from "./ThreadActionsProvider";
const mocks = vi.hoisted(() => ({
+ archiveConfirm: vi.fn(),
closePanesForThreads: vi.fn(),
dialogOnClose: vi.fn(),
dialogOnOpen: vi.fn(),
dialogOnOpenChange: vi.fn(),
mutation: vi.fn(),
navigate: vi.fn(),
+ workingArchiveThreadCount: vi.fn(() => 0),
}));
vi.mock("react-router-dom", async (importOriginal) => {
@@ -42,6 +44,27 @@ vi.mock("@/components/dialogs/ThreadRenameDialog", () => ({
ThreadRenameDialog: () => null,
}));
+vi.mock("@/components/dialogs/ThreadArchiveDialog", () => ({
+ ThreadArchiveDialog: ({
+ onArchive,
+ }: {
+ onArchive: (target: unknown) => void;
+ }) => {
+ mocks.archiveConfirm.mockImplementation(onArchive);
+ return null;
+ },
+}));
+
+vi.mock(
+ "@/hooks/cache-owners/thread-archive-cache",
+ async (importOriginal) => ({
+ ...(await importOriginal<
+ typeof import("@/hooks/cache-owners/thread-archive-cache")
+ >()),
+ getCachedWorkingArchiveThreadCount: mocks.workingArchiveThreadCount,
+ }),
+);
+
vi.mock("@/components/ui/app-toast", () => ({
appToast: {
dismiss: vi.fn(),
@@ -153,6 +176,7 @@ beforeEach(() => {
focusedRoute: null,
removedAny: false,
});
+ mocks.workingArchiveThreadCount.mockReturnValue(0);
});
afterEach(() => {
@@ -161,6 +185,38 @@ afterEach(() => {
});
describe("ThreadActionsProvider archive feedback", () => {
+ it("asks for confirmation instead of archiving when cached work is active", () => {
+ const thread = makeThread();
+ mocks.workingArchiveThreadCount.mockReturnValue(1);
+ renderProvider();
+
+ fireEvent.click(screen.getByRole("button", { name: "Archive" }));
+
+ expect(mocks.dialogOnOpen).toHaveBeenCalledWith({
+ thread,
+ workingThreadCount: 1,
+ });
+ expect(sdk.threads.archiveAll).not.toHaveBeenCalled();
+ });
+
+ it("archives exactly once after active-work confirmation", async () => {
+ const thread = makeThread();
+ const target = { thread, workingThreadCount: 2 };
+ mocks.workingArchiveThreadCount.mockReturnValue(2);
+ renderProvider();
+
+ fireEvent.click(screen.getByRole("button", { name: "Archive" }));
+ mocks.archiveConfirm(target);
+
+ await vi.waitFor(() => {
+ expect(sdk.threads.archiveAll).toHaveBeenCalledTimes(1);
+ });
+ expect(sdk.threads.archiveAll).toHaveBeenCalledWith({
+ threadId: thread.id,
+ });
+ expect(mocks.dialogOnClose).toHaveBeenCalledTimes(1);
+ });
+
it("shows one archive toast whose Undo restores the parent and children", async () => {
renderProvider();
diff --git a/apps/app/src/components/thread/ThreadActionsProvider.tsx b/apps/app/src/components/thread/ThreadActionsProvider.tsx
index b5dfdf289d..8737d1619e 100644
--- a/apps/app/src/components/thread/ThreadActionsProvider.tsx
+++ b/apps/app/src/components/thread/ThreadActionsProvider.tsx
@@ -8,6 +8,7 @@ import {
type ReactNode,
} from "react";
import { useSetAtom } from "jotai";
+import { useQueryClient } from "@tanstack/react-query";
import { appToast } from "@/components/ui/app-toast";
import {
closePanesForThreadsAtom,
@@ -41,12 +42,17 @@ import {
ThreadDeleteDialog,
type ThreadDeleteDialogTarget,
} from "@/components/dialogs/ThreadDeleteDialog";
+import {
+ ThreadArchiveDialog,
+ type ThreadArchiveDialogTarget,
+} from "@/components/dialogs/ThreadArchiveDialog";
import { ArchivedThreadToastTitle } from "@/components/thread/ArchivedThreadToastTitle";
import { destroyPersistedBrowserViewsForThread } from "@/components/secondary-panel/browserViewVisibilityCoordinator";
import { getThreadReadToggleAction } from "@bb/client-core";
import { getRootComposeRoutePath, getThreadRoutePath } from "@/lib/route-paths";
import { getDesktopBrowserApi } from "@/lib/bb-desktop";
import { useRouteNavigate } from "@/components/ui/app-route-anchor";
+import { getCachedWorkingArchiveThreadCount } from "@/hooks/cache-owners/thread-archive-cache";
export interface ThreadActionsContextValue {
archiveThreadAndChildren: (thread: Thread) => void;
@@ -99,6 +105,7 @@ export function ThreadActionsProvider({
// Stable across navigations: the context value below must not change per
// pathname, or every mounted sidebar ThreadRow re-renders on each route change.
const navigate = useRouteNavigate();
+ const queryClient = useQueryClient();
const { threadId: viewedThreadId } = useRouteState();
// Read the currently-viewed thread live inside async mutation callbacks: a
// pane's stale-prune (deleted/archived thread) can move the URL between a
@@ -138,9 +145,12 @@ export function ThreadActionsProvider({
const renameDialog = useDialogState();
const deleteDialog = useDialogState();
+ const archiveDialog = useDialogState();
const { onClose: closeRenameDialog, onOpen: openRenameDialog } = renameDialog;
const { onClose: closeDeleteDialog, onOpen: openDeleteDialog } = deleteDialog;
+ const { onClose: closeArchiveDialog, onOpen: openArchiveDialog } =
+ archiveDialog;
useEffect(() => {
return () => {
@@ -330,7 +340,7 @@ export function ThreadActionsProvider({
[unarchiveMutate],
);
- const archiveThreadAndChildrenAction = useCallback(
+ const performArchiveThreadAndChildren = useCallback(
(thread: Thread) => {
archiveThreadAndChildrenMutateAsync({ id: thread.id }).then(
(response) => {
@@ -396,6 +406,29 @@ export function ThreadActionsProvider({
],
);
+ const archiveThreadAndChildrenAction = useCallback(
+ (thread: Thread) => {
+ const workingThreadCount = getCachedWorkingArchiveThreadCount({
+ queryClient,
+ threadId: thread.id,
+ });
+ if (workingThreadCount > 0) {
+ openArchiveDialog({ thread, workingThreadCount });
+ return;
+ }
+ performArchiveThreadAndChildren(thread);
+ },
+ [openArchiveDialog, performArchiveThreadAndChildren, queryClient],
+ );
+
+ const confirmArchive = useCallback(
+ (target: ThreadArchiveDialogTarget) => {
+ closeArchiveDialog();
+ performArchiveThreadAndChildren(target.thread);
+ },
+ [closeArchiveDialog, performArchiveThreadAndChildren],
+ );
+
const toggleRead = useCallback(
(thread: Thread) => {
if (getThreadReadToggleAction(thread) === "mark_unread") {
@@ -466,6 +499,12 @@ export function ThreadActionsProvider({
onOpenChange={renameDialog.onOpenChange}
onRename={submitRename}
/>
+
= {}): ThreadListEntry {
+ return {
+ id: "thread-parent",
+ projectId: "project-1",
+ environmentId: "env-1",
+ providerId: "codex",
+ title: null,
+ titleFallback: null,
+ sectionId: null,
+ status: "idle",
+ parentThreadId: null,
+ sourceThreadId: null,
+ originKind: null,
+ originPluginId: null,
+ visibility: "visible",
+ archivedAt: null,
+ pinnedAt: null,
+ deletedAt: null,
+ lastReadAt: null,
+ latestAttentionAt: 1,
+ createdAt: 1,
+ updatedAt: 1,
+ runtime: {
+ displayStatus: "idle",
+ hostReconnectGraceExpiresAt: null,
+ },
+ activity: {
+ activeWorkflowCount: 0,
+ activeBackgroundAgentCount: 0,
+ activeBackgroundCommandCount: 0,
+ activePlanModeCount: 0,
+ activeGoalCount: 0,
+ },
+ pinSortKey: null,
+ hasPendingInteraction: false,
+ environmentHostId: "host-1",
+ environmentName: "Environment",
+ environmentBranchName: "main",
+ environmentWorkspaceDisplayKind: "managed-worktree",
+ ...thread,
+ };
+}
+
+function makeSidebarNavigation(
+ threads: ThreadListEntry[],
+): SidebarBootstrapResponse {
+ const project = {
+ id: "project-1",
+ kind: "standard" as const,
+ name: "Project",
+ gitRemoteUrl: null,
+ createdAt: 1,
+ updatedAt: 1,
+ sources: [],
+ threads,
+ defaultExecutionOptions: null,
+ };
+ return {
+ sections: [],
+ projects: [project],
+ personalProject: {
+ ...project,
+ id: "proj_personal",
+ kind: "personal",
+ threads: [],
+ },
+ };
+}
+
+describe("getCachedWorkingArchiveThreadCount", () => {
+ it("does not guard an idle thread tree", () => {
+ const { queryClient } = createQueryClientTestHarness();
+ queryClient.setQueryData(
+ sidebarNavigationQueryKey(),
+ makeSidebarNavigation([
+ makeThread(),
+ makeThread({ id: "thread-child", parentThreadId: "thread-parent" }),
+ ]),
+ );
+
+ expect(
+ getCachedWorkingArchiveThreadCount({
+ queryClient,
+ threadId: "thread-parent",
+ }),
+ ).toBe(0);
+ });
+
+ it("counts working activity on the thread and its children", () => {
+ const { queryClient } = createQueryClientTestHarness();
+ queryClient.setQueryData(
+ sidebarNavigationQueryKey(),
+ makeSidebarNavigation([
+ makeThread({
+ runtime: {
+ displayStatus: "active",
+ hostReconnectGraceExpiresAt: null,
+ },
+ }),
+ makeThread({
+ id: "thread-child",
+ parentThreadId: "thread-parent",
+ activity: {
+ activeWorkflowCount: 0,
+ activeBackgroundAgentCount: 1,
+ activeBackgroundCommandCount: 0,
+ activePlanModeCount: 0,
+ activeGoalCount: 0,
+ },
+ }),
+ makeThread({
+ id: "unrelated-thread",
+ activity: {
+ activeWorkflowCount: 1,
+ activeBackgroundAgentCount: 0,
+ activeBackgroundCommandCount: 0,
+ activePlanModeCount: 0,
+ activeGoalCount: 0,
+ },
+ }),
+ ]),
+ );
+
+ expect(
+ getCachedWorkingArchiveThreadCount({
+ queryClient,
+ threadId: "thread-parent",
+ }),
+ ).toBe(2);
+ });
+});
diff --git a/apps/app/src/hooks/cache-owners/thread-archive-cache.ts b/apps/app/src/hooks/cache-owners/thread-archive-cache.ts
index 85855cfbd1..af9419335f 100644
--- a/apps/app/src/hooks/cache-owners/thread-archive-cache.ts
+++ b/apps/app/src/hooks/cache-owners/thread-archive-cache.ts
@@ -1,5 +1,13 @@
import type { QueryClient } from "@tanstack/react-query";
import type { ThreadListEntry, ThreadWithRuntime } from "@bb/domain";
+import {
+ hasActiveBackgroundAgentActivity,
+ hasActiveBackgroundCommandActivity,
+ hasActiveGoalActivity,
+ hasActivePlanModeActivity,
+ hasActiveWorkflowActivity,
+ isRuntimeBusyThread,
+} from "@bb/client-core";
import { threadQueryKey, threadsQueryKey } from "../queries/query-keys";
import {
applyToCachedSidebarNavigationThreads,
@@ -60,6 +68,51 @@ export function getCachedLiveThreadIdsMatching({
return Array.from(threadIds);
}
+/**
+ * Counts cached live threads in an archive cascade that currently have working
+ * activity. This stays synchronous so idle archives never wait on preflight.
+ */
+export function getCachedWorkingArchiveThreadCount({
+ queryClient,
+ threadId,
+}: {
+ queryClient: QueryClient;
+ threadId: string;
+}): number {
+ const workingThreadIds = new Set();
+ const collectMatchingThread = (thread: ThreadListEntry) => {
+ if (
+ thread.archivedAt !== null ||
+ (thread.id !== threadId && thread.parentThreadId !== threadId)
+ ) {
+ return;
+ }
+ if (
+ isRuntimeBusyThread(thread) ||
+ hasActiveWorkflowActivity(thread) ||
+ hasActiveBackgroundAgentActivity(thread) ||
+ hasActiveBackgroundCommandActivity(thread) ||
+ hasActivePlanModeActivity(thread) ||
+ hasActiveGoalActivity(thread)
+ ) {
+ workingThreadIds.add(thread.id);
+ }
+ };
+
+ for (const { data } of getCachedThreadLists(queryClient, {
+ queryKey: threadsQueryKey(),
+ })) {
+ for (const thread of iterateThreadListCacheEntries(data)) {
+ collectMatchingThread(thread);
+ }
+ }
+ for (const thread of getCachedSidebarNavigationThreads(queryClient)) {
+ collectMatchingThread(thread);
+ }
+
+ return workingThreadIds.size;
+}
+
export function getCachedThreadSnapshots({
queryClient,
threadIds,