diff --git a/apps/app/src/components/plugin/PluginNavSidebarItems.test.tsx b/apps/app/src/components/plugin/PluginNavSidebarItems.test.tsx index 34d4eb90eee..9a54cb38f48 100644 --- a/apps/app/src/components/plugin/PluginNavSidebarItems.test.tsx +++ b/apps/app/src/components/plugin/PluginNavSidebarItems.test.tsx @@ -885,7 +885,7 @@ describe("PluginNavSidebarItems", () => { expect(onCompactCustomizeModeChange).toHaveBeenCalledWith(true); expect( - screen.getByTestId("sidebar-navigation-customize-inline"), + await screen.findByTestId("sidebar-navigation-customize-inline"), ).not.toBeNull(); expect( screen diff --git a/apps/app/src/components/sidebar/ProjectList.tsx b/apps/app/src/components/sidebar/ProjectList.tsx index 4db0a5a4a1e..c1dc67aa822 100644 --- a/apps/app/src/components/sidebar/ProjectList.tsx +++ b/apps/app/src/components/sidebar/ProjectList.tsx @@ -33,6 +33,10 @@ import { import { isTransientReadError } from "@/hooks/queries/query-helpers"; import { stripProjectThreads } from "@/hooks/queries/project-queries"; import { useSidebarNavigation } from "@/hooks/queries/sidebar-navigation-query"; +import { + SidebarThreadLifecycles, + useSidebarThreadLifecycles, +} from "./SidebarThreadLifecycles"; import { useSystemConfig } from "@/hooks/queries/system-queries"; import { useReorderPinnedThread } from "@/hooks/mutations/thread-state-mutations"; import { @@ -1437,7 +1441,7 @@ function ProjectListComponent({ () => sidebarNavigation?.projects.map(stripProjectThreads), [sidebarNavigation], ); - const threads = useMemo(() => { + const unarchivedThreads = useMemo(() => { if (!sidebarNavigation) { return []; } @@ -1448,6 +1452,8 @@ function ProjectListComponent({ sidebarThreads.push(...sidebarNavigation.personalProject.threads); return sidebarThreads; }, [sidebarNavigation]); + const lifecycles = useSidebarThreadLifecycles(unarchivedThreads); + const { threads } = lifecycles; const draftThreadIds = usePromptDraftInputThreadIds(threads); const titleMentionResources = useThreadTitleMentionResources(); const uiPreferencesReady = useUiPreferencesReady(); @@ -1460,6 +1466,12 @@ function ProjectListComponent({ ), }); const { threadId: selectedThreadId } = useRouteState(); + const hierarchyStatus = + projectsState.status === "ready" && + lifecycles.value.includes("archived") && + !lifecycles.value.includes("active") + ? lifecycles.archivedStatus + : projectsState.status; const { isPending: isPinnedReorderPending, mutate: reorderPinnedThreadMutate, @@ -1786,76 +1798,28 @@ function ProjectListComponent({ }} > - ( - - )} - renderChronological={() => ( - <> - - - )} - renderProject={() => ( - <> - + ( + - - )} - /> + )} + renderChronological={() => ( + <> + + + )} + renderProject={() => ( + <> + + + )} + /> + {sectionCreateDialog} {sectionDeleteDialogContent} diff --git a/apps/app/src/components/sidebar/ProjectRow.interactions.test.tsx b/apps/app/src/components/sidebar/ProjectRow.interactions.test.tsx index c86c4ffea05..34dfd058168 100644 --- a/apps/app/src/components/sidebar/ProjectRow.interactions.test.tsx +++ b/apps/app/src/components/sidebar/ProjectRow.interactions.test.tsx @@ -390,7 +390,7 @@ describe("ProjectRow interactions", () => { it("keeps project header controls touch-accessible when their menu opens and closes", async () => { renderProjectRow(); const trigger = screen.getByRole("button", { - name: "Test project actions", + name: /^Test project actions(?:;|$)/, }); const actions = trigger.closest(".bb-sidebar-hover-actions"); expect(actions?.getAttribute("data-sidebar-hover-actions-mobile")).toBe( diff --git a/apps/app/src/components/sidebar/SidebarHeaderControls.test.tsx b/apps/app/src/components/sidebar/SidebarHeaderControls.test.tsx index f10e1d3c051..29ec32ececf 100644 --- a/apps/app/src/components/sidebar/SidebarHeaderControls.test.tsx +++ b/apps/app/src/components/sidebar/SidebarHeaderControls.test.tsx @@ -1,6 +1,7 @@ // @vitest-environment jsdom import { + act, cleanup, fireEvent, render, @@ -22,6 +23,7 @@ import { sidebarOrganizationModeAtom, sidebarEnvironmentGroupingAtom, sidebarSortDirectionAtom, + sidebarThreadLifecyclesAtom, } from "./sidebarCollapsedAtoms"; const viewport = vi.hoisted(() => ({ compact: false })); @@ -40,6 +42,7 @@ function setup( organization: SidebarOrganizationMode = "project", ) { const store = createStore(); + store.set(sidebarThreadLifecyclesAtom, ["active"]); store.set(sidebarOrganizationModeAtom, organization); store.set(sidebarChronologicalSortAtom, "updated"); store.set(sidebarSortDirectionAtom, "default"); @@ -66,9 +69,14 @@ function setup( } async function openMenu(label = "Pinned") { - fireEvent.keyDown(screen.getByRole("button", { name: `${label} actions` }), { - key: "Enter", - }); + fireEvent.keyDown( + screen.getByRole("button", { + name: new RegExp(`^${label} actions(?:;|$)`), + }), + { + key: "Enter", + }, + ); await screen.findByRole("menuitem", { name: "New project" }); } @@ -79,6 +87,22 @@ async function openSubmenu(label: string) { } describe("sidebar header controls", () => { + it("supports keyboard selection when the menu first loads", async () => { + const { store } = setup("Pinned", false, "chronological"); + await openMenu(); + const newProject = screen.getByRole("menuitem", { name: "New project" }); + fireEvent.keyDown(newProject.closest('[role="menu"]')!, { key: "Home" }); + await waitFor(() => expect(document.activeElement).toBe(newProject)); + await openSubmenu("Organize"); + const project = await screen.findByRole("menuitemradio", { + name: "By project", + }); + fireEvent.keyDown(project.closest('[role="menu"]')!, { key: "ArrowDown" }); + await waitFor(() => expect(document.activeElement).toBe(project)); + fireEvent.keyDown(project, { key: "Enter" }); + expect(store.get(sidebarOrganizationModeAtom)).toBe("project"); + }); + it("dismisses on the first outside click after toggling environment grouping", async () => { setup(); await openMenu(); @@ -105,7 +129,7 @@ describe("sidebar header controls", () => { }); it("keeps the primary before overflow and applies the shared control state", async () => { - const { newThread } = setup(); + const { newThread } = setup("Pinned", false, "chronological"); const primary = screen.getByRole("button", { name: "New thread in Pinned", }); @@ -120,6 +144,9 @@ describe("sidebar header controls", () => { false, ); expect(control?.classList.contains("hover:text-foreground")).toBe(false); + expect(control?.classList.contains("focus-visible:ring-1")).toBe(true); + expect(control?.classList.contains("focus-visible:ring-ring")).toBe(true); + expect(control?.className).not.toMatch(/focus-visible:(bg-|ring-[02]\b)/); } expect(primary.classList.contains("max-md:pointer-coarse:w-8")).toBe(true); expect( @@ -146,6 +173,7 @@ describe("sidebar header controls", () => { "New section", "Organize", "Sort by", + "Filter", "Rename", "Remove", ]); @@ -159,6 +187,55 @@ describe("sidebar header controls", () => { ); }); + it.each([false, true])( + "changes filtering through plain combined-menu controls (compact=%s)", + async (compact) => { + viewport.compact = compact; + const { store } = setup(); + const trigger = screen.getByRole("button", { name: "Pinned actions" }); + act(() => { + store.set(sidebarOrganizationModeAtom, "machine"); + store.set(sidebarChronologicalSortAtom, "created"); + store.set(sidebarSortDirectionAtom, "ascending"); + }); + expect(trigger.querySelector('[data-icon="MoreHorizontal"]')).toBeTruthy(); + expect(trigger.classList.contains("bg-state-active")).toBe(false); + expect(trigger.hasAttribute("aria-pressed")).toBe(false); + expect(trigger.hasAttribute("aria-describedby")).toBe(false); + if (compact) fireEvent.click(trigger); + else await openMenu(); + const filter = await screen.findByRole("menuitem", { + name: "Filter", + }); + expect( + screen.getAllByRole("menuitem").map((item) => item.textContent), + ).toEqual(["New project", "New section", "Organize", "Sort by", "Filter"]); + if (compact) fireEvent.click(filter); + else await openSubmenu("Filter"); + const archived = await screen.findByRole("menuitemcheckbox", { + name: "Archived", + }); + fireEvent.click(archived); + expect(store.get(sidebarThreadLifecyclesAtom)).toEqual([ + "active", + "archived", + ]); + expect(screen.queryByRole("menuitem", { name: /^Reset/ })).toBeNull(); + expect(store.get(sidebarOrganizationModeAtom)).toBe("machine"); + expect(store.get(sidebarChronologicalSortAtom)).toBe("created"); + if (compact) { + expect( + screen.getByRole("dialog", { name: "Filter" }), + ).toBeTruthy(); + expect(trigger.closest("[inert], [aria-hidden='true']")).toBeNull(); + fireEvent.click(screen.getByRole("menuitem", { name: "Back" })); + expect( + screen.getByRole("menuitem", { name: "New project" }), + ).toBeTruthy(); + } + }, + ); + it("keeps Organize open and exclusive across selections", async () => { const { store } = setup(); await openMenu(); @@ -198,63 +275,31 @@ describe("sidebar header controls", () => { ); }); - it("resolves auto grouping from the organization mode and pins an explicit choice", async () => { - const { store } = setup(); - await openMenu(); - await openSubmenu("Organize"); - const toggle = await screen.findByRole("menuitemcheckbox", { - name: "By environment", - }); - expect(toggle.getAttribute("aria-checked")).toBe("true"); - - fireEvent.click(toggle); - expect(store.get(sidebarEnvironmentGroupingAtom)).toBe(false); - await waitFor(() => - expect( - screen - .getByRole("menuitemcheckbox", { name: "By environment" }) - .getAttribute("aria-checked"), - ).toBe("false"), - ); - - store.set(sidebarOrganizationModeAtom, "chronological"); - await waitFor(() => - expect( - screen - .getByRole("menuitemcheckbox", { name: "By environment" }) - .getAttribute("aria-checked"), - ).toBe("false"), - ); - }); - - it("leaves auto grouping off in Custom and on in the other modes", async () => { + it("preserves the existing Organize groups without a Reset action", async () => { const { store } = setup("Pinned", false, "chronological"); + act(() => store.set(sidebarEnvironmentGroupingAtom, true)); await openMenu(); await openSubmenu("Organize"); - expect( - ( - await screen.findByRole("menuitemcheckbox", { name: "By environment" }) - ).getAttribute("aria-checked"), - ).toBe("false"); - - store.set(sidebarOrganizationModeAtom, "machine"); - await waitFor(() => - expect( - screen - .getByRole("menuitemcheckbox", { name: "By environment" }) - .getAttribute("aria-checked"), - ).toBe("true"), - ); - expect(store.get(sidebarEnvironmentGroupingAtom)).toBe("auto"); + const grouping = await screen.findByRole("menuitemcheckbox", { name: "By environment" }); + expect(screen.getByRole("group", { name: "Groups" })).toBeTruthy(); + expect(grouping.getAttribute("aria-checked")).toBe("true"); + fireEvent.click(grouping); + expect(store.get(sidebarEnvironmentGroupingAtom)).toBe(false); + fireEvent.click(await screen.findByRole("menuitemradio", { name: "By project" })); + expect(store.get(sidebarOrganizationModeAtom)).toBe("project"); + expect(store.get(sidebarEnvironmentGroupingAtom)).toBe(false); + expect(screen.queryByRole("menuitem", { name: /^Reset/ })).toBeNull(); }); - it("toggles sort direction without closing and resets direction for a different field", async () => { + it("resolves legacy sort, toggles direction, and resets it for another field", async () => { const { store } = setup(); + act(() => store.set(sidebarChronologicalSortAtom, "none")); await openMenu(); await openSubmenu("Sort by"); const updated = await screen.findByRole("menuitemradio", { name: "Updated at, descending. Sort ascending", }); + expect(updated.getAttribute("aria-checked")).toBe("true"); fireEvent.click(updated); expect(store.get(sidebarSortDirectionAtom)).toBe("ascending"); fireEvent.click( @@ -268,6 +313,7 @@ describe("sidebar header controls", () => { ); expect(store.get(sidebarChronologicalSortAtom)).toBe("alpha"); expect(store.get(sidebarSortDirectionAtom)).toBe("ascending"); + expect(screen.queryByRole("menuitem", { name: /^Reset/ })).toBeNull(); expect( screen .getByRole("menuitemradio", { @@ -280,7 +326,9 @@ describe("sidebar header controls", () => { it("announces compact sort direction and resets the nested page after closing", async () => { viewport.compact = true; const { store } = setup(); - fireEvent.click(screen.getByRole("button", { name: "Pinned actions" })); + fireEvent.click( + screen.getByRole("button", { name: /^Pinned actions(?:;|$)/ }), + ); fireEvent.click(await screen.findByRole("menuitem", { name: "Sort by" })); fireEvent.click( await screen.findByRole("menuitemradio", { @@ -304,11 +352,15 @@ describe("sidebar header controls", () => { .getByRole("menuitemradio", { name: "Custom" }) .getAttribute("aria-checked"), ).toBe("true"); - fireEvent.click(screen.getByRole("button", { name: "Pinned actions" })); + fireEvent.click( + screen.getByRole("button", { name: /^Pinned actions(?:;|$)/ }), + ); await waitFor(() => expect(screen.queryByRole("menuitem", { name: "Back" })).toBeNull(), ); - fireEvent.click(screen.getByRole("button", { name: "Pinned actions" })); + fireEvent.click( + screen.getByRole("button", { name: /^Pinned actions(?:;|$)/ }), + ); expect( await screen.findByRole("menuitem", { name: "New project" }), ).toBeTruthy(); diff --git a/apps/app/src/components/sidebar/SidebarHeaderControls.tsx b/apps/app/src/components/sidebar/SidebarHeaderControls.tsx index 2397c2e7d7d..d737b43bfcf 100644 --- a/apps/app/src/components/sidebar/SidebarHeaderControls.tsx +++ b/apps/app/src/components/sidebar/SidebarHeaderControls.tsx @@ -1,5 +1,11 @@ -import { createContext, useContext, useState, type ReactNode } from "react"; -import { useAtom, useAtomValue, useSetAtom } from "jotai"; +import { + createContext, + lazy, + Suspense, + useContext, + useState, + type ReactNode, +} from "react"; import { Button } from "@bb/shared-ui/button"; import { Icon } from "@bb/shared-ui/icon"; import { COARSE_POINTER_ICON_SIZE_CLASS } from "@bb/shared-ui/coarse-pointer-sizing"; @@ -7,28 +13,15 @@ import { useIsCompactViewport } from "@bb/shared-ui/hooks/use-compact-viewport"; import { DropdownMenu, DropdownMenuContent, - DropdownMenuGroup, DropdownMenuItem, - DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, - DropdownMenuSub, - DropdownMenuSubTrigger, - DropdownMenuSubContent, - DropdownMenuPortal, } from "@bb/shared-ui/dropdown-menu"; -import { - sidebarOrganizationModeAtom, - sidebarChronologicalSortAtom, - sidebarGroupThreadsByEnvironmentAtom, - sidebarEnvironmentGroupingAtom, - sidebarSortDirectionAtom, -} from "./sidebarCollapsedAtoms"; import { SidebarControlButton, SidebarRowControls } from "./SidebarRowControls"; import { SIDEBAR_CONTROL_BUTTON_CLASS } from "./sidebarRowClasses"; import { ThreadListVisibilityMenuItems } from "./ThreadListVisibility"; -interface HeaderCreationActions { +export interface HeaderCreationActions { onNewProject?: () => void; onNewSection?: () => void; isCreatingProject?: boolean; @@ -38,116 +31,11 @@ interface HeaderCreationActions { const HeaderCreationContext = createContext({}); export const SidebarHeaderActionsProvider = HeaderCreationContext.Provider; -const SIDEBAR_ORGANIZE_OPTIONS = [ - { label: "By project", mode: "project" }, - { label: "By machine", mode: "machine" }, - { label: "Custom", mode: "chronological" }, -] as const; - -const SIDEBAR_SORT_OPTIONS = [ - { label: "Updated at", sort: "updated", direction: "descending" }, - { label: "Created at", sort: "created", direction: "descending" }, - { label: "Alphabetical", sort: "alpha", direction: "ascending" }, -] as const; - -function SidebarViewItems({ page }: { page: "organize" | "sort" }) { - const [organization, setOrganization] = useAtom(sidebarOrganizationModeAtom); - const [sort, setSort] = useAtom(sidebarChronologicalSortAtom); - const [savedDirection, setDirection] = useAtom(sidebarSortDirectionAtom); - const setEnvironmentGrouping = useSetAtom(sidebarEnvironmentGroupingAtom); - const groupByEnvironment = useAtomValue(sidebarGroupThreadsByEnvironmentAtom); - const selectedSort = sort === "none" ? "updated" : sort; - if (page === "organize") { - return ( - <> - - Sections - {SIDEBAR_ORGANIZE_OPTIONS.map((option) => ( - { - event.preventDefault(); - setOrganization(option.mode); - }} - > - {option.label} - - {organization === option.mode && ( - - )} - - - ))} - - - - Groups - { - event.preventDefault(); - setEnvironmentGrouping(!groupByEnvironment); - }} - > - By environment - - {groupByEnvironment && } - - - - - ); - } - return ( - - {SIDEBAR_SORT_OPTIONS.map((option) => { - const selected = selectedSort === option.sort; - const direction = - savedDirection === "default" ? option.direction : savedDirection; - const nextDirection = selected - ? direction === "ascending" - ? "descending" - : "ascending" - : option.direction; - return ( - { - event.preventDefault(); - setSort(option.sort); - setDirection(nextDirection); - }} - > - {option.label} - {selected && ( - - , {direction}. Sort {nextDirection} - - )} - - {selected && ( - - )} - - - ); - })} - - ); -} +const LazySidebarHeaderMenuContents = lazy(() => + import("./SidebarViewItems").then(({ SidebarHeaderMenuContents }) => ({ + default: SidebarHeaderMenuContents, + })), +); export function SidebarHeaderControls({ label, @@ -168,7 +56,7 @@ export function SidebarHeaderControls({ }) { const creation = useContext(HeaderCreationContext); const compact = useIsCompactViewport(); - const [page, setPage] = useState<"organize" | "sort" | null>(null); + const [page, setPage] = useState<"organize" | "sort" | "filter" | null>(null); const changeOpen = (next: boolean) => { if (!next) setPage(null); onOpenChange?.(next); @@ -210,82 +98,23 @@ export function SidebarHeaderControls({ ? "Organize" : page === "sort" ? "Sort by" - : `${label} actions` + : page === "filter" + ? "Filter" + : `${label} actions` } > - {compact && page ? ( - <> - { - event.preventDefault(); - setPage(null); - }} - > - - Back - - - - - ) : ( - <> - - - New project - - - - New section - - - {( - [ - { page: "organize", label: "Organize", icon: "Layers" }, - { page: "sort", label: "Sort by", icon: "Sort" }, - ] as const - ).map((item) => - compact ? ( - { - event.preventDefault(); - setPage(item.page); - }} - > - - {item.label} - - - ) : ( - - - - {item.label} - - - - - - - - ), - )} - {children ? ( - <> - - {children} - - ) : ( - - )} - - )} + Loading…} + > + + {children} + + diff --git a/apps/app/src/components/sidebar/SidebarThreadLifecycles.test.tsx b/apps/app/src/components/sidebar/SidebarThreadLifecycles.test.tsx new file mode 100644 index 00000000000..5efd6110585 --- /dev/null +++ b/apps/app/src/components/sidebar/SidebarThreadLifecycles.test.tsx @@ -0,0 +1,278 @@ +// @vitest-environment jsdom + +import { + act, + cleanup, + fireEvent, + render, + renderHook, + screen, +} from "@testing-library/react"; +import { createStore, Provider } from "jotai"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MemoryRouter } from "react-router-dom"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { ThreadArchiveFilter } from "@/lib/thread-lifecycle-filter"; +import { buildSidebarEntitySectionId } from "@bb/client-core"; +import { TooltipProvider } from "@bb/shared-ui/tooltip"; +import { makeThreadListEntry } from "@bb/test-helpers/domain-fixtures"; +import { SidebarThreadLifecycles, useSidebarThreadLifecycles } from "./SidebarThreadLifecycles"; +import { SidebarHeaderControls } from "./SidebarHeaderControls"; +import { ChronologicalSectionThreadSections } from "./ProjectRow"; +import { sidebarThreadLifecyclesAtom } from "./sidebarCollapsedAtoms"; + +const archiveQuery = vi.hoisted(() => ({ + fetchNextPage: vi.fn(), + enabled: false, + empty: false, +})); + +vi.mock("@/hooks/queries/thread-queries", () => ({ + useArchivedThreads: (_filters: object, { enabled }: { enabled: boolean }) => { + archiveQuery.enabled = enabled; + return { + data: { + pages: archiveQuery.empty + ? [[]] + : [ + [ + makeThreadListEntry({ + id: "archived-thread", + title: "Archived work", + archivedAt: 1, + projectId: "archive-project", + sectionId: "archive-section", + environmentId: "archive-environment", + environmentHostId: "archive-host", + pinnedAt: 1, + pinSortKey: "a0", + }), + ], + ], + }, + isFetching: false, + isLoadingError: false, + error: null, + hasNextPage: true, + isFetchingNextPage: false, + isFetchNextPageError: false, + fetchNextPage: archiveQuery.fetchNextPage, + }; + }, +})); + +vi.mock("@/hooks/useServerConnectionState", () => ({ + useServerConnectionState: () => "connected", +})); +vi.mock("@/hooks/useThreadSplitsEnabled", () => ({ + useThreadSplitsEnabled: () => false, +})); +vi.mock("@/hooks/usePromptDraftStorage", () => ({ + usePromptDraftHasInput: () => false, + usePromptDraftInputThreadIds: () => new Set(), +})); +vi.mock("@/components/thread/ThreadActionsProvider", () => ({ + useThreadActions: () => ({ + renameThread: vi.fn(), + requestRename: vi.fn(), + requestDelete: vi.fn(), + archiveThreadAndChildren: vi.fn(), + unarchiveThread: vi.fn(), + togglePin: vi.fn(), + toggleRead: vi.fn(), + }), +})); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +function LifecycleContents({ empty }: { empty: boolean }) { + const lifecycles = useSidebarThreadLifecycles(empty ? [] : [ + makeThreadListEntry({ id: "active-thread", title: "Active work" }), + makeThreadListEntry({ + id: "old-draft", + title: "Saved work", + status: "pending", + createdAt: 1, + updatedAt: 1, + }), + ]); + return ( + 0, + collapsedThreadIds: new Set(), + collapsedEnvironmentIds: new Set(), + onToggleThreadCollapsed: vi.fn(), + onToggleEnvironmentCollapsed: vi.fn(), + }} + > + 0} + sections={lifecycles.threads.some((thread) => thread.sectionId === "archive-section") + ? [{ id: "archive-section", name: "Review" }] + : []} + collapsedThreadIds={new Set()} + collapsedEnvironmentIds={new Set()} + onToggleThreadCollapsed={vi.fn()} + onToggleEnvironmentCollapsed={vi.fn()} + topLevelSectionOrder={[ + "threads", + buildSidebarEntitySectionId("section", "archive-section"), + ]} + fullSectionOrder={[ + "threads", + buildSidebarEntitySectionId("section", "archive-section"), + ]} + onTopLevelSectionOrderChange={vi.fn()} + pinnedReorderPending={false} + pinnedThreads={[]} + onReorderPinnedThread={vi.fn()} + builtInSections={{ + collapsedSectionIds: new Set(), + onToggleCollapsed: vi.fn(), + pinned: { label: "Pinned", content: null }, + threads: { + label: "Threads", + actions: , + }, + }} + /> + + ); +} + +function setup(lifecycles: ThreadArchiveFilter[] = ["active"], empty = false) { + archiveQuery.empty = empty; + const store = createStore(); + store.set(sidebarThreadLifecyclesAtom, lifecycles); + render( + + + + + + + + + , + ); + return store; +} + +describe("sidebar lifecycle placement", () => { + it("merges selected rows once and preserves archived hierarchy metadata", () => { + archiveQuery.empty = false; + const store = createStore(); + store.set(sidebarThreadLifecyclesAtom, ["active", "archived"]); + const active = makeThreadListEntry({ id: "active" }); + const duplicate = makeThreadListEntry({ id: "archived-thread" }); + const client = new QueryClient(); + const { result, rerender } = renderHook( + ({ bootstrap }) => useSidebarThreadLifecycles(bootstrap), + { + initialProps: { bootstrap: [active, duplicate] }, + wrapper: ({ children }) => ( + + {children} + + ), + }, + ); + expect(result.current.threads).toEqual([duplicate, active]); + rerender({ bootstrap: [active] }); + expect(result.current.threads[0]).toMatchObject({ + id: "archived-thread", + projectId: "archive-project", + sectionId: "archive-section", + environmentId: "archive-environment", + environmentHostId: "archive-host", + pinnedAt: 1, + pinSortKey: "a0", + }); + }); + + it("filters the existing hierarchy and only pages archives while selected", () => { + const store = setup(); + expect(screen.getByText("Active work")).toBeTruthy(); + expect(screen.getByText("Saved work")).toBeTruthy(); + expect(screen.queryByText("Archived work")).toBeNull(); + expect(screen.queryByRole("heading", { name: "Active" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Active actions" })).toBeNull(); + expect(archiveQuery.enabled).toBe(false); + + act(() => store.set(sidebarThreadLifecyclesAtom, ["active", "archived"])); + expect(screen.getByText("Active work")).toBeTruthy(); + expect(screen.getByText("Saved work")).toBeTruthy(); + expect(screen.getByText("Archived work")).toBeTruthy(); + expect(screen.queryByRole("region", { name: "Drafts" })).toBeNull(); + expect(screen.queryByRole("heading", { name: "Drafts" })).toBeNull(); + expect(archiveQuery.enabled).toBe(true); + fireEvent.click( + screen.getByRole("button", { name: "Load more archived threads" }), + ); + expect(archiveQuery.fetchNextPage).toHaveBeenCalledOnce(); + + act(() => store.set(sidebarThreadLifecyclesAtom, ["archived"])); + expect(screen.queryByText("Active work")).toBeNull(); + expect(screen.queryByText("Saved work")).toBeNull(); + expect(screen.getByText("Archived work")).toBeTruthy(); + + act(() => store.set(sidebarThreadLifecyclesAtom, ["active"])); + expect(screen.getByText("Active work")).toBeTruthy(); + expect(screen.queryByText("Archived work")).toBeNull(); + expect(archiveQuery.enabled).toBe(false); + }); + + it( + "keeps the combined menu reachable when empty, before and after returning to Active", + async () => { + const store = setup(["archived"], true); + expect(screen.getByText("No threads")).toBeTruthy(); + expect( + screen.queryByRole("button", { name: /Filter:/ }), + ).toBeNull(); + fireEvent.keyDown( + screen.getByRole("button", { + name: "Threads actions", + }), + { + key: "Enter", + }, + ); + fireEvent.keyDown( + await screen.findByRole("menuitem", { name: "Filter" }), + { + key: "ArrowRight", + }, + ); + fireEvent.click( + await screen.findByRole("menuitemcheckbox", { name: "Active" }), + ); + expect(store.get(sidebarThreadLifecyclesAtom)).toEqual([ + "active", + "archived", + ]); + fireEvent.click(screen.getByRole("menuitemcheckbox", { name: "Archived" })); + expect(store.get(sidebarThreadLifecyclesAtom)).toEqual(["active"]); + expect(screen.getByText("No threads")).toBeTruthy(); + for (const menu of screen.queryAllByRole("menu").reverse()) { + fireEvent.keyDown(menu, { key: "Escape" }); + } + const trigger = await screen.findByRole("button", { + name: /^Threads actions(?:;|$)/, + }); + fireEvent.keyDown(trigger, { key: "Enter" }); + expect( + await screen.findByRole("menuitem", { name: "Filter" }), + ).toBeTruthy(); + }, + ); +}); diff --git a/apps/app/src/components/sidebar/SidebarThreadLifecycles.tsx b/apps/app/src/components/sidebar/SidebarThreadLifecycles.tsx new file mode 100644 index 00000000000..11629d9c4ca --- /dev/null +++ b/apps/app/src/components/sidebar/SidebarThreadLifecycles.tsx @@ -0,0 +1,96 @@ +import { useMemo, type ComponentProps, type ReactNode } from "react"; +import { useAtomValue } from "jotai"; +import type { ThreadListEntry } from "@bb/domain"; +import { Button } from "@bb/shared-ui/button"; +import { normalizeThreadLifecycleFilter } from "@/lib/thread-lifecycle-filter"; +import { useArchivedThreads } from "@/hooks/queries/thread-queries"; +import { + useConnectionAwareQueryState, +} from "@/hooks/queries/connection-aware-query-state"; +import { isTransientReadError } from "@/hooks/queries/query-helpers"; +import { ProjectThreadTree } from "./ProjectRow"; +import { sidebarThreadLifecyclesAtom } from "./sidebarCollapsedAtoms"; + +export function useSidebarThreadLifecycles( + unarchivedThreads: ThreadListEntry[], +) { + const savedValue = useAtomValue(sidebarThreadLifecyclesAtom); + const value = useMemo(() => normalizeThreadLifecycleFilter(savedValue), [savedValue]); + const archived = useArchivedThreads( + {}, + { enabled: value.includes("archived") }, + ); + const archivedState = useConnectionAwareQueryState({ + hasResolvedData: archived.data !== undefined, + isFetching: archived.isFetching, + isLoadingError: archived.isLoadingError, + isRecoverableLoadingError: isTransientReadError(archived.error), + }); + const threads = useMemo(() => { + const selected = new Map(); + if (value.includes("archived")) { + for (const thread of archived.data?.pages.flat() ?? []) { + if (thread.archivedAt !== null) selected.set(thread.id, thread); + } + } + if (value.includes("active")) { + for (const thread of unarchivedThreads) { + if (thread.archivedAt === null) selected.set(thread.id, thread); + } + } + return [...selected.values()]; + }, [archived.data, unarchivedThreads, value]); + return { + value, + threads, + archived, + archivedStatus: archivedState.status, + }; +} + +export function SidebarThreadLifecycles({ + children, + lifecycles, + treeProps, +}: { + children: ReactNode; + lifecycles: ReturnType; + treeProps: Omit< + ComponentProps, + "threadListState" | "variant" | "progressiveDisclosureEnabled" + >; +}) { + const { value, archived, archivedStatus } = lifecycles; + return ( + <> + {children} + {value.includes("archived") && ( + <> + {value.includes("active") && archivedStatus !== "ready" && ( + + )} + {archived.hasNextPage && ( + + )} + + )} + + ); +} diff --git a/apps/app/src/components/sidebar/SidebarViewItems.tsx b/apps/app/src/components/sidebar/SidebarViewItems.tsx new file mode 100644 index 00000000000..26626caa809 --- /dev/null +++ b/apps/app/src/components/sidebar/SidebarViewItems.tsx @@ -0,0 +1,250 @@ +import type { ReactNode } from "react"; +import { useAtom, useAtomValue, useSetAtom } from "jotai"; +import { Icon } from "@bb/shared-ui/icon"; +import { + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, + DropdownMenuPortal, +} from "@bb/shared-ui/dropdown-menu"; +import type { HeaderCreationActions } from "./SidebarHeaderControls"; +import { ThreadListVisibilityMenuItems } from "./ThreadListVisibility"; +import { ThreadLifecycleFilterItems } from "@/components/thread/ThreadLifecycleFilter"; +import { + sidebarOrganizationModeAtom, + sidebarChronologicalSortAtom, + sidebarSortDirectionAtom, + sidebarThreadLifecyclesAtom, + sidebarGroupThreadsByEnvironmentAtom, + sidebarEnvironmentGroupingAtom, +} from "./sidebarCollapsedAtoms"; + +const SIDEBAR_ORGANIZE_OPTIONS = [ + { label: "By project", mode: "project" }, + { label: "By machine", mode: "machine" }, + { label: "Custom", mode: "chronological" }, +] as const; + +const SIDEBAR_SORT_OPTIONS = [ + { label: "Updated at", sort: "updated", direction: "descending" }, + { label: "Created at", sort: "created", direction: "descending" }, + { label: "Alphabetical", sort: "alpha", direction: "ascending" }, +] as const; + +type SidebarViewPage = "organize" | "sort" | "filter"; + +export function SidebarHeaderMenuContents({ + creation, + compact, + page, + onPageChange, + children, +}: { + creation: HeaderCreationActions; + compact: boolean; + page: SidebarViewPage | null; + onPageChange: (page: SidebarViewPage | null) => void; + children?: ReactNode; +}) { + if (compact && page) { + return ( + <> + { + event.preventDefault(); + onPageChange(null); + }} + > + + Back + + + + + ); + } + return ( + <> + + + New project + + + + New section + + + {( + [ + { page: "organize", label: "Organize", icon: "Layers" }, + { page: "sort", label: "Sort by", icon: "ArrowUpDown" }, + { page: "filter", label: "Filter", icon: "SlidersHorizontal" }, + ] as const + ).map((item) => + compact ? ( + { + event.preventDefault(); + onPageChange(item.page); + }} + > + + {item.label} + + + ) : ( + + + + {item.label} + + + + + + + + ), + )} + {children ? ( + <> + + {children} + + ) : ( + + )} + + ); +} + +function SidebarViewItems({ + page, +}: { + page: SidebarViewPage; +}) { + const [lifecycles, setLifecycles] = useAtom(sidebarThreadLifecyclesAtom); + const [organization, setOrganization] = useAtom(sidebarOrganizationModeAtom); + const [sort, setSort] = useAtom(sidebarChronologicalSortAtom); + const [savedDirection, setDirection] = useAtom(sidebarSortDirectionAtom); + const setEnvironmentGrouping = useSetAtom(sidebarEnvironmentGroupingAtom); + const groupByEnvironment = useAtomValue(sidebarGroupThreadsByEnvironmentAtom); + const selectedSort = sort === "none" ? "updated" : sort; + if (page === "filter") { + return ( + + + + ); + } + if (page === "organize") { + return ( + <> + + Sections + {SIDEBAR_ORGANIZE_OPTIONS.map((option) => ( + { + event.preventDefault(); + setOrganization(option.mode); + }} + > + {option.label} + + {organization === option.mode && ( + + )} + + + ))} + + + + Groups + { + event.preventDefault(); + setEnvironmentGrouping(!groupByEnvironment); + }} + > + By environment + + {groupByEnvironment && } + + + + + ); + } + return ( + + {SIDEBAR_SORT_OPTIONS.map((option) => { + const selected = selectedSort === option.sort; + const direction = + savedDirection === "default" ? option.direction : savedDirection; + const nextDirection = selected + ? direction === "ascending" + ? "descending" + : "ascending" + : option.direction; + return ( + { + event.preventDefault(); + setSort(option.sort); + setDirection(nextDirection); + }} + > + {option.label} + {selected && ( + + , {direction}. Sort {nextDirection} + + )} + + {selected && ( + + )} + + + ); + })} + + ); +} diff --git a/apps/app/src/components/sidebar/SidebarVisibilityControls.test.tsx b/apps/app/src/components/sidebar/SidebarVisibilityControls.test.tsx index c0988a0b53d..94626316bd8 100644 --- a/apps/app/src/components/sidebar/SidebarVisibilityControls.test.tsx +++ b/apps/app/src/components/sidebar/SidebarVisibilityControls.test.tsx @@ -7,7 +7,7 @@ import { SidebarVisibilityCustomize } from "./SidebarVisibilityControls"; afterEach(cleanup); describe("shared sidebar visibility controls", () => { - it("lets group customization toggle visibility without navigating away", () => { + it("loads group customization and toggles visibility without navigating away", async () => { const onVisibleChange = vi.fn(); const onDone = vi.fn(); render( @@ -26,7 +26,7 @@ describe("shared sidebar visibility controls", () => { , ); - fireEvent.click(screen.getByRole("button", { name: "Review" })); + fireEvent.click(await screen.findByRole("button", { name: "Review" })); expect(onVisibleChange).toHaveBeenCalledWith("section:review", true); expect(onDone).not.toHaveBeenCalled(); fireEvent.keyDown(screen.getByRole("button", { name: "Review" }), { diff --git a/apps/app/src/components/sidebar/SidebarVisibilityControls.tsx b/apps/app/src/components/sidebar/SidebarVisibilityControls.tsx index 9cb27802dc7..364ab6a5090 100644 --- a/apps/app/src/components/sidebar/SidebarVisibilityControls.tsx +++ b/apps/app/src/components/sidebar/SidebarVisibilityControls.tsx @@ -1,20 +1,13 @@ import { useCallback, - useEffect, - useId, - useMemo, - useRef, + lazy, + Suspense, useState, type PointerEventHandler, type ReactNode, + type ComponentProps, } from "react"; -import { DndContext, type DragEndEvent } from "@dnd-kit/core"; -import { - SortableContext, - verticalListSortingStrategy, -} from "@dnd-kit/sortable"; import { Button } from "@bb/shared-ui/button"; -import { Checkbox } from "@bb/shared-ui/checkbox"; import { Icon } from "@bb/shared-ui/icon"; import { Popover, PopoverContent, PopoverTrigger } from "@bb/shared-ui/popover"; import { @@ -34,7 +27,6 @@ import { COARSE_POINTER_ICON_SIZE_CLASS, COARSE_POINTER_ROW_ACTION_SIZE_CLASS, } from "@bb/shared-ui/coarse-pointer-sizing"; -import { CHROME_SECTION_LABEL_CLASS } from "@bb/shared-ui/chrome-style-tokens"; import { cn } from "@bb/shared-ui/lib/utils"; import { SIDEBAR_HOVER_ACTIONS_CLASS, @@ -46,8 +38,6 @@ import { SIDEBAR_CONTROL_BUTTON_CLASS, } from "./sidebarRowClasses"; import { TopLevelSidebarSection } from "./TopLevelSidebarSection"; -import { useSidebarSortable } from "./sortableMotion"; -import { useSidebarReorderDnd } from "./useSidebarReorderDnd"; const OVERFLOW_ROW_BUTTON_CLASS = "w-full justify-start gap-2 rounded-sm px-2 text-xs font-normal hover:bg-state-hover focus-visible:bg-state-hover"; @@ -330,264 +320,18 @@ export function SidebarOverflowItem({ ); } -export function SidebarVisibilityCustomize({ - items, - listLabel, - onActivate, - onDone, - onExit, - onReorder, - onVisibleChange, - testIdPrefix = "sidebar-navigation", - title, - variant, - visibleIds, -}: { - items: readonly SidebarVisibilityItem[]; - listLabel: string; - onActivate?: ( - item: SidebarVisibilityItem, - event: SidebarActivationModifiers, - ) => void; - onDone: () => void; - onExit?: () => void; - onReorder: (activeId: string, overId: string) => void; - onVisibleChange: (id: string, visible: boolean) => void; - testIdPrefix?: string; - title: string; - variant: "compact" | "card"; - visibleIds: readonly string[]; -}) { - const containerRef = useRef(null); - const doneButtonRef = useRef(null); - const orderedIds = useMemo(() => items.map((item) => item.id), [items]); - const visibleIdSet = useMemo(() => new Set(visibleIds), [visibleIds]); - const handleDragEnd = useCallback( - (event: DragEndEvent) => { - if ( - typeof event.active.id !== "string" || - typeof event.over?.id !== "string" - ) - return; - onReorder(event.active.id, event.over.id); - }, - [onReorder], - ); - const { dndContextProps, onClickCapture } = useSidebarReorderDnd({ - onDragEnd: handleDragEnd, - }); - - useEffect(() => { - if (variant === "compact") { - doneButtonRef.current?.focus(); - return; - } - containerRef.current - ?.querySelector("[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 ( -
-
- -
- {title} -
-
-
{list}
-
- ); - } - - 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"; +const LazySidebarVisibilityCustomize = lazy(() => + import("./SidebarVisibilityCustomize").then(({ SidebarVisibilityCustomize }) => ({ + default: SidebarVisibilityCustomize, + })), +); +export function SidebarVisibilityCustomize( + props: ComponentProps, +) { return ( -
- - - -
+ Loading…}> + + ); } diff --git a/apps/app/src/components/sidebar/SidebarVisibilityCustomize.tsx b/apps/app/src/components/sidebar/SidebarVisibilityCustomize.tsx new file mode 100644 index 00000000000..fc914faf879 --- /dev/null +++ b/apps/app/src/components/sidebar/SidebarVisibilityCustomize.tsx @@ -0,0 +1,281 @@ +import { useCallback, useEffect, useId, useMemo, useRef } from "react"; +import { DndContext, type DragEndEvent } from "@dnd-kit/core"; +import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable"; +import { Button } from "@bb/shared-ui/button"; +import { Checkbox } from "@bb/shared-ui/checkbox"; +import { Icon } from "@bb/shared-ui/icon"; +import { + COARSE_POINTER_COMPACT_ROW_HEIGHT_CLASS, + COARSE_POINTER_ICON_SIZE_CLASS, + COARSE_POINTER_ROW_ACTION_SIZE_CLASS, +} from "@bb/shared-ui/coarse-pointer-sizing"; +import { CHROME_SECTION_LABEL_CLASS } from "@bb/shared-ui/chrome-style-tokens"; +import { cn } from "@bb/shared-ui/lib/utils"; +import type { + SidebarVisibilityItem, + SidebarActivationModifiers, +} from "./SidebarVisibilityControls"; +import { useSidebarSortable } from "./sortableMotion"; +import { useSidebarReorderDnd } from "./useSidebarReorderDnd"; + +export function SidebarVisibilityCustomize({ + items, + listLabel, + onActivate, + onDone, + onExit, + onReorder, + onVisibleChange, + testIdPrefix = "sidebar-navigation", + title, + variant, + visibleIds, +}: { + items: readonly SidebarVisibilityItem[]; + listLabel: string; + onActivate?: ( + item: SidebarVisibilityItem, + event: SidebarActivationModifiers, + ) => void; + onDone: () => void; + onExit?: () => void; + onReorder: (activeId: string, overId: string) => void; + onVisibleChange: (id: string, visible: boolean) => void; + testIdPrefix?: string; + title: string; + variant: "compact" | "card"; + visibleIds: readonly string[]; +}) { + const containerRef = useRef(null); + const doneButtonRef = useRef(null); + const orderedIds = useMemo(() => items.map((item) => item.id), [items]); + const visibleIdSet = useMemo(() => new Set(visibleIds), [visibleIds]); + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + if ( + typeof event.active.id !== "string" || + typeof event.over?.id !== "string" + ) + return; + onReorder(event.active.id, event.over.id); + }, + [onReorder], + ); + const { dndContextProps, onClickCapture } = useSidebarReorderDnd({ + onDragEnd: handleDragEnd, + }); + + useEffect(() => { + if (variant === "compact") { + doneButtonRef.current?.focus(); + return; + } + containerRef.current + ?.querySelector("[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 ( +
+
+ +
+ {title} +
+
+
{list}
+
+ ); + } + + 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.