("[data-sidebar-customize-launch]")
+ ?.focus();
+ }, [variant]);
+
+ const list = (
+
+
+
+ {items.map((item) => (
+ {
+ onActivate(item, event);
+ onExit?.();
+ }
+ : undefined
+ }
+ onCheckedChange={(checked) => onVisibleChange(item.id, checked)}
+ testIdPrefix={testIdPrefix}
+ />
+ ))}
+
+
+
+ );
+
+ if (variant === "compact") {
+ return (
+
+ );
+ }
+
+ return (
+ {
+ if (event.key !== "Escape") return;
+ event.preventDefault();
+ onDone();
+ }}
+ >
+
+
+ {title}
+
+
+
+ {list}
+
+ );
+}
+
+function SidebarCustomizeItem({
+ checked,
+ item,
+ onActivate,
+ onCheckedChange,
+ reorderDisabled,
+ testIdPrefix,
+}: {
+ checked: boolean;
+ item: SidebarVisibilityItem;
+ onActivate?: ((event: SidebarActivationModifiers) => void) | undefined;
+ onCheckedChange: (checked: boolean) => void;
+ reorderDisabled: boolean;
+ testIdPrefix: string;
+}) {
+ const checkboxId = useId();
+ const { dragBindings, setNodeRef, style } = useSidebarSortable({
+ id: item.id,
+ disabled: reorderDisabled,
+ });
+ const isNavigation = testIdPrefix === "sidebar-navigation";
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/apps/app/src/components/sidebar/ThreadRow.test.tsx b/apps/app/src/components/sidebar/ThreadRow.test.tsx
index 0cc7b6bb5c0..8ed0f900a40 100644
--- a/apps/app/src/components/sidebar/ThreadRow.test.tsx
+++ b/apps/app/src/components/sidebar/ThreadRow.test.tsx
@@ -9,6 +9,7 @@ import {
waitFor,
} from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import { createStore, Provider } from "jotai";
import type { ThreadListEntry } from "@bb/domain";
@@ -22,11 +23,13 @@ import {
const mocks = vi.hoisted(() => ({
renameThread: vi.fn(),
+ unarchiveThread: vi.fn(),
}));
vi.mock("@/components/thread/ThreadActionsProvider", () => ({
useThreadActions: () => ({
renameThreadAsync: mocks.renameThread,
+ unarchiveThread: mocks.unarchiveThread,
}),
}));
import { TooltipProvider } from "@bb/shared-ui/tooltip";
@@ -54,12 +57,12 @@ import { NO_COLLAPSED_CHILD_ACTIVITY } from "@bb/client-core";
import { sdk } from "@/lib/sdk";
import { makeThreadListEntry as makeThreadListEntryFixture } from "@bb/test-helpers/domain-fixtures";
-vi.mock("@/components/thread/ThreadActionsMenu", () => ({
+vi.mock("@/components/thread/ThreadActionsMenu", async (importOriginal) => ({
+ ...(await importOriginal()),
ThreadActionsContextMenu: ({ children }: { children: ReactNode }) => (
<>{children}>
),
ThreadActionsMenu: () => null,
- ThreadArchiveQuickAction: () => null,
}));
function createThread(
@@ -220,6 +223,7 @@ function renderSplitThreadRow({
afterEach(() => {
cleanup();
mocks.renameThread.mockReset();
+ mocks.unarchiveThread.mockReset();
resetSidebarTitleDoubleClickForTest();
resetPluginThreadRowStatusesForTest();
removePluginSlotRegistrations("icon-probe");
@@ -229,6 +233,71 @@ afterEach(() => {
});
describe("ThreadRow", () => {
+ it("keeps desktop restore available, hides it on mobile, and blocks row event propagation", () => {
+ const thread = createThread({ archivedAt: 1 });
+ const rowEvent = vi.fn();
+ const client = new QueryClient();
+ render(
+
+
+
+
+ ,
+ );
+ const restore = screen.getByRole("button", { name: "Unarchive thread" });
+ expect(restore.querySelector('[data-icon="ArchiveRestore"]')).toBeTruthy();
+ expect(restore.classList.contains("bg-state-hover")).toBe(false);
+ expect(restore.classList.contains("bg-state-active")).toBe(false);
+ expect(restore.closest("[data-sidebar-hover-actions-open]")).toBeNull();
+ expect(restore.closest(".max-md\\:pointer-coarse\\:hidden")).not.toBeNull();
+ expect(screen.queryByRole("button", { name: "Archive thread" })).toBeNull();
+ fireEvent.pointerDown(restore, { pointerType: "touch", button: 0 });
+ fireEvent.keyDown(restore, { key: "Enter" });
+ fireEvent.click(restore);
+ expect(mocks.unarchiveThread).toHaveBeenCalledOnce();
+ expect(mocks.unarchiveThread).toHaveBeenCalledWith(thread);
+ expect(rowEvent).not.toHaveBeenCalled();
+ });
+
+ it("disables only the restoring thread and recovers when its mutation fails", async () => {
+ const client = new QueryClient();
+ const thread = createThread({ archivedAt: 1 });
+ let rejectRestore!: (error: Error) => void;
+ const mutation = client.getMutationCache().build(client, {
+ mutationKey: ["unarchive-thread"],
+ mutationFn: (_input: { id: string }) => new Promise((_resolve, reject) => {
+ rejectRestore = reject;
+ }),
+ });
+ render(
+
+
+ ,
+ );
+ const restore = screen.getByRole("button", { name: "Unarchive thread" });
+ let completion: Promise;
+ act(() => {
+ completion = mutation.execute({ id: "another-thread" }).catch(() => undefined);
+ });
+ await waitFor(() => expect(rejectRestore).toBeTypeOf("function"));
+ expect(restore.disabled).toBe(false);
+ await act(async () => {
+ rejectRestore(new Error("Unarchive failed"));
+ await completion;
+ });
+ act(() => {
+ completion = mutation.execute({ id: thread.id }).catch(() => undefined);
+ });
+ await waitFor(() => expect(restore.disabled).toBe(true));
+ fireEvent.click(restore);
+ expect(mocks.unarchiveThread).not.toHaveBeenCalled();
+ await act(async () => {
+ rejectRestore(new Error("Unarchive failed"));
+ await completion;
+ });
+ await waitFor(() => expect(restore.disabled).toBe(false));
+ });
+
const splitWorkingCases: Array<{
label: string;
pluginStatus?: PluginComposerThreadRowStatus;
diff --git a/apps/app/src/components/sidebar/ThreadRow.tsx b/apps/app/src/components/sidebar/ThreadRow.tsx
index fcc677f5fce..f1e556a07c0 100644
--- a/apps/app/src/components/sidebar/ThreadRow.tsx
+++ b/apps/app/src/components/sidebar/ThreadRow.tsx
@@ -10,6 +10,7 @@ import {
useRef,
} from "react";
import { useSetAtom } from "jotai";
+import { useIsMutating } from "@tanstack/react-query";
import type { ThreadListEntry } from "@bb/domain";
import type { PluginComposerThreadRowStatus } from "@get-bb/plugin-sdk";
import { getThreadConversationCollapsedAtom } from "@/components/secondary-panel/threadSecondaryPanelAtoms";
@@ -266,6 +267,34 @@ function ThreadTrailingIndicator({
);
}
+function ThreadRestoreStatusAction({ thread }: { thread: ThreadListEntry }) {
+ const pending = useIsMutating({
+ mutationKey: ["unarchive-thread"],
+ predicate: (mutation) => {
+ const variables = mutation.state.variables;
+ return (
+ typeof variables === "object" &&
+ variables !== null &&
+ "id" in variables &&
+ variables.id === thread.id
+ );
+ },
+ });
+ return (
+ event.stopPropagation()}
+ onKeyDown={(event) => event.stopPropagation()}
+ >
+ 0}
+ className={SIDEBAR_CONTROL_BUTTON_CLASS}
+ />
+
+ );
+}
+
function ThreadRowComponent({
projectId,
thread,
@@ -551,7 +580,29 @@ function ThreadRowComponent({
isEditing && "hidden",
)}
>
- {shortcut ? (
+ {thread.archivedAt !== null ? (
+
+
+
+
+
+
+ ) : shortcut ? (
) : (
void) {
export function ThreadArchiveQuickAction({
thread,
className,
+ disabled,
}: {
thread: Thread;
className?: string;
+ disabled?: boolean;
}) {
const { archiveThreadAndChildren, unarchiveThread } = useThreadActions();
const isArchived = thread.archivedAt != null;
@@ -363,6 +365,7 @@ export function ThreadArchiveQuickAction({
size="icon"
className={cn("rounded-md p-0", className)}
aria-label={`${label} thread`}
+ disabled={disabled}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
diff --git a/apps/app/src/components/thread/ThreadLifecycleFilter.test.tsx b/apps/app/src/components/thread/ThreadLifecycleFilter.test.tsx
new file mode 100644
index 00000000000..b6afbc1bf47
--- /dev/null
+++ b/apps/app/src/components/thread/ThreadLifecycleFilter.test.tsx
@@ -0,0 +1,75 @@
+// @vitest-environment jsdom
+
+import { useState } from "react";
+import {
+ cleanup,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import type { ThreadArchiveFilter } from "@/lib/thread-lifecycle-filter";
+import { ThreadLifecycleFilter } from "./ThreadLifecycleFilter";
+
+const viewport = vi.hoisted(() => ({ compact: false }));
+vi.mock("@bb/shared-ui/hooks/use-compact-viewport", () => ({
+ useIsCompactViewport: () => viewport.compact,
+}));
+
+afterEach(() => {
+ cleanup();
+ viewport.compact = false;
+});
+
+function Filter() {
+ const [value, onChange] = useState(["active"]);
+ return ;
+}
+
+describe("ThreadLifecycleFilter", () => {
+ it.each([false, true])(
+ "keeps a nonempty selection through the responsive menu (compact=%s)",
+ async (compact) => {
+ viewport.compact = compact;
+ const { container } = render();
+ const trigger = screen.getByRole("button", {
+ name: "Filter: Active",
+ });
+ if (compact) {
+ fireEvent.click(trigger);
+ } else {
+ fireEvent.keyDown(trigger, { key: "Enter" });
+ }
+ expect(screen.queryByRole("menuitemcheckbox", { name: "Drafts" })).toBeNull();
+ const active = await screen.findByRole("menuitemcheckbox", {
+ name: "Active",
+ });
+ expect(screen.getByRole("group", { name: "Filter" })).toBeTruthy();
+ if (!compact) {
+ expect(active.getAttribute("title")).toBe(
+ "Keep at least one filter selected",
+ );
+ }
+ expect(active.getAttribute("aria-disabled")).not.toBe("true");
+ expect(active.hasAttribute("data-disabled")).toBe(false);
+ fireEvent.click(active);
+ expect(active.getAttribute("aria-checked")).toBe("true");
+ fireEvent.keyDown(active, { key: "Enter" });
+ expect(active.getAttribute("aria-checked")).toBe("true");
+ fireEvent.click(screen.getByRole("menuitemcheckbox", { name: "Archived" }));
+ await waitFor(() =>
+ expect(active.getAttribute("aria-disabled")).not.toBe("true"),
+ );
+ fireEvent.click(active);
+ const archived = screen.getByRole("menuitemcheckbox", { name: "Archived" });
+ expect(archived.getAttribute("aria-checked")).toBe("true");
+ expect(archived.getAttribute("aria-disabled")).not.toBe("true");
+ expect(archived.hasAttribute("data-disabled")).toBe(false);
+ fireEvent.click(archived);
+ expect(archived.getAttribute("aria-checked")).toBe("true");
+ expect(container.closest("[inert]")).toBeNull();
+ expect(container.closest('[aria-hidden="true"]')).toBeNull();
+ },
+ );
+});
diff --git a/apps/app/src/components/thread/ThreadLifecycleFilter.tsx b/apps/app/src/components/thread/ThreadLifecycleFilter.tsx
new file mode 100644
index 00000000000..7d27eb6e052
--- /dev/null
+++ b/apps/app/src/components/thread/ThreadLifecycleFilter.tsx
@@ -0,0 +1,111 @@
+import { Button } from "@bb/shared-ui/button";
+import { Icon } from "@bb/shared-ui/icon";
+import {
+ normalizeThreadLifecycleFilter,
+ type ThreadArchiveFilter,
+} from "@/lib/thread-lifecycle-filter";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuTrigger,
+} from "@bb/shared-ui/dropdown-menu";
+
+export const THREAD_LIFECYCLE_OPTIONS = [
+ { value: "active", label: "Active" },
+ { value: "archived", label: "Archived" },
+] as const satisfies readonly { value: ThreadArchiveFilter; label: string }[];
+
+interface ThreadLifecycleFilterProps {
+ value: readonly ThreadArchiveFilter[];
+ onChange: (value: ThreadArchiveFilter[]) => void;
+}
+
+export function ThreadLifecycleFilterItems({
+ value: savedValue,
+ onChange,
+}: ThreadLifecycleFilterProps) {
+ const value = normalizeThreadLifecycleFilter(savedValue);
+ return (
+ <>
+ {THREAD_LIFECYCLE_OPTIONS.map((option) => {
+ const checked = value.includes(option.value);
+ const required = checked && value.length === 1;
+ return (
+ {
+ event.preventDefault();
+ if (required) return;
+ onChange(
+ THREAD_LIFECYCLE_OPTIONS.flatMap((candidate) =>
+ (
+ candidate.value === option.value
+ ? !checked
+ : value.includes(candidate.value)
+ )
+ ? [candidate.value]
+ : [],
+ ),
+ );
+ }}
+ >
+ {option.label}
+
+ {checked && }
+
+
+ );
+ })}
+ >
+ );
+}
+
+export function ThreadLifecycleFilter({
+ value: savedValue,
+ onChange,
+}: ThreadLifecycleFilterProps) {
+ const value = normalizeThreadLifecycleFilter(savedValue);
+ const label =
+ value.length === THREAD_LIFECYCLE_OPTIONS.length
+ ? "All"
+ : THREAD_LIFECYCLE_OPTIONS.filter((option) =>
+ value.includes(option.value),
+ )
+ .map((option) => option.label)
+ .join(", ");
+
+ return (
+
+
+
+
+
+
+ Filter
+
+
+
+
+ );
+}
diff --git a/apps/app/src/components/ui/theme.css b/apps/app/src/components/ui/theme.css
index 2426b2a9436..7d7f2879321 100644
--- a/apps/app/src/components/ui/theme.css
+++ b/apps/app/src/components/ui/theme.css
@@ -189,7 +189,7 @@
content: "";
position: sticky;
top: 0;
- z-index: 70;
+ z-index: 55;
display: block;
height: var(--bb-sidebar-sticky-stack-padding-top);
margin-top: calc(-1 * var(--bb-sidebar-sticky-stack-padding-top));
diff --git a/apps/app/src/components/ui/theme.test.ts b/apps/app/src/components/ui/theme.test.ts
index ef4a245c7b0..74a62127fbf 100644
--- a/apps/app/src/components/ui/theme.test.ts
+++ b/apps/app/src/components/ui/theme.test.ts
@@ -142,6 +142,25 @@ describe("theme.css neutral ramp", () => {
);
});
+ it("keeps the scrollport cap below label controls but above project rows", () => {
+ const cap = Number(
+ css.match(
+ /\[data-sidebar-sticky-stack\]::before\s*\{[^}]*z-index:\s*(\d+)/,
+ )?.[1],
+ );
+ const tier = (name: string) =>
+ Number(
+ css.match(
+ new RegExp(
+ `\\[data-sidebar-sticky-tier="${name}"\\]\\s*\\{[^}]*--bb-sidebar-sticky-tier-z-index:\\s*(\\d+)`,
+ ),
+ )?.[1],
+ );
+
+ expect(cap).toBeLessThan(tier("label"));
+ expect(cap).toBeGreaterThan(tier("project"));
+ });
+
it("collapses the label slot when a section header is not sticky", () => {
const compact = css.replace(/\s+/g, " ");
const declarations = (selector: string): string | undefined =>
diff --git a/apps/app/src/hooks/cache-owners/query-cache.ts b/apps/app/src/hooks/cache-owners/query-cache.ts
index bd0566d3a88..b91008c58a9 100644
--- a/apps/app/src/hooks/cache-owners/query-cache.ts
+++ b/apps/app/src/hooks/cache-owners/query-cache.ts
@@ -80,8 +80,10 @@ type SidebarNavigationProject = SidebarBootstrapResponse["projects"][number];
export type CachedThreadListsAndSidebarNavigationMapper = (
threads: ThreadListEntry[],
) => ThreadListEntry[];
-type SidebarNavigationThreadMapper =
- CachedThreadListsAndSidebarNavigationMapper;
+type SidebarNavigationThreadMapper = (
+ threads: ThreadListEntry[],
+ projectId: string,
+) => ThreadListEntry[];
interface ApplyToCachedSidebarNavigationThreadsArgs {
mapper: SidebarNavigationThreadMapper;
@@ -303,7 +305,7 @@ function mapSidebarNavigationProjectThreads(
): SidebarNavigationProject {
return {
...project,
- threads: mapper(project.threads),
+ threads: mapper(project.threads, project.id),
};
}
diff --git a/apps/app/src/hooks/cache-owners/thread-lifecycle-cache.test.ts b/apps/app/src/hooks/cache-owners/thread-lifecycle-cache.test.ts
new file mode 100644
index 00000000000..b0a9479658f
--- /dev/null
+++ b/apps/app/src/hooks/cache-owners/thread-lifecycle-cache.test.ts
@@ -0,0 +1,66 @@
+import { QueryClient } from "@tanstack/react-query";
+import { describe, expect, it } from "vitest";
+import { makeThreadListEntry } from "@bb/test-helpers/domain-fixtures";
+import {
+ makeProjectWithThreadsResponse,
+ makeSidebarBootstrapResponse,
+} from "@/test/fixtures/projects";
+import { archivedThreadsListQueryKey, sidebarNavigationQueryKey } from "../queries/query-keys";
+import {
+ beginUnarchiveThreadTransaction,
+ rollbackThreadListMutationTransaction,
+} from "./thread-state-cache-owner";
+
+describe("sidebar archive cache", () => {
+ it.each(["project-1", "proj_personal"])(
+ "keeps a restored row in its sidebar hierarchy before the server responds (%s)",
+ async (projectId) => {
+ const queryClient = new QueryClient();
+ const archivedKey = archivedThreadsListQueryKey({});
+ const archived = makeThreadListEntry({
+ id: "archived",
+ projectId,
+ archivedAt: 100,
+ parentThreadId: "parent",
+ sectionId: "section-1",
+ pinnedAt: 10,
+ pinSortKey: "a0",
+ environmentId: "environment-1",
+ environmentHostId: "host-1",
+ latestAttentionAt: 20,
+ createdAt: 5,
+ });
+ const neighbor = makeThreadListEntry({ id: "parent", projectId });
+ const navigation = makeSidebarBootstrapResponse({
+ projects: [makeProjectWithThreadsResponse({
+ id: "project-1",
+ threads: projectId === "project-1" ? [neighbor] : [],
+ })],
+ personalProject: makeProjectWithThreadsResponse({
+ id: "proj_personal",
+ kind: "personal",
+ threads: projectId === "proj_personal" ? [neighbor] : [],
+ }),
+ });
+ const pages = { pages: [[archived]], pageParams: [0] };
+ queryClient.setQueryData(archivedKey, pages);
+ queryClient.setQueryData(sidebarNavigationQueryKey(), navigation);
+
+ const transaction = await beginUnarchiveThreadTransaction({
+ queryClient,
+ threadId: archived.id,
+ });
+
+ const next = queryClient.getQueryData(sidebarNavigationQueryKey())!;
+ const destination = projectId === "proj_personal" ? next.personalProject : next.projects[0];
+ const other = projectId === "proj_personal" ? next.projects[0] : next.personalProject;
+ expect(destination?.threads).toEqual([neighbor, { ...archived, archivedAt: null }]);
+ expect(other?.threads).toEqual([]);
+ expect(queryClient.getQueryData(archivedKey)).toMatchObject({ pages: [[]] });
+
+ rollbackThreadListMutationTransaction({ queryClient, threadId: archived.id, transaction });
+ expect(queryClient.getQueryData(sidebarNavigationQueryKey())).toEqual(navigation);
+ expect(queryClient.getQueryData(archivedKey)).toEqual(pages);
+ },
+ );
+});
diff --git a/apps/app/src/hooks/cache-owners/thread-state-cache-owner.ts b/apps/app/src/hooks/cache-owners/thread-state-cache-owner.ts
index fcbe95b79ca..7565c9209dd 100644
--- a/apps/app/src/hooks/cache-owners/thread-state-cache-owner.ts
+++ b/apps/app/src/hooks/cache-owners/thread-state-cache-owner.ts
@@ -600,7 +600,22 @@ export function beginUnarchiveThreadTransaction({
threadId,
}: ThreadIdCacheArgs): Promise {
return runOptimisticThreadFieldTransaction({
- applyToLists: removeThreadFromLists,
+ applyToLists: (queryClient, threadId) => {
+ const thread = getCachedThreadLists(queryClient, {
+ queryKey: threadsQueryKey(),
+ })
+ .flatMap(({ data }) => [...iterateThreadListCacheEntries(data)])
+ .find((candidate) => candidate.id === threadId);
+ removeThreadFromLists(queryClient, threadId);
+ if (!thread) return;
+ applyToCachedSidebarNavigationThreads({
+ queryClient,
+ mapper: (list, projectId) =>
+ projectId === thread.projectId
+ ? [...list, { ...thread, archivedAt: null }]
+ : list,
+ });
+ },
patch: { archivedAt: null },
queryClient,
threadId,
diff --git a/apps/app/src/hooks/queries/thread-queries.test.tsx b/apps/app/src/hooks/queries/thread-queries.test.tsx
index a7be0d344e8..1f02b5bf4b7 100644
--- a/apps/app/src/hooks/queries/thread-queries.test.tsx
+++ b/apps/app/src/hooks/queries/thread-queries.test.tsx
@@ -342,6 +342,39 @@ describe("useThreadDetailBootstrap", () => {
});
describe("useArchivedThreads", () => {
+ it("fetches pages only while selected and continues from the loaded offset", async () => {
+ const { queryClient, wrapper } = createQueryClientTestHarness();
+ vi.mocked(sdk.threads.list)
+ .mockResolvedValueOnce(
+ Array.from({ length: ARCHIVED_THREADS_PAGE_SIZE }, (_, index) =>
+ makeThreadListEntry({
+ id: `archived-${index}`,
+ archivedAt: 1,
+ }),
+ ),
+ )
+ .mockResolvedValueOnce([]);
+ const { result, rerender } = renderHook(
+ ({ enabled }) => useArchivedThreads({}, { enabled }),
+ { wrapper, initialProps: { enabled: false } },
+ );
+ expect(sdk.threads.list).not.toHaveBeenCalled();
+ rerender({ enabled: true });
+ await waitFor(() => expect(result.current.hasNextPage).toBe(true));
+ await act(async () => {
+ await result.current.fetchNextPage();
+ });
+ expect(vi.mocked(sdk.threads.list).mock.calls[1]?.[0]?.offset).toBe(
+ ARCHIVED_THREADS_PAGE_SIZE,
+ );
+ await waitFor(() => expect(result.current.hasNextPage).toBe(false));
+ rerender({ enabled: false });
+ await act(async () => {
+ await queryClient.invalidateQueries();
+ });
+ expect(sdk.threads.list).toHaveBeenCalledTimes(2);
+ });
+
it("loads archived threads across all projects when no scope is selected", async () => {
const { wrapper } = createQueryClientTestHarness();
diff --git a/apps/app/src/hooks/realtime-cache-effects.test.ts b/apps/app/src/hooks/realtime-cache-effects.test.ts
index c0882681210..ac732abca71 100644
--- a/apps/app/src/hooks/realtime-cache-effects.test.ts
+++ b/apps/app/src/hooks/realtime-cache-effects.test.ts
@@ -2306,6 +2306,7 @@ describe("createRealtimeCacheEffects", () => {
const sidebarNavigationKey = sidebarNavigationQueryKey();
const idleRow = {
activity: NO_THREAD_ACTIVITY,
+ archivedAt: null,
id: "thr_1",
latestAttentionAt: 100,
runtime: { displayStatus: "idle", hostReconnectGraceExpiresAt: null },
@@ -2371,11 +2372,11 @@ describe("createRealtimeCacheEffects", () => {
const sidebarThreads = queryClient.getQueryData<{
projects: { threads: (typeof idleRow)[] }[];
}>(sidebarNavigationKey)?.projects[0]?.threads;
- expect(sidebarThreads?.[0]).toEqual({ id: "thr_1", ...statusChange });
+ expect(sidebarThreads?.[0]).toEqual({ ...idleRow, ...statusChange });
expect(sidebarThreads?.[1]).toBe(otherRow);
expect(
queryClient.getQueryData<(typeof idleRow)[]>(threadListKey)?.[0],
- ).toEqual({ id: "thr_1", ...statusChange });
+ ).toEqual({ ...idleRow, ...statusChange });
for (const unsubscribe of unsubscribers) {
unsubscribe();
diff --git a/apps/app/src/lib/thread-lifecycle-filter.test.ts b/apps/app/src/lib/thread-lifecycle-filter.test.ts
new file mode 100644
index 00000000000..6ca1e5eb3d7
--- /dev/null
+++ b/apps/app/src/lib/thread-lifecycle-filter.test.ts
@@ -0,0 +1,50 @@
+// @vitest-environment jsdom
+
+import { createStore } from "jotai";
+import { afterEach, describe, expect, it } from "vitest";
+import { createThreadArchiveFilterAtom } from "./thread-lifecycle-filter";
+
+const sidebarKey = "test.sidebar.archiveFilter";
+const paletteKey = "test.palette.archiveFilter";
+
+afterEach(() => {
+ window.localStorage.removeItem(sidebarKey);
+ window.localStorage.removeItem(paletteKey);
+});
+
+describe("browser-local thread filters", () => {
+ it("restores independent selections without server preferences", () => {
+ const store = createStore();
+ const sidebar = createThreadArchiveFilterAtom(sidebarKey);
+ const palette = createThreadArchiveFilterAtom(paletteKey);
+ expect(store.get(sidebar)).toEqual(["active"]);
+ store.set(sidebar, ["active", "archived"]);
+ store.set(palette, ["archived"]);
+
+ const reloaded = createStore();
+ expect(reloaded.get(createThreadArchiveFilterAtom(sidebarKey))).toEqual([
+ "active",
+ "archived",
+ ]);
+ expect(reloaded.get(createThreadArchiveFilterAtom(paletteKey))).toEqual([
+ "archived",
+ ]);
+ });
+
+ it("falls back to Active for malformed, empty, or unsupported stored values", () => {
+ for (const value of [
+ "invalid JSON",
+ "null",
+ '"active"',
+ "[]",
+ '["draft"]',
+ '["active","active"]',
+ '["active","archived","unknown"]',
+ ]) {
+ window.localStorage.setItem(sidebarKey, value);
+ expect(createStore().get(createThreadArchiveFilterAtom(sidebarKey))).toEqual([
+ "active",
+ ]);
+ }
+ });
+});
diff --git a/apps/app/src/lib/thread-lifecycle-filter.ts b/apps/app/src/lib/thread-lifecycle-filter.ts
new file mode 100644
index 00000000000..eac341d88da
--- /dev/null
+++ b/apps/app/src/lib/thread-lifecycle-filter.ts
@@ -0,0 +1,34 @@
+import { atomWithStorage } from "jotai/utils";
+import { createJsonLocalStorage } from "@/lib/browser-storage";
+
+export type ThreadArchiveFilter = "active" | "archived";
+
+function isThreadArchiveFilter(value: unknown): value is ThreadArchiveFilter[] {
+ return (
+ Array.isArray(value) &&
+ value.length >= 1 &&
+ value.length <= 2 &&
+ new Set(value).size === value.length &&
+ value.every((item) => item === "active" || item === "archived")
+ );
+}
+
+export function createThreadArchiveFilterAtom(storageKey: string) {
+ return atomWithStorage(
+ storageKey,
+ ["active"],
+ createJsonLocalStorage(isThreadArchiveFilter),
+ { getOnInit: true },
+ );
+}
+
+export function normalizeThreadLifecycleFilter(
+ value: readonly ThreadArchiveFilter[],
+): ThreadArchiveFilter[] {
+ return [
+ ...(value.includes("active") || value.length === 0
+ ? ["active" as const]
+ : []),
+ ...(value.includes("archived") ? ["archived" as const] : []),
+ ];
+}
diff --git a/docs/configuration.md b/docs/configuration.md
index a859297c10f..b95b6a0a07f 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -728,14 +728,28 @@ to By project (`project`). Explicit server choices take precedence over legacy
browser choices, which take precedence over this installation fallback. Reset
saves the installation fallback as an explicit choice.
+The built-in sidebar defaults to Active, including threads with saved messages.
+Filter selects Active and Archived and remembers the selection in this browser,
+not in the server-backed preferences or SDK/CLI. There is no separate
+Drafts section or filter; saved messages remain in their owning thread. The
+selected archived threads retain their section, project, machine, and pin placement.
+Choose Filter in a sidebar header's combined actions menu to change the selection.
+The combined menu offers Organize, Sort by, and Filter.
+Organize retains its Sections choices and Groups → By environment toggle.
+Desktop archived rows have a persistent Unarchive icon
+that restores the thread without navigating away.
+Archived loads pages only while selected.
+Plugin sidebar replacements own their rendering.
+
`sidebar.threadGrouping.environment` decides whether two or more sibling threads
that share one worktree environment collapse into a single worktree row inside
their section. `true` groups them and `false` keeps every thread on its own row,
in every organization mode. The default, `auto`, groups them in **By project**
and **By machine** and leaves them flat in **Custom**, which is how each mode
-behaved before the preference existed. The thread-list header's Organize menu
-exposes it under Groups as the By environment toggle, which writes `true` or
-`false` and so applies to every mode once you use it.
+behaved before the preference existed. Set this preference through Organize →
+Groups → By environment, settings, or
+`bb settings ui set sidebar.threadGrouping.environment true`; an explicit
+`true` or `false` applies to every mode.
Each `sidebar.threadGrouping.*` key toggles one grouping dimension
independently, so a future dimension adds a key rather than changing this one.
diff --git a/packages/templates/src/templates/bb-guide-customization.md b/packages/templates/src/templates/bb-guide-customization.md
index 1ecfd298208..ea95d8dc0a2 100644
--- a/packages/templates/src/templates/bb-guide-customization.md
+++ b/packages/templates/src/templates/bb-guide-customization.md
@@ -305,11 +305,19 @@ to By project (`project`). Explicit server choices take precedence over legacy
browser choices, which take precedence over this installation fallback. Reset
saves the installation fallback as an explicit choice.
+The built-in sidebar's Filter selects Active and Archived, defaulting to Active.
+The selection is browser-local, not a server-backed preference or SDK/CLI setting.
+Active includes threads with saved messages; there is no separate
+Drafts section or filter. Archived threads use their preserved placement and a
+restore action. Archived pages load only while selected.
+Plugin sidebar replacements own their filters.
+
Every thread-list header's actions menu offers New project, New section,
-Organize, and Sort by. Organize selects By project, By machine, or Custom, and
-its By environment toggle decides whether sibling threads sharing one worktree
-collapse into a single worktree row inside their section, in every organization
-mode. `sidebar.threadGrouping.environment` defaults to `auto`, which groups them
+Organize, Sort by, and Filter. Organize selects By project,
+By machine, or Custom and retains Groups → By environment.
+The separate `sidebar.threadGrouping.environment` preference
+decides whether sibling threads sharing one worktree collapse into a single row.
+It defaults to `auto`, which groups them
everywhere except Custom: `bb settings ui set sidebar.threadGrouping.environment
false` keeps every thread on its own row, and `true` groups them in every mode.
Sort by selects a field, and selecting it again reverses its arrow/direction.
diff --git a/plugins/bb-guide/skills/bb-cli/references/app-settings.md b/plugins/bb-guide/skills/bb-cli/references/app-settings.md
index 030ddf17ca2..0be967a793e 100644
--- a/plugins/bb-guide/skills/bb-cli/references/app-settings.md
+++ b/plugins/bb-guide/skills/bb-cli/references/app-settings.md
@@ -19,6 +19,11 @@ every window and client sees the same value.
orders, the collapsed-id lists, `sidebar.hiddenGroups`,
`sidebar.pluginPanelOrder`, `sidebar.visiblePluginPanels`, `sidebar.navigationProvider`,
`sidebar.threadListProvider`).
+- The built-in sidebar's Filter selects Active and Archived, defaulting to Active,
+ including threads with saved messages. This selection is browser-local, not
+ a server-backed preference or SDK/CLI setting. Selected archived rows
+ retain their hierarchy placement and offer a restore action. Archived pages load only while selected;
+ plugin sidebar replacements keep ownership of their rendering.
- `sidebar.organizationMode` defaults to Custom (`chronological`) on new installs.
Migrated installs with existing projects, threads, or UI preferences fall back to
By project (`project`). Saved server choices win over legacy browser choices,
@@ -27,8 +32,8 @@ every window and client sees the same value.
one worktree environment collapse into a single worktree row inside their
section: `true` groups them and `false` keeps every thread on its own row, in
every organization mode. The default `auto` groups them in By project and By
- machine and leaves them flat in Custom. The thread-list header's Organize menu
- exposes it under Groups as By environment. Each `sidebar.threadGrouping.*` key
+ machine and leaves them flat in Custom. Set it through Organize → Groups →
+ By environment, settings, or the CLI. Each `sidebar.threadGrouping.*` key
toggles one grouping dimension independently.
- `bb settings ui list [--json]` prints every key with its value, revision,
and description; `bb settings ui get [--json]` prints one.