diff --git a/apps/app/src/components/sidebar/ProjectList.modes.test.tsx b/apps/app/src/components/sidebar/ProjectList.modes.test.tsx
index 48db4f6d65..88e49d5dea 100644
--- a/apps/app/src/components/sidebar/ProjectList.modes.test.tsx
+++ b/apps/app/src/components/sidebar/ProjectList.modes.test.tsx
@@ -17,6 +17,7 @@ import {
useAtomValue,
} from "jotai";
import { afterEach, describe, expect, it, vi } from "vitest";
+import { MemoryRouter } from "react-router-dom";
import type { ThreadListEntry } from "@bb/domain";
import { ActiveSidebarModeSections, MachineModeSections } from "./ProjectList";
import { buildMachineThreadGroups } from "@bb/client-core";
@@ -33,8 +34,14 @@ import {
} from "./sidebarCollapsedAtoms";
import { useSidebarModeSectionOrder } from "./useSidebarModeSectionOrder";
import { makeThreadListEntry } from "@bb/test-helpers/domain-fixtures";
+import { TooltipProvider } from "@bb/shared-ui/tooltip";
const mockUseHosts = vi.hoisted(() => vi.fn(() => ({ data: [] })));
+const mockUseSystemConfig = vi.hoisted(() =>
+ vi.fn(() => ({
+ data: { experiments: { sidebarProgressiveDisclosure: true } },
+ })),
+);
vi.mock("@/hooks/queries/host-queries", () => ({
useHosts: mockUseHosts,
@@ -42,7 +49,19 @@ vi.mock("@/hooks/queries/host-queries", () => ({
}));
vi.mock("@/hooks/queries/system-queries", () => ({
- useSystemConfig: () => ({ data: undefined }),
+ useSystemConfig: mockUseSystemConfig,
+}));
+
+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(),
+ }),
}));
const queryClient = new QueryClient();
@@ -156,30 +175,34 @@ function MachineModeProbe({ threads = [] }: { threads?: ThreadListEntry[] }) {
};
return (
-
- 0}
- renderSectionDisplayOptions={() => null}
- isSectionDisplayOptionsOpen={() => false}
- onToggleCollapsed={handleToggleCollapsed}
- onToggleThreadCollapsed={vi.fn()}
- onToggleEnvironmentCollapsed={vi.fn()}
- />
-
+
+
+
+ 0}
+ renderSectionDisplayOptions={() => null}
+ isSectionDisplayOptionsOpen={() => false}
+ onToggleCollapsed={handleToggleCollapsed}
+ onToggleThreadCollapsed={vi.fn()}
+ onToggleEnvironmentCollapsed={vi.fn()}
+ />
+
+
+
);
}
@@ -293,4 +316,41 @@ describe("sidebar organization mode sections", () => {
expect(screen.getByLabelText("Plan mode active")).not.toBeNull();
expect(screen.queryByLabelText("Thread working")).toBeNull();
});
+
+ 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 19a3d48ecf..0fea909c14 100644
--- a/apps/app/src/components/sidebar/ProjectList.tsx
+++ b/apps/app/src/components/sidebar/ProjectList.tsx
@@ -1148,7 +1148,6 @@ export function MachineModeSections({
threads,
threadsSection,
}: MachineModeSectionsProps) {
- const progressiveDisclosureEnabled = useSidebarProgressiveDisclosureEnabled();
const { data: hosts } = useHosts();
const [collapsedMachineKeyList, setCollapsedMachineKeyList] = useAtom(
sidebarCollapsedMachinesAtom,
@@ -1278,7 +1277,7 @@ export function MachineModeSections({
dndParentKey={CHRONOLOGICAL_CONTAINER_ID}
rootItems={allThreadItems}
threadListState={allThreadsListState}
- progressiveDisclosureEnabled={progressiveDisclosureEnabled}
+ progressiveDisclosureEnabled={false}
compareThreads={compareThreads}
variant="section"
selectedThreadId={selectedThreadId}
@@ -1328,7 +1327,7 @@ export function MachineModeSections({
dndParentKey={sectionId}
rootItems={machineItemsBySectionId.get(sectionId)}
threadListState={section.threadListState}
- progressiveDisclosureEnabled={progressiveDisclosureEnabled}
+ progressiveDisclosureEnabled={false}
compareThreads={compareThreads}
variant="section"
selectedThreadId={selectedThreadId}
diff --git a/apps/app/src/components/sidebar/ProjectRow.tsx b/apps/app/src/components/sidebar/ProjectRow.tsx
index ca4d3ac028..c6c022086d 100644
--- a/apps/app/src/components/sidebar/ProjectRow.tsx
+++ b/apps/app/src/components/sidebar/ProjectRow.tsx
@@ -1974,6 +1974,34 @@ function isAttentionProjectThreadItem(
);
}
+function getProjectThreadItemFinishedAt(item: ProjectThreadItem): number {
+ let finishedAt = Number.NEGATIVE_INFINITY;
+ for (const thread of getProjectThreadItemDescendants([item])) {
+ if (thread.status !== "idle" && thread.status !== "error") continue;
+ finishedAt = Math.max(finishedAt, thread.latestAttentionAt);
+ }
+ return finishedAt;
+}
+
+function rankDisclosureItems(
+ items: readonly ProjectThreadItem[],
+): ProjectThreadItem[] {
+ const inputIndex = new Map(
+ items.map((item, index) => [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,
@@ -2014,27 +2042,47 @@ export const ProjectThreadTree = memo(function ProjectThreadTree({
buildProjectThreadGroups(projectThreads, compareThreads, draftThreadIds),
[compareThreads, draftThreadIds, projectThreads, 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(
@@ -2050,6 +2098,10 @@ export const ProjectThreadTree = memo(function ProjectThreadTree({
: undefined,
);
};
+ const handleShowLess = () => {
+ setRevealedItemKeys(new Set());
+ setFocusItemKey(undefined);
+ };
if (threadListState.status === "loading") {
return ;
@@ -2110,19 +2162,34 @@ export const ProjectThreadTree = memo(function ProjectThreadTree({
})}
/>
) : null}
- {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 ecb4400ff7..638f83fe07 100644
--- a/apps/app/src/views/SettingsView.tsx
+++ b/apps/app/src/views/SettingsView.tsx
@@ -1040,7 +1040,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 c6c104d480..e78a2176a0 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -994,11 +994,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 d1447e1eab..f41844e5f6 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 `timelineWindowing` experiment mounts only nearby rows in long
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 80155af79f..308cc36554 100644
--- a/plugins/bb-guide/skills/bb-cli/references/app-settings.md
+++ b/plugins/bb-guide/skills/bb-cli/references/app-settings.md
@@ -146,10 +146,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