From c16a8df62a95b623233864b9bcf900e826e0b057 Mon Sep 17 00:00:00 2001 From: Joshua Pham Date: Wed, 16 Sep 2026 07:23:25 +0000 Subject: [PATCH 1/3] Restore compose recents on desktop --- .../views/RootComposeMobileRecents.test.tsx | 21 +++++++++++++++++++ .../src/views/RootComposeMobileRecents.tsx | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/apps/app/src/views/RootComposeMobileRecents.test.tsx b/apps/app/src/views/RootComposeMobileRecents.test.tsx index 9d443938e28..f941b08b0f5 100644 --- a/apps/app/src/views/RootComposeMobileRecents.test.tsx +++ b/apps/app/src/views/RootComposeMobileRecents.test.tsx @@ -686,6 +686,27 @@ describe("mobile recent thread rows", () => { }); describe("RootComposeMobileRecents", () => { + it("remains visible in the desktop compose layout", () => { + const { container } = render( + + + , + ); + + const section = container.querySelector( + "[data-root-compose-mobile-recents]", + ); + expect(section).not.toBeNull(); + expect(section?.className).not.toContain("md:hidden"); + expect(section?.className).toContain("md:mt-4"); + }); + it("shows concurrent Plan activity before the runtime spinner", () => { render( diff --git a/apps/app/src/views/RootComposeMobileRecents.tsx b/apps/app/src/views/RootComposeMobileRecents.tsx index 9b5a39f2160..3a65047e88e 100644 --- a/apps/app/src/views/RootComposeMobileRecents.tsx +++ b/apps/app/src/views/RootComposeMobileRecents.tsx @@ -467,7 +467,7 @@ export function RootComposeMobileRecents({

Date: Wed, 16 Sep 2026 17:07:32 +0000 Subject: [PATCH 2/3] Limit compose recents to fifteen threads --- .../app/src/views/RootComposeMobileRecents.test.tsx | 13 ++++++++----- apps/app/src/views/RootComposeMobileRecents.tsx | 3 ++- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/apps/app/src/views/RootComposeMobileRecents.test.tsx b/apps/app/src/views/RootComposeMobileRecents.test.tsx index f941b08b0f5..545e6f288ac 100644 --- a/apps/app/src/views/RootComposeMobileRecents.test.tsx +++ b/apps/app/src/views/RootComposeMobileRecents.test.tsx @@ -115,8 +115,8 @@ afterEach(() => { const NONE: ReadonlySet = new Set(); describe("getMobileRecentThreads", () => { - it("returns every active thread newest-first instead of a capped window", () => { - const threads = Array.from({ length: 12 }, (_unused, index) => + it("returns the 15 most recent active threads newest-first", () => { + const threads = Array.from({ length: 17 }, (_unused, index) => makeThread({ id: `thr_${index}`, latestAttentionAt: index, @@ -130,8 +130,13 @@ describe("getMobileRecentThreads", () => { threads, }); - expect(rows).toHaveLength(12); + expect(rows).toHaveLength(15); expect(rows.map((row) => row.thread.id)).toEqual([ + "thr_16", + "thr_15", + "thr_14", + "thr_13", + "thr_12", "thr_11", "thr_10", "thr_9", @@ -142,8 +147,6 @@ describe("getMobileRecentThreads", () => { "thr_4", "thr_3", "thr_2", - "thr_1", - "thr_0", ]); expect(rows.every((row) => row.depth === 0)).toBe(true); }); diff --git a/apps/app/src/views/RootComposeMobileRecents.tsx b/apps/app/src/views/RootComposeMobileRecents.tsx index 3a65047e88e..84ddcb57b9b 100644 --- a/apps/app/src/views/RootComposeMobileRecents.tsx +++ b/apps/app/src/views/RootComposeMobileRecents.tsx @@ -43,6 +43,7 @@ import { collapsedThreadIdsAtom } from "@/components/sidebar/sidebarCollapsedAto export const MOBILE_RECENT_ROW_HEIGHT_PX = 60; export const MOBILE_RECENT_LABEL_HEIGHT_PX = 24; +const RECENT_THREAD_LIMIT = 15; const MOBILE_RECENT_ROW_HEIGHT_CLASS = "h-15"; type ThreadListEntryComparator = ( @@ -218,7 +219,7 @@ export function getMobileRecentThreads({ ), rows, }); - return rows; + return rows.slice(0, RECENT_THREAD_LIMIT); } function MobileRecentThreadRow({ From 30eb8586b242f1d7b8314917bdd818881d4724e4 Mon Sep 17 00:00:00 2001 From: SawyerHood Date: Mon, 21 Sep 2026 11:59:33 -0700 Subject: [PATCH 3/3] Preserve access and activity for truncated recent thread children --- .../RootComposeMobileRecents.stories.tsx | 41 +++++ .../views/RootComposeMobileRecents.test.tsx | 140 +++++++++++++++++- .../src/views/RootComposeMobileRecents.tsx | 40 ++++- 3 files changed, 210 insertions(+), 11 deletions(-) diff --git a/apps/app/src/views/RootComposeMobileRecents.stories.tsx b/apps/app/src/views/RootComposeMobileRecents.stories.tsx index 591af6b1b8c..fde8c46c87c 100644 --- a/apps/app/src/views/RootComposeMobileRecents.stories.tsx +++ b/apps/app/src/views/RootComposeMobileRecents.stories.tsx @@ -299,3 +299,44 @@ export function Overview() { ); } + +export function CutoffHierarchy() { + const threads = [ + ...Array.from({ length: 14 }, (_, index) => + makeRecentThread({ + overrides: { + id: `thr_cutoff_recent_${index}`, + title: `Recent thread ${index + 1}`, + latestAttentionAt: 100 - index, + }, + }), + ), + makeRecentThread({ + overrides: { + id: "thr_cutoff_parent", + title: "Boundary parent", + latestAttentionAt: 50, + }, + }), + makeRecentThread({ + overrides: { + id: "thr_cutoff_child", + title: "Waiting child", + parentThreadId: "thr_cutoff_parent", + latestAttentionAt: 200, + hasPendingInteraction: true, + }, + }), + ]; + return ( +
+ +
+ ); +} diff --git a/apps/app/src/views/RootComposeMobileRecents.test.tsx b/apps/app/src/views/RootComposeMobileRecents.test.tsx index 545e6f288ac..16b451d7040 100644 --- a/apps/app/src/views/RootComposeMobileRecents.test.tsx +++ b/apps/app/src/views/RootComposeMobileRecents.test.tsx @@ -124,7 +124,7 @@ describe("getMobileRecentThreads", () => { }), ); - const rows = getMobileRecentThreads({ + const { rows } = getMobileRecentThreads({ collapsedThreadIds: NONE, draftThreadIds: NONE, threads, @@ -151,8 +151,42 @@ describe("getMobileRecentThreads", () => { expect(rows.every((row) => row.depth === 0)).toBe(true); }); + it("marks only ancestors of omitted rows as having hidden children", () => { + const { rows, hasMore } = getMobileRecentThreads({ + collapsedThreadIds: NONE, + draftThreadIds: NONE, + visibleLimit: 3, + threads: [ + makeIdleThread({ id: "parent" }), + makeIdleThread({ + id: "child", + parentThreadId: "parent", + latestAttentionAt: 10, + }), + makeIdleThread({ id: "grandchild", parentThreadId: "child" }), + makeIdleThread({ + id: "sibling", + parentThreadId: "parent", + latestAttentionAt: 1, + hasPendingInteraction: true, + }), + ], + }); + expect(hasMore).toBe(true); + expect( + rows.map(({ thread, hasHiddenChildren }) => [ + thread.id, + hasHiddenChildren, + ]), + ).toEqual([ + ["parent", true], + ["child", false], + ["grandchild", false], + ]); + }); + it("nests a child under its parent instead of listing it as a peer", () => { - const rows = getMobileRecentThreads({ + const { rows } = getMobileRecentThreads({ collapsedThreadIds: NONE, draftThreadIds: NONE, threads: [ @@ -190,7 +224,7 @@ describe("getMobileRecentThreads", () => { }), ]; - const rows = getMobileRecentThreads({ + const { rows } = getMobileRecentThreads({ collapsedThreadIds: new Set(["thr_parent"]), draftThreadIds: NONE, threads, @@ -202,7 +236,7 @@ describe("getMobileRecentThreads", () => { }); it("promotes a child whose parent is absent to the top level", () => { - const rows = getMobileRecentThreads({ + const { rows } = getMobileRecentThreads({ collapsedThreadIds: NONE, draftThreadIds: NONE, threads: [ @@ -220,7 +254,7 @@ describe("getMobileRecentThreads", () => { }); it("does not group worktree threads into environment rows", () => { - const rows = getMobileRecentThreads({ + const { rows } = getMobileRecentThreads({ collapsedThreadIds: NONE, draftThreadIds: NONE, threads: [ @@ -689,6 +723,102 @@ describe("mobile recent thread rows", () => { }); describe("RootComposeMobileRecents", () => { + it("retains cutoff child activity after expansion and reveals the child with show more", () => { + const threads = [ + ...Array.from({ length: 14 }, (_, index) => + makeIdleThread({ + id: `thr_recent_${index}`, + latestAttentionAt: 100 - index, + }), + ), + makeIdleThread({ + id: "thr_parent", + title: "Boundary parent", + latestAttentionAt: 50, + }), + makeIdleThread({ + id: "thr_child", + title: "Waiting child", + parentThreadId: "thr_parent", + latestAttentionAt: 200, + hasPendingInteraction: true, + }), + ]; + render( + + + , + ); + expect(screen.getAllByRole("link")).toHaveLength(15); + expect( + screen.queryByRole("button", { name: "Show more recent threads" }), + ).toBeNull(); + fireEvent.click( + screen.getByRole("button", { + name: "Show threads under Boundary parent", + }), + ); + expect( + screen.getByRole("link", { + name: "Open Boundary parent — Thread needs user input", + }), + ).toBeTruthy(); + expect( + screen.queryByRole("link", { name: /Open Waiting child/ }), + ).toBeNull(); + fireEvent.click( + screen.getByRole("button", { name: "Show more recent threads" }), + ); + expect( + screen.getByRole("link", { + name: "Open Waiting child — Thread needs user input", + }), + ).toBeTruthy(); + expect( + screen + .getByRole("link", { name: /^Open Boundary parent/ }) + .getAttribute("aria-label"), + ).not.toContain("Thread needs user input"); + expect(screen.getAllByRole("link")).toHaveLength(16); + expect( + screen.queryByRole("button", { name: "Show more recent threads" }), + ).toBeNull(); + }); + + it("reveals additional recent threads in bounded batches", () => { + render( + + + makeIdleThread({ id: `thr_${index}` }), + )} + /> + , + ); + expect(screen.getAllByRole("link")).toHaveLength(15); + fireEvent.click( + screen.getByRole("button", { name: "Show more recent threads" }), + ); + expect(screen.getAllByRole("link")).toHaveLength(30); + fireEvent.click( + screen.getByRole("button", { name: "Show more recent threads" }), + ); + expect(screen.getAllByRole("link")).toHaveLength(32); + expect( + screen.queryByRole("button", { name: "Show more recent threads" }), + ).toBeNull(); + }); + it("remains visible in the desktop compose layout", () => { const { container } = render( diff --git a/apps/app/src/views/RootComposeMobileRecents.tsx b/apps/app/src/views/RootComposeMobileRecents.tsx index 84ddcb57b9b..eb6c56803e9 100644 --- a/apps/app/src/views/RootComposeMobileRecents.tsx +++ b/apps/app/src/views/RootComposeMobileRecents.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useAtom } from "jotai"; import type { ProviderInfo, ThreadListEntry } from "@bb/domain"; import { RouteAnchor } from "@/components/ui/app-route-anchor"; @@ -55,6 +55,7 @@ interface GetMobileRecentThreadsArgs { collapsedThreadIds: ReadonlySet; draftThreadIds: ReadonlySet; threads: readonly ThreadListEntry[]; + visibleLimit?: number; } interface MobileRecentThreadRowProps { @@ -138,6 +139,7 @@ export interface MobileRecentThreadRow { hasUnsubmittedDraft: boolean; hasChildren: boolean; isCollapsed: boolean; + hasHiddenChildren: boolean; } function flattenMobileRecentNodes({ @@ -163,6 +165,7 @@ function flattenMobileRecentNodes({ hasUnsubmittedDraft: draftThreadIds.has(node.thread.id), hasChildren, isCollapsed, + hasHiddenChildren: isCollapsed, }); if (hasChildren && !isCollapsed) { flattenMobileRecentNodes({ @@ -207,7 +210,11 @@ export function getMobileRecentThreads({ collapsedThreadIds, draftThreadIds, threads, -}: GetMobileRecentThreadsArgs): MobileRecentThreadRow[] { + visibleLimit = RECENT_THREAD_LIMIT, +}: GetMobileRecentThreadsArgs): { + rows: MobileRecentThreadRow[]; + hasMore: boolean; +} { const rows: MobileRecentThreadRow[] = []; flattenMobileRecentNodes({ collapsedThreadIds, @@ -219,7 +226,15 @@ export function getMobileRecentThreads({ ), rows, }); - return rows.slice(0, RECENT_THREAD_LIMIT); + const visibleRows = rows.slice(0, visibleLimit); + let truncatedDepth = rows[visibleLimit]?.depth ?? 0; + for (const row of [...visibleRows].reverse()) { + if (row.depth < truncatedDepth) { + row.hasHiddenChildren = true; + truncatedDepth = row.depth; + } + } + return { rows: visibleRows, hasMore: rows.length > visibleLimit }; } function MobileRecentThreadRow({ @@ -236,13 +251,13 @@ function MobileRecentThreadRow({ hasUnsubmittedDraft, hasChildren, isCollapsed, + hasHiddenChildren, } = row; const touchStartedBeforeLink = useRef(false); const { providers: environmentProviders } = useSystemEnvironmentProviders(); const threadTitle = getThreadDisplayTitle(thread); const indicatorState: ThreadListIndicatorState = threadListIndicatorStateForThread(thread, hasUnsubmittedDraft); - const hasHiddenChildren = hasChildren && isCollapsed; const trailingIndicatorState: ThreadListIndicatorState = hasHiddenChildren ? { hasPendingInteraction: @@ -420,6 +435,7 @@ export function RootComposeMobileRecents({ showCreatingRow, threads, }: RootComposeMobileRecentsProps) { + const [visibleLimit, setVisibleLimit] = useState(RECENT_THREAD_LIMIT); const [collapsedThreadIdList, setCollapsedThreadIdList] = useAtom( collapsedThreadIdsAtom, ); @@ -450,14 +466,15 @@ export function RootComposeMobileRecents({ return next.length === current.length ? current : next; }); }, [highlightedThreadId, setCollapsedThreadIdList, threads]); - const recentThreads = useMemo( + const { rows: recentThreads, hasMore } = useMemo( () => getMobileRecentThreads({ collapsedThreadIds, draftThreadIds, threads, + visibleLimit, }), - [collapsedThreadIds, draftThreadIds, threads], + [collapsedThreadIds, draftThreadIds, threads, visibleLimit], ); if (!showCreatingRow && recentThreads.length === 0) { @@ -519,6 +536,17 @@ export function RootComposeMobileRecents({ ))} ) : null} + {hasMore ? ( + + ) : null}

); }