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