diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index b40e7f28faf..734f978e62f 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -59,6 +59,68 @@ describe("sortThreadsForListV2", () => { }); describe("buildThreadListV2Items", () => { + it("excludes subagent child threads from top-level rows and counts", () => { + const rootThreadId = ThreadId.make("root"); + const activeChildId = ThreadId.make("active-child"); + const forkId = ThreadId.make("fork"); + const settledChildId = ThreadId.make("settled-child"); + const snoozedChildId = ThreadId.make("snoozed-child"); + const layout = buildThreadListV2Items({ + threads: [ + makeThread({ id: rootThreadId, title: "Root" }), + makeThread({ + id: forkId, + title: "Fork", + lineage: { + rootThreadId, + parentThreadId: rootThreadId, + relationshipToParent: "fork", + }, + }), + makeThread({ + id: activeChildId, + title: "Active child", + lineage: { + rootThreadId, + parentThreadId: rootThreadId, + relationshipToParent: "subagent", + }, + }), + makeThread({ + id: settledChildId, + title: "Settled child", + lineage: { + rootThreadId, + parentThreadId: rootThreadId, + relationshipToParent: "subagent", + }, + settledOverride: "settled", + settledAt: "2026-06-01T12:00:00.000Z", + }), + makeThread({ + id: snoozedChildId, + title: "Snoozed child", + lineage: { + rootThreadId, + parentThreadId: rootThreadId, + relationshipToParent: "subagent", + }, + snoozedUntil: "2026-06-03T09:00:00.000Z", + snoozedAt: "2026-06-01T12:00:00.000Z", + }), + ], + environmentId: null, + searchQuery: "", + now: NOW, + settledLimit: 0, + }); + + expect(layout.items.map((item) => item.thread.id)).toEqual([forkId, rootThreadId]); + expect(layout.hiddenSettledCount).toBe(0); + expect(layout.snoozedCount).toBe(0); + expect(layout.nextSnoozeWakeAt).toBeNull(); + }); + it("hides snoozed threads and counts them — visibility parity with web", () => { const layout = buildThreadListV2Items({ threads: [ diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index da4b19e61e1..7e21d292783 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -143,6 +143,7 @@ export function buildThreadListV2Items(input: { let snoozedCount = 0; let nextSnoozeWakeAt: string | null = null; for (const thread of input.threads) { + if (thread.lineage.relationshipToParent === "subagent") continue; // Callers pass live (unarchived) shells; settled threads are among them // and partition into the tail via effectiveSettled. if (input.environmentId !== null && thread.environmentId !== input.environmentId) continue; diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 8e92a5bc7ab..a5ab342b663 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -3,6 +3,7 @@ import { archiveSelectedThreadEntries, buildMultiSelectThreadContextMenuItems, createThreadJumpHintVisibilityController, + filterSidebarV2VisibleThreads, getSidebarThreadIdsToPrewarm, getVisibleSidebarThreadIds, resolveAdjacentThreadId, @@ -25,6 +26,7 @@ import { shouldNavigateAfterProjectRemoval, shouldClearThreadSelectionOnMouseDown, sortLogicalProjectsForSidebar, + sortSidebarV2ProjectGroups, sortSettledThreadsForSidebarV2, sortThreadsForSidebarV2, sortScopedProjectsForSidebar, @@ -198,6 +200,55 @@ describe("resolveSidebarStageBadgeLabel", () => { }); describe("sidebar thread lineage helpers", () => { + it("keeps only top-level, unarchived threads in the Sidebar V2 project scope", () => { + const parentId = ThreadId.make("thread-parent"); + const projectId = ProjectId.make("project-visible"); + const environmentId = EnvironmentId.make("environment-visible"); + const root = makeThreadFixture({ + id: parentId, + environmentId, + projectId, + }); + const subagent = makeThreadFixture({ + id: ThreadId.make("thread-subagent"), + environmentId, + projectId, + lineage: { + rootThreadId: parentId, + parentThreadId: parentId, + relationshipToParent: "subagent", + }, + }); + const fork = makeThreadFixture({ + id: ThreadId.make("thread-fork"), + environmentId, + projectId, + lineage: { + rootThreadId: parentId, + parentThreadId: parentId, + relationshipToParent: "fork", + }, + }); + const archived = makeThreadFixture({ + id: ThreadId.make("thread-archived"), + environmentId, + projectId, + archivedAt: "2026-01-02T00:00:00.000Z", + }); + const otherProject = makeThreadFixture({ + id: ThreadId.make("thread-other-project"), + environmentId, + projectId: ProjectId.make("project-other"), + }); + + expect( + filterSidebarV2VisibleThreads( + [root, subagent, fork, archived, otherProject], + new Set([`${environmentId}:${projectId}`]), + ).map((thread) => thread.id), + ).toEqual([parentId, fork.id]); + }); + it("identifies subagent threads so the sidebar can hide them", () => { const parentId = ThreadId.make("thread-parent"); const subagent = makeThreadFixture({ @@ -1452,3 +1503,51 @@ describe("sortLogicalProjectsForSidebar", () => { ).toEqual(["logical-newer", "logical-older"]); }); }); + +describe("sortSidebarV2ProjectGroups", () => { + it("does not let a hidden subagent thread reorder projects", () => { + const olderProjectId = ProjectId.make("project-older"); + const newerProjectId = ProjectId.make("project-newer"); + const olderRootThreadId = ThreadId.make("thread-older-root"); + const projects = [ + { + ...makeProject({ id: olderProjectId, title: "A older project" }), + projectKey: "logical-older", + memberProjectRefs: [{ environmentId: localEnvironmentId, projectId: olderProjectId }], + }, + { + ...makeProject({ id: newerProjectId, title: "Z newer project" }), + projectKey: "logical-newer", + memberProjectRefs: [{ environmentId: localEnvironmentId, projectId: newerProjectId }], + }, + ]; + const threads = [ + makeThread({ + id: olderRootThreadId, + projectId: olderProjectId, + updatedAt: "2026-03-09T10:01:00.000Z", + }), + makeThread({ + id: ThreadId.make("thread-newer-root"), + projectId: newerProjectId, + updatedAt: "2026-03-09T10:05:00.000Z", + }), + makeThread({ + id: ThreadId.make("thread-hidden-subagent"), + projectId: olderProjectId, + updatedAt: "2026-03-09T10:10:00.000Z", + lineage: { + rootThreadId: olderRootThreadId, + parentThreadId: olderRootThreadId, + relationshipToParent: "subagent", + }, + }), + ]; + + expect( + sortSidebarV2ProjectGroups(projects, threads, "updated_at").map( + (project) => project.projectKey, + ), + ).toEqual(["logical-newer", "logical-older"]); + }); +}); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index a917631d1a4..4dcd8906a20 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -96,6 +96,21 @@ export function isSidebarSubagentThread(thread: Pick & { + environmentId: string; + projectId: string; + }, +>(threads: readonly T[], scopedProjectKeys: ReadonlySet | null): T[] { + return threads.filter( + (thread) => + thread.archivedAt === null && + !isSidebarSubagentThread(thread) && + (scopedProjectKeys === null || + scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)), + ); +} + export function getSidebarForkParentThreadId( thread: Pick, ) { @@ -815,6 +830,21 @@ export function sortLogicalProjectsForSidebar< ); } +export function sortSidebarV2ProjectGroups< + TProject extends LogicalSidebarProject, + TThread extends ScopedSidebarThread & Pick, +>( + projects: readonly TProject[], + threads: readonly TThread[], + sortOrder: SidebarProjectSortOrder, +): TProject[] { + return sortLogicalProjectsForSidebar( + projects, + filterSidebarV2VisibleThreads(threads, null), + sortOrder, + ); +} + /** * Sorts the cross-environment project collection used by landing surfaces. * Project ids are only unique within an environment, and archived threads diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index f5261c62e06..4dbda5c8427 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -102,6 +102,7 @@ import type { SidebarThreadSummary } from "../types"; import { cn } from "~/lib/utils"; import { formatWorkingDurationLabel, + filterSidebarV2VisibleThreads, firstValidTimestampMs, hasUnseenCompletion, isTrailingDoubleClick, @@ -111,7 +112,7 @@ import { resolveSidebarV2Status, resolveWorkingStartedAt, shouldNavigateAfterProjectRemoval, - sortLogicalProjectsForSidebar, + sortSidebarV2ProjectGroups, sortSettledThreadsForSidebarV2, sortThreadsForSidebarV2, } from "./Sidebar.logic"; @@ -1104,7 +1105,7 @@ export default function SidebarV2() { ], ); const projectGroups = useMemo( - () => sortLogicalProjectsForSidebar(unsortedProjectGroups, threads, sidebarProjectSortOrder), + () => sortSidebarV2ProjectGroups(unsortedProjectGroups, threads, sidebarProjectSortOrder), [sidebarProjectSortOrder, threads, unsortedProjectGroups], ); const serverProviders = useAtomValue(primaryServerProvidersAtom); @@ -1367,12 +1368,7 @@ export default function SidebarV2() { // memo exactly at the next wake boundary. void snoozeWakeTick; const preciseNow = new Date().toISOString(); - const visible = threads.filter( - (thread) => - thread.archivedAt === null && - (scopedProjectKeys === null || - scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)), - ); + const visible = filterSidebarV2VisibleThreads(threads, scopedProjectKeys); const active: EnvironmentThreadShell[] = []; const snoozed: EnvironmentThreadShell[] = []; const settled: EnvironmentThreadShell[] = [];