Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions apps/app/src/components/dialogs/ThreadArchiveDialog.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<ConfirmDeleteDialog open={target !== null} onOpenChange={onOpenChange}>
{target ? (
<ConfirmDeleteDialogContent
title="Archive active work?"
description={`${target.workingThreadCount === 1 ? "A thread is" : `${target.workingThreadCount} threads are`} currently working in this thread tree. Archiving may interrupt the active work.`}
confirmLabel="Archive anyway"
pending={pending}
onConfirm={() => onArchive(target)}
onCancel={() => onOpenChange(false)}
/>
) : null}
</ConfirmDeleteDialog>
);
}
56 changes: 56 additions & 0 deletions apps/app/src/components/thread/ThreadActionsProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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(),
Expand Down Expand Up @@ -153,6 +176,7 @@ beforeEach(() => {
focusedRoute: null,
removedAny: false,
});
mocks.workingArchiveThreadCount.mockReturnValue(0);
});

afterEach(() => {
Expand All @@ -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(<ArchiveButton thread={thread} />);

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(<ArchiveButton thread={thread} />);

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(<ArchiveButton thread={makeThread()} />);

Expand Down
41 changes: 40 additions & 1 deletion apps/app/src/components/thread/ThreadActionsProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -138,9 +145,12 @@ export function ThreadActionsProvider({

const renameDialog = useDialogState<ThreadRenameDialogTarget>();
const deleteDialog = useDialogState<ThreadDeleteDialogTarget>();
const archiveDialog = useDialogState<ThreadArchiveDialogTarget>();

const { onClose: closeRenameDialog, onOpen: openRenameDialog } = renameDialog;
const { onClose: closeDeleteDialog, onOpen: openDeleteDialog } = deleteDialog;
const { onClose: closeArchiveDialog, onOpen: openArchiveDialog } =
archiveDialog;

useEffect(() => {
return () => {
Expand Down Expand Up @@ -330,7 +340,7 @@ export function ThreadActionsProvider({
[unarchiveMutate],
);

const archiveThreadAndChildrenAction = useCallback(
const performArchiveThreadAndChildren = useCallback(
(thread: Thread) => {
archiveThreadAndChildrenMutateAsync({ id: thread.id }).then(
(response) => {
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -466,6 +499,12 @@ export function ThreadActionsProvider({
onOpenChange={renameDialog.onOpenChange}
onRename={submitRename}
/>
<ThreadArchiveDialog
target={archiveDialog.target}
pending={archiveThreadAndChildrenMutation.isPending}
onOpenChange={archiveDialog.onOpenChange}
onArchive={confirmArchive}
/>
<ThreadDeleteDialog
target={deleteDialog.target}
pending={deleteThread.isPending}
Expand Down
138 changes: 138 additions & 0 deletions apps/app/src/hooks/cache-owners/thread-archive-cache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import type { ThreadListEntry } from "@bb/domain";
import type { SidebarBootstrapResponse } from "@bb/server-contract";
import { describe, expect, it } from "vitest";
import { createQueryClientTestHarness } from "@/test/queryClientTestHarness";
import { sidebarNavigationQueryKey } from "../queries/query-keys";
import { getCachedWorkingArchiveThreadCount } from "./thread-archive-cache";

function makeThread(thread: Partial<ThreadListEntry> = {}): 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);
});
});
Loading
Loading