From 7e14e2afd95ad4b2b58d5f888240e2f4788d3dcd Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Sat, 19 Sep 2026 00:53:40 -0700 Subject: [PATCH 1/3] Support dragging worktree groups and their member threads --- .../app/src/components/sidebar/ProjectRow.tsx | 48 +++++-- .../useSectionThreadDnd.projection.test.tsx | 83 +++++++++++ .../sidebar/useSectionThreadDnd.test.ts | 113 +++++++++++++++ .../components/sidebar/useSectionThreadDnd.ts | 134 ++++++++++++++++-- .../src/sidebar/projectThreadGroups.ts | 17 ++- .../test/projectThreadGroups.test.ts | 32 +++++ 6 files changed, 403 insertions(+), 24 deletions(-) diff --git a/apps/app/src/components/sidebar/ProjectRow.tsx b/apps/app/src/components/sidebar/ProjectRow.tsx index 552de48614f..2f1a7928eb3 100644 --- a/apps/app/src/components/sidebar/ProjectRow.tsx +++ b/apps/app/src/components/sidebar/ProjectRow.tsx @@ -299,6 +299,7 @@ interface ThreadTreeNodeRowProps { } interface ThreadTreeItemRowProps { + isEnvGrouped?: boolean; projectId: string; item: ProjectThreadItem; depthOffset: number; @@ -354,6 +355,10 @@ function getItemProjectId(item: ProjectThreadItem): string { } interface EnvironmentThreadGroupRowProps { + sectionDnd?: SectionThreadDndState; + dragBindings?: SidebarSortableDragBindings; + sortableRef?: (element: HTMLDivElement | null) => void; + sortableStyle?: CSSProperties; projectId: string; environmentThreadGroup: EnvironmentThreadGroup; depthOffset: number; @@ -381,6 +386,7 @@ interface GetThreadNodeStickyLevelArgs { } interface EnvironmentThreadGroupHeaderProps { + dragBindings?: SidebarSortableDragBindings; environmentId: string; environmentProviderId: string | null; representativeThread: ThreadListEntry; @@ -627,7 +633,7 @@ const SectionDndItemRow = memo(function SectionDndItemRow({ sectionDnd, ...props }: ThreadTreeItemRowProps) { - if (!sectionDnd || props.item.kind === "environment") { + if (!sectionDnd) { return ; } @@ -649,7 +655,7 @@ const DraggableSectionThreadItemRow = memo( disabled: false, displace: false, }); - const isActive = sectionDnd.activeThread?.id === itemId; + const isActive = sectionDnd.activeItemId === itemId; const hasProjectedDestination = sectionDnd.dragOverParentKey !== null || sectionDnd.nestTarget?.state === "valid" || @@ -826,6 +832,7 @@ function EnvironmentThreadGroupHeaderActions({ } function EnvironmentThreadGroupHeader({ + dragBindings, environmentId, environmentProviderId, representativeThread, @@ -990,6 +997,9 @@ function EnvironmentThreadGroupHeader({ if (stickyLevel !== undefined) { return ( +
{content}
); } const EnvironmentThreadGroupRow = memo(function EnvironmentThreadGroupRow({ + sectionDnd, + dragBindings, + sortableRef, + sortableStyle, projectId, environmentThreadGroup, depthOffset, @@ -1069,8 +1090,13 @@ const EnvironmentThreadGroupRow = memo(function EnvironmentThreadGroupRow({ return ( <> - + { }); }); +describe("worktree group drop collisions", () => { + it("keeps an empty Threads destination when hidden group children overlap the pointer", () => { + const rootItems = buildSectionThreadList( + [ + createThread({ + id: "first", + sectionId: "a", + environmentId: "env", + environmentIsWorktree: true, + createdAt: 3, + }), + createThread({ + id: "second", + sectionId: "a", + environmentId: "env", + environmentIsWorktree: true, + createdAt: 2, + }), + createThread({ + id: "child", + sectionId: "a", + environmentId: "env", + environmentIsWorktree: true, + parentThreadId: "second", + }), + ], + undefined, + SECTIONS, + new Set(), + true, + ); + const lookup = collectSectionThreadDndLookup( + rootItems, + CHRONOLOGICAL_CONTAINER_ID, + ); + const activeId = [...lookup.groupThreadsByItemId.keys()][0]; + const { result } = renderSectionThreadDnd(rootItems); + const props = () => result.current!.dndContextProps; + act(() => props().onDragStart?.(dragStart(activeId))); + act(() => props().onDragOver?.(dragOver(activeId, "threads"))); + expect(result.current?.dragOverParentKey).toBe(CHRONOLOGICAL_CONTAINER_ID); + + const sourceRect = { + top: 100, + left: 0, + width: 200, + height: 28, + right: 200, + bottom: 128, + }; + const targetRect = { ...sourceRect, top: 40, bottom: 68 }; + const sourceIds = [ + activeId, + "first", + "second", + "child", + getSidebarThreadRowDroppableId("child"), + ]; + const droppableRects = new Map([ + ...sourceIds.map((id): [string, typeof sourceRect] => [id, sourceRect]), + [CHRONOLOGICAL_CONTAINER_ID, targetRect], + ]); + for (const pointerCoordinates of [ + { x: 20, y: 114 }, + { x: 20, y: 150 }, + ]) { + const collisions = props().collisionDetection!({ + active: { id: activeId }, + collisionRect: sourceRect, + droppableRects, + droppableContainers: [...droppableRects.keys()].map((id) => ({ id })), + pointerCoordinates, + } as unknown as Parameters[0]); + expect(collisions.map(({ id }) => id)).toEqual([ + CHRONOLOGICAL_CONTAINER_ID, + ]); + } + act(() => + props().onDragCancel?.({ active: { id: activeId } } as DragCancelEvent), + ); + }); +}); + describe("useSectionThreadDnd settled drop cleanup", () => { const nestedRootItems = buildSectionThreadList( [ diff --git a/apps/app/src/components/sidebar/useSectionThreadDnd.test.ts b/apps/app/src/components/sidebar/useSectionThreadDnd.test.ts index 813a7a4d13d..8db992f5ab3 100644 --- a/apps/app/src/components/sidebar/useSectionThreadDnd.test.ts +++ b/apps/app/src/components/sidebar/useSectionThreadDnd.test.ts @@ -705,3 +705,116 @@ describe("thread row nest collisions", () => { ).toEqual([groupCollision]); }); }); + +describe("worktree group section dragging", () => { + function groupLookup() { + return collectSectionThreadDndLookup( + buildSectionThreadList( + [ + createThread({ + id: "first", + environmentId: "env", + environmentIsWorktree: true, + sectionId: "a", + createdAt: 10, + }), + createThread({ + id: "second", + environmentId: "env", + environmentIsWorktree: true, + sectionId: "a", + createdAt: 9, + }), + createThread({ + id: "child", + parentThreadId: "first", + environmentId: "env", + environmentIsWorktree: true, + sectionId: "a", + }), + createThread({ + id: "other-section", + environmentId: "env", + environmentIsWorktree: true, + sectionId: "b", + }), + ], + undefined, + [ + { id: "a", name: "A" }, + { id: "b", name: "B" }, + ], + new Set(), + true, + ), + CHRONOLOGICAL_CONTAINER_ID, + ); + } + + it("moves only the represented group, including descendants, to another section", () => { + const lookup = groupLookup(); + const activeId = [...lookup.groupThreadsByItemId.keys()][0]; + const decision = resolveSectionThreadDropDecision( + lookup, + activeId, + "section:b", + ); + expect(decision).toMatchObject({ kind: "move-group", sectionId: "b" }); + expect( + decision?.kind === "move-group" && decision.threadIds.sort(), + ).toEqual(["child", "first", "second"]); + }); + + it("moves the first and other group members independently", () => { + const lookup = groupLookup(); + for (const id of ["first", "second"]) { + expect( + resolveSectionThreadDropDecision(lookup, id, "section:b"), + ).toMatchObject({ kind: "move", activeId: id, sectionId: "b" }); + } + expect( + resolveSectionThreadDropDecision(lookup, "child", "section:b"), + ).toMatchObject({ kind: "detach", activeId: "child", sectionId: "b" }); + expect( + resolveSectionThreadDropDecision( + lookup, + "first", + getSidebarThreadRowDroppableId("child"), + ), + ).toMatchObject({ kind: "rejected", reason: "own-subtree" }); + expect( + resolveSectionThreadDropDecision( + lookup, + "second", + getSidebarThreadRowDroppableId("first"), + ), + ).toMatchObject({ kind: "nest", parentThreadId: "first" }); + expect( + resolveSectionThreadDropDecision(lookup, "child", "pinned"), + ).toMatchObject({ kind: "pin", detach: true }); + }); + + it("allows moving back to Threads and ignores same-section and pinned drops", () => { + const lookup = groupLookup(); + const activeId = [...lookup.groupThreadsByItemId.keys()][0]; + expect( + resolveSectionThreadDropDecision(lookup, activeId, "threads"), + ).toMatchObject({ kind: "move-group", sectionId: null }); + expect( + resolveSectionThreadDropDecision(lookup, activeId, "section:a"), + ).toBeNull(); + expect( + resolveSectionThreadDropDecision(lookup, activeId, "pinned"), + ).toBeNull(); + expect( + resolveSectionThreadDropDecision( + lookup, + activeId, + "section:b", + null, + null, + { groups: true }, + ), + ).toBeNull(); + }); +}); diff --git a/apps/app/src/components/sidebar/useSectionThreadDnd.ts b/apps/app/src/components/sidebar/useSectionThreadDnd.ts index b33beb3b5b7..4dcbd0cb829 100644 --- a/apps/app/src/components/sidebar/useSectionThreadDnd.ts +++ b/apps/app/src/components/sidebar/useSectionThreadDnd.ts @@ -31,6 +31,7 @@ import type { NeighborReorderRequest } from "@bb/client-core"; import { buildSidebarEntitySectionId, getSidebarDndItemId, + getProjectThreadItemDescendants, reorderSidebarSectionOrder, type ProjectThreadItem, type ProjectThreadNode, @@ -89,6 +90,7 @@ export interface SectionThreadReorderTarget { } export interface SectionThreadDndState { + activeItemId: string | null; activeThread: ThreadListEntry | null; consumeClickSuppression: ConsumeDragClickSuppression; dndContextProps: ReorderDndContextProps; @@ -121,6 +123,7 @@ interface UseSectionThreadDndArgs { } interface SectionThreadDndLookup { + groupThreadsByItemId: Map; sectionParentKeyBySectionId: Map; sectionSectionIdByParentKey: Map; sectionIdByParentKey: Map; @@ -133,6 +136,13 @@ interface SectionThreadDndLookup { } export type SectionThreadDropDecision = + | { + kind: "move-group"; + activeId: string; + threadIds: string[]; + sectionId: string | null; + toParentKey: string; + } | { kind: "move"; activeId: string; @@ -229,6 +239,7 @@ export function collectSectionThreadDndLookup( options: CollectSectionThreadDndLookupOptions = {}, ): SectionThreadDndLookup { const lookup: SectionThreadDndLookup = { + groupThreadsByItemId: new Map(), sectionParentKeyBySectionId: new Map([ ["threads", containerId], ["pinned", PINNED_THREAD_PARENT_KEY], @@ -248,19 +259,44 @@ export function collectSectionThreadDndLookup( nestParentIdByItemId: new Map(), }; + const registerNode = ( + node: ProjectThreadNode, + parentKey: string, + nestParentId?: string, + ) => { + const threadId = node.thread.id; + lookup.itemKindById.set(threadId, "thread"); + lookup.parentKeyByItemId.set(threadId, parentKey); + lookup.threadByItemId.set(threadId, node.thread); + lookup.nodeByItemId.set(threadId, node); + if (nestParentId) lookup.nestParentIdByItemId.set(threadId, nestParentId); + registerNestedChildren(node, parentKey); + }; + const registerEnvironment = ( + item: Extract, + parentKey: string, + nestParentId?: string, + ) => { + const itemId = getSidebarDndItemId(item); + lookup.itemKindById.set(itemId, "environment"); + lookup.parentKeyByItemId.set(itemId, parentKey); + lookup.groupThreadsByItemId.set( + itemId, + getProjectThreadItemDescendants([item]), + ); + lookup.threadByItemId.set(itemId, item.group.nodes[0].thread); + for (const node of item.group.nodes) + registerNode(node, parentKey, nestParentId); + }; const registerNestedChildren = ( node: ProjectThreadNode, parentKey: string, ) => { for (const child of node.children) { - if (child.kind !== "thread") continue; - const childId = child.node.thread.id; - lookup.itemKindById.set(childId, "thread"); - lookup.parentKeyByItemId.set(childId, parentKey); - lookup.threadByItemId.set(childId, child.node.thread); - lookup.nodeByItemId.set(childId, child.node); - lookup.nestParentIdByItemId.set(childId, node.thread.id); - registerNestedChildren(child.node, parentKey); + if (child.kind === "thread") + registerNode(child.node, parentKey, node.thread.id); + else if (child.kind === "environment") + registerEnvironment(child, parentKey, node.thread.id); } }; @@ -287,9 +323,9 @@ export function collectSectionThreadDndLookup( lookup.itemKindById.set(itemId, item.kind); lookup.parentKeyByItemId.set(itemId, parentKey); if (item.kind === "thread") { - lookup.threadByItemId.set(itemId, item.node.thread); - lookup.nodeByItemId.set(itemId, item.node); - registerNestedChildren(item.node, parentKey); + registerNode(item.node, parentKey); + } else if (item.kind === "environment") { + registerEnvironment(item, parentKey); } else if (item.kind === "section") { const sectionId = options.groups ? parseGroupSectionId(item.group.key) @@ -562,6 +598,30 @@ export function resolveSectionThreadDropDecision( const fromParentKey = lookup.parentKeyByItemId.get(activeId); if (!activeThread || !fromParentKey) return null; + const groupThreads = lookup.groupThreadsByItemId.get(activeId); + if (groupThreads) { + if (options.groups) return null; + const overThreadId = + overId === null ? null : parseSidebarThreadRowDroppableId(overId); + const toParentKey = + overId === activeId + ? projectedParentKey + : resolveSectionThreadDropParentKey(lookup, overThreadId ?? overId); + if ( + !toParentKey || + toParentKey === fromParentKey || + !lookup.sectionIdByParentKey.has(toParentKey) + ) + return null; + return { + kind: "move-group", + activeId, + threadIds: groupThreads.map((thread) => thread.id), + sectionId: lookup.sectionIdByParentKey.get(toParentKey) ?? null, + toParentKey, + }; + } + const overRowThreadId = overId === null ? null : parseSidebarThreadRowDroppableId(overId); if (overRowThreadId !== null && overRowThreadId !== activeId) { @@ -738,6 +798,7 @@ function resolveTargetParentKey( switch (decision?.kind) { case "pin": return PINNED_THREAD_PARENT_KEY; + case "move-group": case "move": case "detach": case "unpin": @@ -794,6 +855,7 @@ function hasDropDecisionLanded( decision: SectionThreadDropDecision, ): boolean { switch (decision.kind) { + case "move-group": case "move": return ( lookup.parentKeyByItemId.get(decision.activeId) === decision.toParentKey @@ -904,7 +966,12 @@ export function useSectionThreadDnd({ const getNestBandFraction = useCallback( (threadId: string): number | null => { const activeId = activeIdRef.current; - if (activeId === null || threadId === activeId) return null; + if ( + activeId === null || + threadId === activeId || + lookup.groupThreadsByItemId.has(activeId) + ) + return null; if (lookup.itemKindById.get(threadId) !== "thread") return null; const armed = armedNestThreadIdRef.current === threadId; if (coarsePointerRef.current) return 1; @@ -956,7 +1023,27 @@ export function useSectionThreadDnd({ }); } pinnedInsertRef.current = null; - const reorderCollisions = reorderCollisionDetection(args); + const groupThreads = + typeof args.active.id === "string" + ? lookup.groupThreadsByItemId.get(args.active.id) + : undefined; + const groupThreadIds = groupThreads + ? new Set(groupThreads.map((thread) => thread.id)) + : null; + const reorderCollisions = reorderCollisionDetection( + groupThreadIds + ? { + ...args, + droppableContainers: args.droppableContainers.filter(({ id }) => { + if (typeof id !== "string") return true; + const threadId = + parseSidebarThreadRowDroppableId(id) ?? + lookup.threadByItemId.get(id)?.id; + return threadId === undefined || !groupThreadIds.has(threadId); + }), + } + : args, + ); latestRowCollisionRef.current = null; const initialThreadRowRects = initialThreadRowRectsRef.current; if (initialThreadRowRects.size === 0) { @@ -992,6 +1079,7 @@ export function useSectionThreadDnd({ handleResolvedRow, handleRowPointer, holdNestCandidate, + lookup, topLevelSectionIds, ], ); @@ -1122,7 +1210,17 @@ export function useSectionThreadDnd({ clearDropDwell(); clearNestCandidate(); startProjectionInputTracking(); - setActiveThread(thread); + const groupThreads = activeId + ? lookup.groupThreadsByItemId.get(activeId) + : undefined; + setActiveThread( + thread && groupThreads + ? { + ...thread, + title: `${thread.environmentName ?? thread.environmentBranchName ?? "Worktree group"} (${groupThreads.length} threads)`, + } + : thread, + ); setDragOverParentKey(null); setRowDrop(null); setReorderTarget(null); @@ -1301,6 +1399,13 @@ export function useSectionThreadDnd({ return; } switch (decision.kind) { + case "move-group": + void Promise.allSettled( + decision.threadIds.map((id) => + updateThread.mutateAsync({ id, sectionId: decision.sectionId }), + ), + ).finally(clearProjectedDrag); + break; case "move": updateThread.mutate( { @@ -1422,6 +1527,7 @@ export function useSectionThreadDnd({ pendingDropDecision !== null && hasDropDecisionLanded(lookup, pendingDropDecision); return { + activeItemId: dropDecisionLanded ? null : activeIdRef.current, activeThread: dropDecisionLanded ? null : activeThread, consumeClickSuppression, dndContextProps, diff --git a/packages/client-core/src/sidebar/projectThreadGroups.ts b/packages/client-core/src/sidebar/projectThreadGroups.ts index a6916d6c098..0d0b38055f1 100644 --- a/packages/client-core/src/sidebar/projectThreadGroups.ts +++ b/packages/client-core/src/sidebar/projectThreadGroups.ts @@ -537,7 +537,7 @@ export function getSidebarDndItemId(item: ProjectThreadItem): string { case "thread": return item.node.thread.id; case "environment": - return item.group.nodes[0].thread.id; + return `environment:${item.group.nodes[0].thread.id}`; case "section": return item.group.key; } @@ -634,7 +634,20 @@ function bucketIntoSections( } const looseItems: ProjectThreadItem[] = []; - for (const item of items) { + const partitionedItems = items.flatMap((item): ProjectThreadItem[] => { + if (item.kind !== "environment") return [item]; + const nodesBySection = new Map(); + for (const node of item.group.nodes) { + const sectionId = node.thread.sectionId; + const nodes = nodesBySection.get(sectionId) ?? []; + nodes.push(node); + nodesBySection.set(sectionId, nodes); + } + return [...nodesBySection.values()].flatMap((nodes) => + buildSortedItems(nodes, compareThreads, true, draftThreadIds), + ); + }); + for (const item of partitionedItems) { const orderingThread = getItemOrderingThread(item, compareThreads); const sectionId = orderingThread?.sectionId; if (!sectionId) { diff --git a/packages/client-core/test/projectThreadGroups.test.ts b/packages/client-core/test/projectThreadGroups.test.ts index 6922cfce00e..e6fca3b6dda 100644 --- a/packages/client-core/test/projectThreadGroups.test.ts +++ b/packages/client-core/test/projectThreadGroups.test.ts @@ -669,6 +669,38 @@ describe("worktree grouping preference", () => { ]); }); + it("keeps moved members outside the remaining worktree group", () => { + const items = buildSectionThreadList( + [ + ...worktreeSiblings, + createThread({ + id: "moved", + environmentId: "env_wt", + environmentIsWorktree: true, + sectionId: "review", + }), + createThread({ + id: "loose", + environmentId: "env_wt", + environmentIsWorktree: true, + }), + ], + compareStandardThreads, + [...sections, { id: "review", name: "Review" }], + new Set(), + true, + ); + expect(summarizeItems(items)).toEqual([ + { + section: "chronological::sec_work", + name: "Work", + items: [{ env: "env_wt", threads: ["wt-b", "wt-a"] }], + }, + { section: "chronological::review", name: "Review", items: ["moved"] }, + "loose", + ]); + }); + it("keeps worktree siblings flat inside a section when disabled", () => { const items = buildSectionThreadList( worktreeSiblings, From 324a053ffcbcc42c18b53abb3ba406159b6ce412 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Fri, 18 Sep 2026 22:28:09 -0700 Subject: [PATCH 2/3] Fix environment grouping across custom sidebar sections --- .../src/sidebar/projectThreadGroups.ts | 39 +++++---- .../test/projectThreadGroups.test.ts | 85 ++++++++++++++++--- 2 files changed, 98 insertions(+), 26 deletions(-) diff --git a/packages/client-core/src/sidebar/projectThreadGroups.ts b/packages/client-core/src/sidebar/projectThreadGroups.ts index 0d0b38055f1..67c4f813bb0 100644 --- a/packages/client-core/src/sidebar/projectThreadGroups.ts +++ b/packages/client-core/src/sidebar/projectThreadGroups.ts @@ -211,7 +211,24 @@ function buildSortedItems( compareThreads: ThreadComparator, groupEnvironmentThreads: boolean, draftThreadIds: ReadonlySet, + respectSections = false, ): ProjectThreadItem[] { + if (groupEnvironmentThreads && respectSections) { + const nodesBySectionId = new Map(); + for (const node of nodes) { + const sectionId = node.thread.sectionId; + const bucket = nodesBySectionId.get(sectionId); + if (bucket) { + bucket.push(node); + } else { + nodesBySectionId.set(sectionId, [node]); + } + } + return [...nodesBySectionId.values()].flatMap((sectionNodes) => + buildSortedItems(sectionNodes, compareThreads, true, draftThreadIds), + ); + } + if (!groupEnvironmentThreads) { nodes.sort((left, right) => compareThreads(left.thread, right.thread)); return nodes.map(buildThreadItem); @@ -353,6 +370,7 @@ function buildThreadTreeItems( compareThreads: ThreadComparator, groupEnvironmentThreads: boolean, draftThreadIds: ReadonlySet, + respectSections = false, ): ProjectThreadItem[] { const projectThreads = allThreads.filter(isSidebarProjectThread); const projectThreadIds = new Set(projectThreads.map((thread) => thread.id)); @@ -413,6 +431,7 @@ function buildThreadTreeItems( compareThreads, groupEnvironmentThreads, draftThreadIds, + respectSections, ); } @@ -438,11 +457,12 @@ export function buildSectionThreadList( groupEnvironmentThreads = false, ): ProjectThreadItem[] { return bucketIntoSections( - buildChronologicalThreadList( + buildThreadTreeItems( allThreads, compareThreads, - draftThreadIds, groupEnvironmentThreads, + draftThreadIds, + true, ), CHRONOLOGICAL_CONTAINER_ID, compareThreads, @@ -634,20 +654,7 @@ function bucketIntoSections( } const looseItems: ProjectThreadItem[] = []; - const partitionedItems = items.flatMap((item): ProjectThreadItem[] => { - if (item.kind !== "environment") return [item]; - const nodesBySection = new Map(); - for (const node of item.group.nodes) { - const sectionId = node.thread.sectionId; - const nodes = nodesBySection.get(sectionId) ?? []; - nodes.push(node); - nodesBySection.set(sectionId, nodes); - } - return [...nodesBySection.values()].flatMap((nodes) => - buildSortedItems(nodes, compareThreads, true, draftThreadIds), - ); - }); - for (const item of partitionedItems) { + for (const item of items) { const orderingThread = getItemOrderingThread(item, compareThreads); const sectionId = orderingThread?.sectionId; if (!sectionId) { diff --git a/packages/client-core/test/projectThreadGroups.test.ts b/packages/client-core/test/projectThreadGroups.test.ts index e6fca3b6dda..ff39a26160a 100644 --- a/packages/client-core/test/projectThreadGroups.test.ts +++ b/packages/client-core/test/projectThreadGroups.test.ts @@ -669,24 +669,76 @@ describe("worktree grouping preference", () => { ]); }); - it("keeps moved members outside the remaining worktree group", () => { - const items = buildSectionThreadList( - [ + it.each([false, true])( + "respects sections with environment grouping %s", + (groupEnvironmentThreads) => { + const threads = [ ...worktreeSiblings, createThread({ - id: "moved", + id: "later", environmentId: "env_wt", environmentIsWorktree: true, - sectionId: "review", + sectionId: "sec_later", + createdAt: 30, }), createThread({ id: "loose", environmentId: "env_wt", environmentIsWorktree: true, + createdAt: 40, }), - ], + ]; + const items = buildSectionThreadList( + threads, + compareStandardThreads, + [...sections, { id: "sec_later", name: "Later" }], + new Set(), + groupEnvironmentThreads, + ); + expect(summarizeItems(items)).toEqual([ + { + section: "chronological::sec_work", + name: "Work", + items: groupEnvironmentThreads + ? [{ env: "env_wt", threads: ["wt-b", "wt-a"] }] + : ["wt-b", "wt-a"], + }, + { + section: "chronological::sec_later", + name: "Later", + items: ["later"], + }, + "loose", + ]); + expect(summarizeItems(buildProjectThreadGroups(threads))).toEqual([ + { env: "env_wt", threads: ["loose", "later", "wt-b", "wt-a"] }, + ]); + }, + ); + + it("keeps separate environment groups and their descendants in each section", () => { + const threads = [ + ...worktreeSiblings, + createThread({ + id: "later-a", + environmentId: "env_wt", + environmentIsWorktree: true, + sectionId: "sec_later", + createdAt: 30, + }), + createThread({ + id: "later-b", + environmentId: "env_wt", + environmentIsWorktree: true, + sectionId: "sec_later", + createdAt: 40, + }), + createThread({ id: "child", parentThreadId: "wt-a", createdAt: 50 }), + ]; + const items = buildSectionThreadList( + threads, compareStandardThreads, - [...sections, { id: "review", name: "Review" }], + sections, new Set(), true, ); @@ -694,11 +746,24 @@ describe("worktree grouping preference", () => { { section: "chronological::sec_work", name: "Work", - items: [{ env: "env_wt", threads: ["wt-b", "wt-a"] }], + items: [ + { + env: "env_wt", + threads: ["wt-b", { id: "wt-a", children: ["child"] }], + }, + ], + }, + { + section: "chronological::sec_later", + name: "Section", + items: [{ env: "env_wt", threads: ["later-b", "later-a"] }], }, - { section: "chronological::review", name: "Review", items: ["moved"] }, - "loose", ]); + expect( + items.map((item) => + item.kind === "section" ? item.group.threadCount : 0, + ), + ).toEqual([3, 2]); }); it("keeps worktree siblings flat inside a section when disabled", () => { From f7a5dcbfe978156804b4372cb18a8886a0ac0373 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Mon, 21 Sep 2026 09:34:07 -0700 Subject: [PATCH 3/3] Account for worktree group drag bundle cost --- apps/app/bundle-budget.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/app/bundle-budget.json b/apps/app/bundle-budget.json index 10390fa8478..62b6b128c9f 100644 --- a/apps/app/bundle-budget.json +++ b/apps/app/bundle-budget.json @@ -47,7 +47,7 @@ "`node scripts/why-eager.mjs --from=views/SplitWorkspaceRoute.tsx `", "to print the static chain that pulled a package into the closure." ], - "maxBootBytes": 1737248, + "maxBootBytes": 1739296, "maxBootBrotliBytes": 429072, "forbiddenBootPackages": [ "@pierre/diffs",