From 97caed2f9e96510b68a947f8735e348027e586cd Mon Sep 17 00:00:00 2001 From: Joshua Pham Date: Wed, 16 Sep 2026 16:53:56 +0000 Subject: [PATCH] Improve project sidebar disclosure --- .../sidebar/ProjectList.modes.test.tsx | 44 +++++++++- .../src/components/sidebar/ProjectList.tsx | 7 +- .../app/src/components/sidebar/ProjectRow.tsx | 87 ++++++++++++++++--- .../ProjectThreadTree.disclosure.test.tsx | 53 ++++++++++- apps/app/src/views/SettingsView.tsx | 2 +- docs/configuration.md | 11 +-- .../src/templates/bb-guide-customization.md | 11 +-- .../skills/bb-cli/references/app-settings.md | 9 +- 8 files changed, 192 insertions(+), 32 deletions(-) diff --git a/apps/app/src/components/sidebar/ProjectList.modes.test.tsx b/apps/app/src/components/sidebar/ProjectList.modes.test.tsx index eb2ec8edab..1c54e044a5 100644 --- a/apps/app/src/components/sidebar/ProjectList.modes.test.tsx +++ b/apps/app/src/components/sidebar/ProjectList.modes.test.tsx @@ -55,8 +55,14 @@ vi.mock("@/hooks/queries/host-queries", () => ({ usePrimaryHost: vi.fn(() => undefined), })); +const mockUseSystemConfig = vi.hoisted(() => + vi.fn(() => ({ + data: { experiments: { sidebarProgressiveDisclosure: true } }, + })), +); + vi.mock("@/hooks/queries/system-queries", () => ({ - useSystemConfig: () => ({ data: undefined }), + useSystemConfig: mockUseSystemConfig, })); vi.mock("@/components/thread/ThreadActionsProvider", () => ({ @@ -433,4 +439,40 @@ describe("sidebar organization mode sections", () => { expect(store.get(sidebarCollapsedMachinesAtom)).toEqual(["no-machine"]); expect(store.get(sidebarMachineSectionOrderAtom)).toEqual(savedOrder); }); + it("does not progressively disclose threads in By machine", () => { + const store = createStore(); + store.set(sidebarMachineSectionOrderAtom, ["machine:no-machine"]); + const threads = Array.from({ length: 7 }, (_, index) => + makeThread({ + id: `machine-thread-${index}`, + title: `Machine thread ${index}`, + titleFallback: `Machine thread ${index}`, + status: "idle", + hasPendingInteraction: false, + lastReadAt: 10, + latestAttentionAt: index, + runtime: { + displayStatus: "idle", + hostReconnectGraceExpiresAt: null, + }, + activity: { + activeWorkflowCount: 0, + activeBackgroundAgentCount: 0, + activeBackgroundCommandCount: 0, + activePlanModeCount: 0, + activeGoalCount: 0, + }, + }), + ); + + render( + + + , + ); + + expect(screen.getByText("Machine thread 6")).not.toBeNull(); + expect(screen.queryByRole("button", { name: "Show more" })).toBeNull(); + expect(mockUseSystemConfig).not.toHaveBeenCalled(); + }); }); diff --git a/apps/app/src/components/sidebar/ProjectList.tsx b/apps/app/src/components/sidebar/ProjectList.tsx index 4db0a5a4a1..28a56f0a29 100644 --- a/apps/app/src/components/sidebar/ProjectList.tsx +++ b/apps/app/src/components/sidebar/ProjectList.tsx @@ -1165,7 +1165,6 @@ export function MachineModeSections({ threads, threadsSection, }: MachineModeSectionsProps) { - const progressiveDisclosureEnabled = useSidebarProgressiveDisclosureEnabled(); const groupThreadsByEnvironment = useAtomValue( sidebarGroupThreadsByEnvironmentAtom, ); @@ -1311,7 +1310,7 @@ export function MachineModeSections({ dndParentKey={CHRONOLOGICAL_CONTAINER_ID} rootItems={allThreadItems} threadListState={allThreadsListState} - progressiveDisclosureEnabled={progressiveDisclosureEnabled} + progressiveDisclosureEnabled={false} compareThreads={compareThreads} variant="section" selectedThreadId={selectedThreadId} @@ -1337,7 +1336,7 @@ export function MachineModeSections({ [getSidebarItemKey(item), index]), + ); + return [...items].sort((left, right) => { + const leftFinishedAt = getProjectThreadItemFinishedAt(left); + const rightFinishedAt = getProjectThreadItemFinishedAt(right); + if (leftFinishedAt !== rightFinishedAt) { + return leftFinishedAt > rightFinishedAt ? -1 : 1; + } + return ( + (inputIndex.get(getSidebarItemKey(left)) ?? 0) - + (inputIndex.get(getSidebarItemKey(right)) ?? 0) + ); + }); +} + export const ProjectThreadTree = memo(function ProjectThreadTree({ projectId, dndParentKey, @@ -1941,27 +1969,47 @@ export const ProjectThreadTree = memo(function ProjectThreadTree({ providedRootItems, ], ); + const disclosureItems = useMemo( + () => + rankDisclosureItems( + allRootItems.filter( + (item) => !isAttentionProjectThreadItem(item, selectedThreadId), + ), + ), + [allRootItems, selectedThreadId], + ); + const initialDisclosureItemKeys = useMemo( + () => + new Set( + disclosureItems + .slice(0, THREAD_ITEMS_INITIAL_LIMIT) + .map(getSidebarItemKey), + ), + [disclosureItems], + ); const rootItems = useMemo(() => { if (!progressiveDisclosureEnabled) { return allRootItems; } return allRootItems.filter( - (item, index) => - index < THREAD_ITEMS_INITIAL_LIMIT || + (item) => + initialDisclosureItemKeys.has(getSidebarItemKey(item)) || revealedItemKeys.has(getSidebarItemKey(item)) || isAttentionProjectThreadItem(item, selectedThreadId), ); }, [ allRootItems, + initialDisclosureItemKeys, selectedThreadId, revealedItemKeys, progressiveDisclosureEnabled, ]); const visibleItemKeys = new Set(rootItems.map(getSidebarItemKey)); - const hiddenItems = allRootItems.filter( + const hiddenItems = disclosureItems.filter( (item) => !visibleItemKeys.has(getSidebarItemKey(item)), ); const hasMoreItems = hiddenItems.length > 0; + const hasRevealedItems = revealedItemKeys.size > 0; const handleShowMore: MouseEventHandler = (event) => { const nextItems = hiddenItems.slice(0, THREAD_ITEMS_EXPAND_SIZE); setRevealedItemKeys( @@ -1977,6 +2025,10 @@ export const ProjectThreadTree = memo(function ProjectThreadTree({ : undefined, ); }; + const handleShowLess = () => { + setRevealedItemKeys(new Set()); + setFocusItemKey(undefined); + }; if (threadListState.status === "loading") { return ; @@ -2022,19 +2074,34 @@ export const ProjectThreadTree = memo(function ProjectThreadTree({ onToggleThreadCollapsed={onToggleThreadCollapsed} onToggleEnvironmentCollapsed={onToggleEnvironmentCollapsed} /> - {hasMoreItems ? ( - + {hasMoreItems ? ( + + ) : null} + {hasRevealedItems ? ( + + ) : null} + ) : null} ); diff --git a/apps/app/src/components/sidebar/ProjectThreadTree.disclosure.test.tsx b/apps/app/src/components/sidebar/ProjectThreadTree.disclosure.test.tsx index 91aa913cd2..61b85dc538 100644 --- a/apps/app/src/components/sidebar/ProjectThreadTree.disclosure.test.tsx +++ b/apps/app/src/components/sidebar/ProjectThreadTree.disclosure.test.tsx @@ -148,7 +148,7 @@ describe("ProjectThreadTree progressive disclosure", () => { expect(screen.getByText("Thread 6")).not.toBeNull(); }); - it("reveals ten more items per Show more click and hides the button when exhausted", () => { + it("reveals ten more items per click and Show less restores the initial set", () => { renderThreadTree(makePlainThreads(17)); expect(screen.getByText("Thread 4")).not.toBeNull(); @@ -159,11 +159,18 @@ describe("ProjectThreadTree progressive disclosure", () => { expect(screen.getByText("Thread 14")).not.toBeNull(); expect(screen.queryByText("Thread 15")).toBeNull(); expect(screen.getByRole("button", { name: "Show more" })).not.toBeNull(); - expect(screen.queryByRole("button", { name: "Show less" })).toBeNull(); + expect(screen.getByRole("button", { name: "Show less" })).not.toBeNull(); fireEvent.click(screen.getByRole("button", { name: "Show more" })); expect(screen.getByText("Thread 16")).not.toBeNull(); expect(screen.queryByRole("button", { name: "Show more" })).toBeNull(); + expect(screen.getByRole("button", { name: "Show less" })).not.toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "Show less" })); + expect(screen.getByText("Thread 4")).not.toBeNull(); + expect(screen.queryByText("Thread 5")).toBeNull(); + expect(screen.getByRole("button", { name: "Show more" })).not.toBeNull(); + expect(screen.queryByRole("button", { name: "Show less" })).toBeNull(); }); it("does not spend Show more slots on attention items", () => { @@ -243,4 +250,46 @@ describe("ProjectThreadTree progressive disclosure", () => { expect(screen.getByText("Thread 6")).not.toBeNull(); expect(screen.getByText("Thread 7")).not.toBeNull(); }); + + it("ranks a root tree by its most recently finished descendant", () => { + const threads = [ + makeThreadListEntry({ + id: "older-parent", + title: "Older parent", + titleFallback: "Older parent", + createdAt: 1, + updatedAt: 1, + latestAttentionAt: 1, + lastReadAt: 100, + }), + makeThreadListEntry({ + id: "recent-child", + parentThreadId: "older-parent", + title: "Recent child", + titleFallback: "Recent child", + createdAt: 2, + updatedAt: 100, + latestAttentionAt: 100, + lastReadAt: 100, + }), + ...Array.from({ length: 5 }, (_, index) => + makeThreadListEntry({ + id: `other-${index}`, + title: `Other ${index}`, + titleFallback: `Other ${index}`, + createdAt: index + 3, + updatedAt: index + 10, + latestAttentionAt: index + 10, + lastReadAt: index + 10, + }), + ), + ]; + + renderThreadTree(threads); + + expect(screen.getByText("Older parent")).not.toBeNull(); + expect(screen.getByText("Recent child")).not.toBeNull(); + expect(screen.queryByText("Other 0")).toBeNull(); + expect(screen.getByRole("button", { name: "Show more" })).not.toBeNull(); + }); }); diff --git a/apps/app/src/views/SettingsView.tsx b/apps/app/src/views/SettingsView.tsx index 881c9348c9..c3ff163144 100644 --- a/apps/app/src/views/SettingsView.tsx +++ b/apps/app/src/views/SettingsView.tsx @@ -1045,7 +1045,7 @@ const EXPERIMENT_DEFINITIONS: Record< sidebarProgressiveDisclosure: { label: "Sidebar progressive disclosure", description: - "In By project and By machine, show the first five groups in the current sort order, keep attention groups visible, and reveal ten more per click. Manually is unchanged.", + "In By project, show five recently finished thread trees, keep attention groups visible, and reveal or collapse more trees. Other organization modes are unchanged.", }, timelineWindowing: { label: "Timeline windowing", diff --git a/docs/configuration.md b/docs/configuration.md index a859297c10..0501fe874e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1039,11 +1039,12 @@ checks for these sessions every five minutes. Active turns, commands, agents, workflows, and monitors keep their sessions loaded. The `sidebarProgressiveDisclosure` experiment is off by default. In **By -project** and **By machine**, it shows the first five groups in the current sort -order, keeps attention groups visible, and reveals ten more per **Show more** -click. Revealed groups stay visible through activity and sort-order changes. -**Manually** is unchanged. Toggle it with `bb settings experiment -sidebarProgressiveDisclosure `. +project**, it keeps attention groups visible, shows the five root trees with the +most recently finished descendant, and reveals ten more per **Show more** +click. **Show less** returns to the initial set. Parent and child rows stay +together, and revealed groups remain visible through activity and sort-order +changes. Other organization modes are unchanged. Toggle it with +`bb settings experiment sidebarProgressiveDisclosure `. The `timelineWindowing` experiment is off by default. When enabled, long timelines and large expanded timeline details retain stable height-preserving diff --git a/packages/templates/src/templates/bb-guide-customization.md b/packages/templates/src/templates/bb-guide-customization.md index 1ecfd29820..829c1c6638 100644 --- a/packages/templates/src/templates/bb-guide-customization.md +++ b/packages/templates/src/templates/bb-guide-customization.md @@ -157,11 +157,12 @@ BB releases restorable provider sessions after 30 idle minutes. The daemon checks for these sessions every five minutes. Active turns, commands, agents, workflows, and monitors keep their sessions loaded. -The default-off `sidebarProgressiveDisclosure` experiment shows the first five -groups in the current sort order in **By project** and **By machine**, keeps -attention groups visible, and reveals ten more per **Show more** click. Revealed -groups stay visible through activity and sort-order changes. -**Manually** is unchanged. Enable it with `bb settings experiment +The default-off `sidebarProgressiveDisclosure` experiment keeps attention +groups visible in **By project**, shows the five root trees with the most +recently finished descendant, and reveals ten more per **Show more** click. +**Show less** returns to the initial set. Parent and child rows stay together, +and revealed groups remain visible through activity and sort-order changes. +Other organization modes are unchanged. Enable it with `bb settings experiment sidebarProgressiveDisclosure true`. The default-off `serverMove` experiment enables Move server here in Settings → 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 030ddf17ca..ad8e0a2ad2 100644 --- a/plugins/bb-guide/skills/bb-cli/references/app-settings.md +++ b/plugins/bb-guide/skills/bb-cli/references/app-settings.md @@ -176,10 +176,11 @@ every window and client sees the same value. - The `sidebarProgressiveDisclosure` experiment defaults to false. - Enable it with `bb settings experiment sidebarProgressiveDisclosure true`. -- In **By project** and **By machine**, it shows the first five groups in the - current sort order, keeps attention groups visible, and reveals ten more per - **Show more** click. Revealed groups stay visible through activity and - sort-order changes. **Manually** is unchanged. +- In **By project**, it keeps attention groups visible, shows the five root + trees with the most recently finished descendant, and reveals ten more per + **Show more** click. **Show less** returns to the initial set. Parent and + child rows stay together, and revealed groups stay visible through activity + and sort-order changes. Other organization modes are unchanged. ## Timeline windowing