From c5a30f1b4dbb30304ea75d3826ddac6d03d31c4f Mon Sep 17 00:00:00 2001 From: Joshua Pham Date: Mon, 24 Aug 2026 19:02:18 +0000 Subject: [PATCH] Confirm archive when cached thread work is active --- .../dialogs/ThreadArchiveDialog.tsx | 39 +++++ .../thread/ThreadActionsProvider.test.tsx | 56 +++++++ .../thread/ThreadActionsProvider.tsx | 41 +++++- .../cache-owners/thread-archive-cache.test.ts | 138 ++++++++++++++++++ .../cache-owners/thread-archive-cache.ts | 53 +++++++ 5 files changed, 326 insertions(+), 1 deletion(-) create mode 100644 apps/app/src/components/dialogs/ThreadArchiveDialog.tsx create mode 100644 apps/app/src/hooks/cache-owners/thread-archive-cache.test.ts 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,