diff --git a/apps/app/src/views/RootComposeMobileRecents.stories.tsx b/apps/app/src/views/RootComposeMobileRecents.stories.tsx
index 591af6b1b8..fde8c46c87 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 9d443938e2..16b451d704 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,
@@ -124,14 +124,19 @@ describe("getMobileRecentThreads", () => {
}),
);
- const rows = getMobileRecentThreads({
+ const { rows } = getMobileRecentThreads({
collapsedThreadIds: NONE,
draftThreadIds: NONE,
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,14 +147,46 @@ describe("getMobileRecentThreads", () => {
"thr_4",
"thr_3",
"thr_2",
- "thr_1",
- "thr_0",
]);
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: [
@@ -187,7 +224,7 @@ describe("getMobileRecentThreads", () => {
}),
];
- const rows = getMobileRecentThreads({
+ const { rows } = getMobileRecentThreads({
collapsedThreadIds: new Set(["thr_parent"]),
draftThreadIds: NONE,
threads,
@@ -199,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: [
@@ -217,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: [
@@ -686,6 +723,123 @@ 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(
+
+
+ ,
+ );
+
+ 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 9b5a39f216..eb6c56803e 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";
@@ -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 = (
@@ -54,6 +55,7 @@ interface GetMobileRecentThreadsArgs {
collapsedThreadIds: ReadonlySet;
draftThreadIds: ReadonlySet;
threads: readonly ThreadListEntry[];
+ visibleLimit?: number;
}
interface MobileRecentThreadRowProps {
@@ -137,6 +139,7 @@ export interface MobileRecentThreadRow {
hasUnsubmittedDraft: boolean;
hasChildren: boolean;
isCollapsed: boolean;
+ hasHiddenChildren: boolean;
}
function flattenMobileRecentNodes({
@@ -162,6 +165,7 @@ function flattenMobileRecentNodes({
hasUnsubmittedDraft: draftThreadIds.has(node.thread.id),
hasChildren,
isCollapsed,
+ hasHiddenChildren: isCollapsed,
});
if (hasChildren && !isCollapsed) {
flattenMobileRecentNodes({
@@ -206,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,
@@ -218,7 +226,15 @@ export function getMobileRecentThreads({
),
rows,
});
- return rows;
+ 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({
@@ -235,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:
@@ -419,6 +435,7 @@ export function RootComposeMobileRecents({
showCreatingRow,
threads,
}: RootComposeMobileRecentsProps) {
+ const [visibleLimit, setVisibleLimit] = useState(RECENT_THREAD_LIMIT);
const [collapsedThreadIdList, setCollapsedThreadIdList] = useAtom(
collapsedThreadIdsAtom,
);
@@ -449,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) {
@@ -467,7 +485,7 @@ export function RootComposeMobileRecents({
) : null}
+ {hasMore ? (
+
+ ) : null}
);
}