Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -689,14 +689,14 @@ export function Overview() {
/>
</StoryRow>
<StoryRow
label="environment destroyed"
hint="environment-gone row suppresses git/childThreads"
label="environment archived"
hint="archived-environment row suppresses git/childThreads"
>
<Row environmentGone={destroyedEnvironmentFixture} mergeBase={null} />
</StoryRow>
<StoryRow
label="environment destroying + child thread"
hint="environment-gone row plus parent context"
label="environment archiving + child thread"
hint="archiving-environment row plus parent context"
>
<Row
environmentGone={destroyingEnvironmentFixture}
Expand All @@ -705,8 +705,8 @@ export function Overview() {
/>
</StoryRow>
<StoryRow
label="environment gone (with other context, all suppressed)"
hint="gone environment takes precedence — git/child work are hidden"
label="environment archived (with other context, all suppressed)"
hint="archived environment takes precedence — git/child work are hidden"
>
<Row
environmentGone={destroyedEnvironmentFixture}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,9 @@ describe("ThreadPromptContextBanner", () => {
/>,
);

expect(markup).toContain("Environment is unavailable");
expect(markup).toContain("This thread can&#x27;t run any more work.");
expect(markup).toContain("Environment archived");
expect(markup).toContain("This environment has been archived.");
expect(markup).not.toContain("to keep working");
expect(markup).toContain('role="status"');
expect(markup).not.toContain("<button");
expect(markup).not.toContain("Provision");
Expand All @@ -115,10 +116,10 @@ describe("ThreadPromptContextBanner", () => {
expectedLabel: "Thread is archived",
},
{
label: "environment gone",
label: "environment archived",
archivedSection: null,
environmentGoneSection: { status: "destroyed" as const },
expectedLabel: "Environment is unavailable",
expectedLabel: "Environment archived",
},
])(
"keeps the $label read-only status visible in compact mode",
Expand Down Expand Up @@ -150,6 +151,35 @@ describe("ThreadPromptContextBanner", () => {
},
);

it("prioritizes the archived-environment status over unarchiving", () => {
const markup = renderToStaticMarkup(
<MemoryRouter>
<ThreadPromptContextBanner
gitSection={null}
gitSectionPending={false}
archivedSection={{
archivedAt: 1_731_456_000_000,
onUnarchive: noop,
}}
environmentGoneSection={{ status: "destroyed" }}
parentThreadSection={{
parentThreadTitle: "Parent thread",
href: "/threads/thr_parent",
relationship: "parent",
}}
childThreadsSection={null}
pullRequestSection={null}
expandedSection={null}
onToggleSection={noop}
/>
</MemoryRouter>,
);

expect(markup).toContain("Environment archived");
expect(markup).not.toContain("Thread is archived");
expect(markup).not.toContain(">Unarchive<");
});

it("labels a standalone pull request without non-actionable attention text", () => {
const markup = renderToStaticMarkup(
<ThreadPromptContextBanner
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -193,9 +193,19 @@ const KIND_PREFIX: Record<WorkspaceChangedFilesSection["kind"], string> = {
};

const ARCHIVED_THREAD_STATUS_LABEL = "Thread is archived";
const ENVIRONMENT_GONE_STATUS_LABEL = "Environment is unavailable";
const ENVIRONMENT_GONE_ARIA_LABEL =
"Environment is unavailable. This thread can't run any more work.";
const ENVIRONMENT_GONE_STATUS_COPY: Record<
ThreadPromptEnvironmentGoneSection["status"],
{ ariaLabel: string; label: string }
> = {
destroying: {
ariaLabel: "This environment is being archived.",
label: "Archiving environment...",
},
destroyed: {
ariaLabel: "This environment has been archived.",
label: "Environment archived",
},
};
const PROMPT_BANNER_ACTION_FILL_CLASS = "bg-background shadow-xs";
const PROMPT_BANNER_ACTION_INTERACTIVE_CLASS =
"cursor-pointer text-muted-foreground transition-colors hover:bg-state-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-60";
Expand Down Expand Up @@ -863,21 +873,21 @@ export function ThreadPromptContextBanner({
onToggleSection,
}: ThreadPromptContextBannerProps) {
if (archivedSection || environmentGoneSection) {
const environmentGone = environmentGoneSection !== null;
const environmentGoneCopy = environmentGoneSection
? ENVIRONMENT_GONE_STATUS_COPY[environmentGoneSection.status]
: null;
return (
<ReadOnlyContextBanner
iconName={archivedSection ? "Archive" : "CircleX"}
iconName={environmentGone ? "CircleX" : "Archive"}
statusAriaLabel={
archivedSection
? ARCHIVED_THREAD_STATUS_LABEL
: ENVIRONMENT_GONE_ARIA_LABEL
environmentGoneCopy?.ariaLabel ?? ARCHIVED_THREAD_STATUS_LABEL
}
statusLabel={
archivedSection
? ARCHIVED_THREAD_STATUS_LABEL
: ENVIRONMENT_GONE_STATUS_LABEL
environmentGoneCopy?.label ?? ARCHIVED_THREAD_STATUS_LABEL
}
statusAction={
archivedSection?.onUnarchive ? (
archivedSection?.onUnarchive && !environmentGone ? (
<ThreadUnarchiveTextAction
isPending={archivedSection.unarchivePending}
onUnarchive={archivedSection.onUnarchive}
Expand Down
198 changes: 198 additions & 0 deletions apps/app/src/components/thread/ThreadActionsProvider.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
// @vitest-environment jsdom

import type { ReactNode } from "react";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Thread } from "@bb/domain";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { appToast } from "@/components/ui/app-toast";
import { sdk } from "@/lib/sdk";
import {
ThreadActionsProvider,
useThreadActions,
} from "./ThreadActionsProvider";

const mocks = vi.hoisted(() => ({
closePanesForThreads: vi.fn(),
dialogOnClose: vi.fn(),
dialogOnOpen: vi.fn(),
dialogOnOpenChange: vi.fn(),
mutation: vi.fn(),
navigate: vi.fn(),
}));

vi.mock("react-router-dom", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-router-dom")>();
return { ...actual, useNavigate: () => mocks.navigate };
});

vi.mock("jotai", async (importOriginal) => {
const actual = await importOriginal<typeof import("jotai")>();
return {
...actual,
useSetAtom: () => mocks.closePanesForThreads,
};
});

vi.mock("@/components/dialogs/ThreadDeleteDialog", () => ({
ThreadDeleteDialog: () => null,
}));

vi.mock("@/components/dialogs/ThreadRenameDialog", () => ({
ThreadRenameDialog: () => null,
}));

vi.mock("@/components/ui/app-toast", () => ({
appToast: {
dismiss: vi.fn(),
error: vi.fn(),
loading: vi.fn(),
message: vi.fn(),
success: vi.fn(),
warning: vi.fn(),
},
}));

vi.mock("@/hooks/mutations/thread-state-mutations", async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@/hooks/mutations/thread-state-mutations")
>();
return {
...actual,
useDeleteThread: () => ({ isPending: false, mutate: mocks.mutation }),
useMarkThreadRead: () => ({ mutate: mocks.mutation }),
useMarkThreadUnread: () => ({ mutate: mocks.mutation }),
usePinThread: () => ({ mutate: mocks.mutation }),
useUnpinThread: () => ({ mutate: mocks.mutation }),
useUpdateThread: () => ({ isPending: false, mutate: mocks.mutation }),
};
});

vi.mock("@/lib/sdk", () => ({
sdk: {
threads: {
archiveAll: vi.fn(),
childSummary: vi.fn(),
unarchive: vi.fn(),
},
},
}));

vi.mock("@/hooks/useDialogState", () => ({
useDialogState: () => ({
onClose: mocks.dialogOnClose,
onOpen: mocks.dialogOnOpen,
onOpenChange: mocks.dialogOnOpenChange,
target: null,
}),
}));

vi.mock("@/hooks/useRouteState", () => ({
useRouteState: () => ({ threadId: null }),
}));

function makeThread(overrides: Partial<Thread> = {}): Thread {
return {
archivedAt: null,
childOrigin: null,
createdAt: 1,
deletedAt: null,
environmentId: "env_test",
id: "thr_parent",
lastReadAt: null,
latestAttentionAt: 1,
originKind: null,
originPluginId: null,
parentThreadId: null,
pinnedAt: null,
projectId: "proj_test",
providerId: "codex",
sectionId: null,
sourceThreadId: null,
status: "idle",
title: "Investigate archive behavior",
titleFallback: null,
updatedAt: 1,
visibility: "visible",
...overrides,
};
}

function ArchiveButton({ thread }: { thread: Thread }) {
const { archiveThreadAndChildren } = useThreadActions();
return (
<button type="button" onClick={() => archiveThreadAndChildren(thread)}>
Archive
</button>
);
}

function renderProvider(children: ReactNode) {
return render(
<QueryClientProvider client={queryClient}>
<ThreadActionsProvider>{children}</ThreadActionsProvider>
</QueryClientProvider>,
);
}

let queryClient: QueryClient;

beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
mutations: { retry: false },
queries: { retry: false },
},
});
vi.mocked(sdk.threads.archiveAll).mockResolvedValue({
archivedThreadIds: ["thr_parent", "thr_child"],
ok: true,
});
vi.mocked(sdk.threads.unarchive).mockResolvedValue({ ok: true });
mocks.closePanesForThreads.mockReturnValue({
focusedRoute: null,
removedAny: false,
});
});

afterEach(() => {
cleanup();
vi.clearAllMocks();
});

describe("ThreadActionsProvider archive feedback", () => {
it("shows one archive toast whose Undo restores the parent and children", async () => {
renderProvider(<ArchiveButton thread={makeThread()} />);

fireEvent.click(screen.getByRole("button", { name: "Archive" }));

await vi.waitFor(() => {
expect(appToast.success).toHaveBeenCalledTimes(1);
});
expect(appToast.message).not.toHaveBeenCalled();
const toastOptions = vi.mocked(appToast.success).mock.calls[0]?.[1];
expect(toastOptions).toMatchObject({
action: { label: "Undo" },
duration: 10_000,
id: "thread-archived-thr_parent",
});

const undoAction = toastOptions?.action;
if (undoAction === undefined) {
throw new Error("Expected archive toast to provide Undo");
}
render(<button onClick={undoAction.onClick}>Run undo</button>);
fireEvent.click(screen.getByRole("button", { name: "Run undo" }));

await vi.waitFor(() => {
expect(sdk.threads.unarchive).toHaveBeenCalledTimes(2);
});
expect(sdk.threads.unarchive).toHaveBeenNthCalledWith(1, {
threadId: "thr_parent",
});
expect(sdk.threads.unarchive).toHaveBeenNthCalledWith(2, {
threadId: "thr_child",
});
});
});
21 changes: 20 additions & 1 deletion apps/app/src/components/thread/ThreadActionsProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,13 @@ interface ThreadActionContext {
childThreadCount: number;
}

/**
* Keeps immediate archive feedback actionable without pinning a toast for the
* full server-side recovery window. The archived thread's normal Unarchive
* action remains available while its environment is still retiring.
*/
const ARCHIVE_UNDO_TOAST_DURATION_MS = 10_000;

export function ThreadActionsProvider({
children,
}: ThreadActionsProviderProps) {
Expand Down Expand Up @@ -343,7 +350,18 @@ export function ThreadActionsProvider({
appToast.dismiss(toastId);
}}
/>,
{ id: toastId },
{
action: {
label: "Undo",
onClick: () => {
for (const threadId of response.archivedThreadIds) {
unarchiveMutate({ id: threadId });
}
},
},
duration: ARCHIVE_UNDO_TOAST_DURATION_MS,
id: toastId,
},
);
},
onError: (error) => {
Expand All @@ -363,6 +381,7 @@ export function ThreadActionsProvider({
closePanesForThreads,
navigate,
syncNavigationAfterClose,
unarchiveMutate,
],
);

Expand Down
Loading
Loading