diff --git a/apps/app/src/components/sidebar/PinnedThreadTree.tsx b/apps/app/src/components/sidebar/PinnedThreadTree.tsx index 697a8a382..6c5ba39fd 100644 --- a/apps/app/src/components/sidebar/PinnedThreadTree.tsx +++ b/apps/app/src/components/sidebar/PinnedThreadTree.tsx @@ -89,7 +89,10 @@ interface SortablePinnedRootItemProps { selectedThreadId?: string; } -interface PinnedRootItemProps extends Omit { +interface PinnedRootItemProps extends Omit< + SortablePinnedRootItemProps, + "disabled" +> { consumeClickSuppression?: () => boolean; } @@ -171,6 +174,7 @@ const PinnedRootItem = memo(function PinnedRootItem({ selectedThreadId={selectedThreadId} variant="section" isManagerCollapsed={collapsedManagerIds.has(item.group.managerThread.id)} + collapsedManagerIds={collapsedManagerIds} collapsedEnvironmentIds={collapsedEnvironmentIds} onProjectSelect={onProjectSelect} onToggleManagerCollapsed={onToggleManagerCollapsed} @@ -242,6 +246,7 @@ const SortablePinnedRootItem = memo(function SortablePinnedRootItem({ selectedThreadId={selectedThreadId} variant="section" isManagerCollapsed={collapsedManagerIds.has(item.group.managerThread.id)} + collapsedManagerIds={collapsedManagerIds} collapsedEnvironmentIds={collapsedEnvironmentIds} onProjectSelect={onProjectSelect} onToggleManagerCollapsed={onToggleManagerCollapsed} @@ -265,8 +270,9 @@ export const PinnedThreadTree = memo(function PinnedThreadTree({ isPinnedReorderPending = false, onReorderPinnedRoot, }: PinnedThreadTreeProps) { - const [optimisticPinnedRootOrder, setOptimisticPinnedRootOrder] = - useState(null); + const [optimisticPinnedRootOrder, setOptimisticPinnedRootOrder] = useState< + PinnedRootOrderEntry[] | null + >(null); const renderedRootItems = useMemo(() => { if (!optimisticPinnedRootOrder) { return rootItems; @@ -289,7 +295,9 @@ export const PinnedThreadTree = memo(function PinnedThreadTree({ [renderedRootItems], ); const reorderDisabled = - isPinnedReorderPending || !onReorderPinnedRoot || renderedRootItems.length < 2; + isPinnedReorderPending || + !onReorderPinnedRoot || + renderedRootItems.length < 2; const { beginDragClickSuppression, clearDragClickSuppressionSoon, @@ -306,9 +314,12 @@ export const PinnedThreadTree = memo(function PinnedThreadTree({ coordinateGetter: sortableKeyboardCoordinates, }), ); - const handleDragStart = useCallback((_event: DragStartEvent) => { - beginDragClickSuppression(); - }, [beginDragClickSuppression]); + const handleDragStart = useCallback( + (_event: DragStartEvent) => { + beginDragClickSuppression(); + }, + [beginDragClickSuppression], + ); const handleDragCancel = useCallback(() => { clearDragClickSuppressionSoon(); }, [clearDragClickSuppressionSoon]); diff --git a/apps/app/src/components/sidebar/ProjectRow.tsx b/apps/app/src/components/sidebar/ProjectRow.tsx index 789c86e18..320494453 100644 --- a/apps/app/src/components/sidebar/ProjectRow.tsx +++ b/apps/app/src/components/sidebar/ProjectRow.tsx @@ -92,17 +92,17 @@ import { } from "./projectThreadGroups"; import { SIDEBAR_MANAGED_ENV_GROUP_LINE_CLASS, - SIDEBAR_MANAGER_CHILD_ROW_PADDING_CLASS, SIDEBAR_MANAGER_GROUP_LINE_CLASS, SIDEBAR_MANAGER_LINE_CONTINUATION_CLASS, SIDEBAR_MANAGER_ROW_PADDING_CLASS, SIDEBAR_PROJECT_GROUP_LINE_CLASS, - SIDEBAR_PROJECT_THREAD_ROW_PADDING_CLASS, SIDEBAR_ROW_BASE_CLASS, SIDEBAR_ROW_INTERACTIVE_STATE_CLASS, SIDEBAR_SECTION_GROUP_LINE_CLASS, SIDEBAR_SECTION_LINE_CONTINUATION_CLASS, SIDEBAR_STANDARD_ROW_PADDING_CLASS, + getSidebarThreadRowPaddingClass, + type SidebarThreadRowIndent, } from "./sidebarRowClasses"; import { SIDEBAR_SORTABLE_TRANSITION } from "./sortableMotion"; import { @@ -225,6 +225,8 @@ type ProjectThreadListClickCaptureHandler = MouseEventHandler; const EMPTY_PROJECT_THREADS: ThreadListEntry[] = []; const PROJECT_ROW_LEADING_SLOT_CLASS = "h-7 w-8 max-md:pointer-coarse:h-10 max-md:pointer-coarse:w-10"; +const SIDEBAR_DEEP_MANAGER_LINE_CONTINUATION_CLASS = + "pointer-events-none absolute -bottom-0.5 left-16 top-0 z-[1] w-px bg-border-hairline"; interface ProjectThreadTreeGroupProps { children: ReactNode; @@ -311,12 +313,44 @@ function getProjectThreadTreeDefaultThreadOptions( : THREAD_ROW_PROJECT_DEFAULT_OPTIONS; } +type ManagerThreadGroupPlacement = "root" | "managed-child"; + +function getProjectThreadTreeManagedChildIndent( + variant: ProjectThreadTreeVariant, + placement: ManagerThreadGroupPlacement, +): SidebarThreadRowIndent { + if (placement === "managed-child") { + return variant === "section" ? "nested-child" : "deep-child"; + } + + return variant === "section" ? "project-child" : "nested-child"; +} + +function getProjectThreadTreeManagerIndent( + variant: ProjectThreadTreeVariant, + placement: ManagerThreadGroupPlacement, +): SidebarThreadRowIndent { + if (placement === "managed-child") { + return getProjectThreadTreeManagedChildIndent(variant, "root"); + } + + return variant === "section" ? "root" : "project-child"; +} + function getProjectThreadTreeManagedChildOptions( variant: ProjectThreadTreeVariant, + placement: ManagerThreadGroupPlacement, ): ThreadRowOptions { - return variant === "section" - ? THREAD_ROW_SECTION_MANAGED_CHILD_OPTIONS - : THREAD_ROW_PROJECT_MANAGED_CHILD_OPTIONS; + if (placement === "root") { + return variant === "section" + ? THREAD_ROW_SECTION_MANAGED_CHILD_OPTIONS + : THREAD_ROW_PROJECT_MANAGED_CHILD_OPTIONS; + } + + return { + kind: "managed-child", + indent: getProjectThreadTreeManagedChildIndent(variant, placement), + }; } function getProjectThreadTreeEnvGroupedChildOptions( @@ -329,23 +363,39 @@ function getProjectThreadTreeEnvGroupedChildOptions( function getProjectThreadTreeEnvGroupedManagedChildOptions( variant: ProjectThreadTreeVariant, + placement: ManagerThreadGroupPlacement, ): ThreadRowOptions { - return variant === "section" - ? THREAD_ROW_SECTION_ENV_GROUPED_MANAGED_CHILD_OPTIONS - : THREAD_ROW_PROJECT_ENV_GROUPED_MANAGED_CHILD_OPTIONS; + if (placement === "root") { + return variant === "section" + ? THREAD_ROW_SECTION_ENV_GROUPED_MANAGED_CHILD_OPTIONS + : THREAD_ROW_PROJECT_ENV_GROUPED_MANAGED_CHILD_OPTIONS; + } + + return { + kind: "env-grouped-managed-child", + indent: "deep-child", + }; } function getProjectThreadTreeManagedEnvHeaderPaddingClass( variant: ProjectThreadTreeVariant, + placement: ManagerThreadGroupPlacement, ): string { - return variant === "section" - ? SIDEBAR_PROJECT_THREAD_ROW_PADDING_CLASS - : SIDEBAR_MANAGER_CHILD_ROW_PADDING_CLASS; + return getSidebarThreadRowPaddingClass( + getProjectThreadTreeManagedChildIndent(variant, placement), + ); } function getProjectThreadTreeChildGroupLineClassName( variant: ProjectThreadTreeVariant, + placement: ManagerThreadGroupPlacement = "root", ): string { + if (placement === "managed-child") { + return variant === "section" + ? SIDEBAR_MANAGER_GROUP_LINE_CLASS + : SIDEBAR_MANAGED_ENV_GROUP_LINE_CLASS; + } + return variant === "section" ? SIDEBAR_SECTION_GROUP_LINE_CLASS : SIDEBAR_MANAGER_GROUP_LINE_CLASS; @@ -353,7 +403,12 @@ function getProjectThreadTreeChildGroupLineClassName( function getProjectThreadTreeManagedEnvGroupLineClassName( variant: ProjectThreadTreeVariant, + placement: ManagerThreadGroupPlacement, ): string { + if (placement === "managed-child") { + return SIDEBAR_MANAGED_ENV_GROUP_LINE_CLASS; + } + return variant === "section" ? SIDEBAR_MANAGER_GROUP_LINE_CLASS : SIDEBAR_MANAGED_ENV_GROUP_LINE_CLASS; @@ -361,7 +416,14 @@ function getProjectThreadTreeManagedEnvGroupLineClassName( function getProjectThreadTreeManagerLineContinuationClassName( variant: ProjectThreadTreeVariant, + placement: ManagerThreadGroupPlacement, ): string { + if (placement === "managed-child") { + return variant === "section" + ? SIDEBAR_MANAGER_LINE_CONTINUATION_CLASS + : SIDEBAR_DEEP_MANAGER_LINE_CONTINUATION_CLASS; + } + return variant === "section" ? SIDEBAR_SECTION_LINE_CONTINUATION_CLASS : SIDEBAR_MANAGER_LINE_CONTINUATION_CLASS; @@ -391,8 +453,10 @@ export interface ManagerThreadGroupRowProps { managerThreadGroup: ManagerThreadGroup; selectedThreadId?: string; isManagerCollapsed: boolean; + collapsedManagerIds: Set; collapsedEnvironmentIds: Set; variant: ProjectThreadTreeVariant; + placement?: ManagerThreadGroupPlacement; onProjectSelect?: () => void; onToggleManagerCollapsed: (threadId: string) => void; onToggleEnvironmentCollapsed: (environmentId: string) => void; @@ -762,6 +826,7 @@ interface ManagedEnvironmentThreadSubGroupProps { selectedThreadId?: string; isCollapsed: boolean; variant: ProjectThreadTreeVariant; + managerPlacement: ManagerThreadGroupPlacement; onProjectSelect?: () => void; onToggleEnvironmentCollapsed: (environmentId: string) => void; } @@ -772,6 +837,7 @@ function ManagedEnvironmentThreadSubGroup({ selectedThreadId, isCollapsed, variant, + managerPlacement, onProjectSelect, onToggleEnvironmentCollapsed, }: ManagedEnvironmentThreadSubGroupProps) { @@ -795,10 +861,14 @@ function ManagedEnvironmentThreadSubGroup({ {threads.map((thread) => ( @@ -824,6 +897,7 @@ function ManagedEnvironmentThreadSubGroup({ onProjectSelect={onProjectSelect} options={getProjectThreadTreeEnvGroupedManagedChildOptions( variant, + managerPlacement, )} /> ))} @@ -838,8 +912,10 @@ export const ManagerThreadGroupRow = memo(function ManagerThreadGroupRow({ managerThreadGroup, selectedThreadId, isManagerCollapsed, + collapsedManagerIds, collapsedEnvironmentIds, variant, + placement = "root", onProjectSelect, onToggleManagerCollapsed, onToggleEnvironmentCollapsed, @@ -854,7 +930,7 @@ export const ManagerThreadGroupRow = memo(function ManagerThreadGroupRow({ const managerOptions = useMemo( () => ({ kind: "manager", - indent: variant === "section" ? "root" : "project-child", + indent: getProjectThreadTreeManagerIndent(variant, placement), isCollapsed: isManagerCollapsed, nestedChildCount, managedChildActivity: stats.managedChildActivity, @@ -868,6 +944,7 @@ export const ManagerThreadGroupRow = memo(function ManagerThreadGroupRow({ isManagerCollapsed, nestedChildCount, onToggleManagerCollapsed, + placement, stats.managedChildActivity, variant, ], @@ -893,7 +970,7 @@ export const ManagerThreadGroupRow = memo(function ManagerThreadGroupRow({
{managedItems.map((item) => @@ -904,7 +981,28 @@ export const ManagerThreadGroupRow = memo(function ManagerThreadGroupRow({ thread={item.thread} isActive={selectedThreadId === item.thread.id} onProjectSelect={onProjectSelect} - options={getProjectThreadTreeManagedChildOptions(variant)} + options={getProjectThreadTreeManagedChildOptions( + variant, + placement, + )} + /> + ) : item.kind === "manager" ? ( + ) : ( @@ -1185,6 +1284,7 @@ export const ProjectThreadTree = memo(function ProjectThreadTree({ isManagerCollapsed={collapsedManagerIds.has( managerThreadGroup.managerThread.id, )} + collapsedManagerIds={collapsedManagerIds} collapsedEnvironmentIds={collapsedEnvironmentIds} onProjectSelect={onProjectSelect} onToggleManagerCollapsed={onToggleManagerCollapsed} @@ -1205,6 +1305,7 @@ export const ProjectThreadTree = memo(function ProjectThreadTree({ isManagerCollapsed={collapsedManagerIds.has( managerThreadGroup.managerThread.id, )} + collapsedManagerIds={collapsedManagerIds} collapsedEnvironmentIds={collapsedEnvironmentIds} onProjectSelect={onProjectSelect} onToggleManagerCollapsed={onToggleManagerCollapsed} @@ -1223,6 +1324,23 @@ export const ProjectThreadTree = memo(function ProjectThreadTree({ onProjectSelect={onProjectSelect} options={getProjectThreadTreeDefaultThreadOptions(variant)} /> + ) : item.kind === "manager" ? ( + ) : ( ; @@ -39,6 +40,31 @@ function createThread( }; } +type ManagedItemSummary = + | string + | { env: string; threads: string[] } + | { manager: string; items: ManagedItemSummary[] }; + +function summarizeManagedItems( + items: readonly ProjectThreadItem[], +): ManagedItemSummary[] { + return items.map((item) => { + if (item.kind === "thread") { + return item.thread.id; + } + if (item.kind === "manager") { + return { + manager: item.group.managerThread.id, + items: summarizeManagedItems(item.group.managedItems), + }; + } + return { + env: item.group.environmentId, + threads: item.group.threads.map((thread) => thread.id), + }; + }); +} + describe("buildPinnedSidebarState", () => { it("sorts visible pinned roots by global pin sort key", () => { const state = buildPinnedSidebarState({ @@ -179,4 +205,43 @@ describe("buildPinnedSidebarState", () => { expect(item.group.managerThread.id).toBe("manager"); expect(item.group.stats.managedChildCount).toBe(1); }); + + it("keeps an explicitly pinned manager child under its pinned manager root", () => { + const state = buildPinnedSidebarState({ + threads: [ + createThread({ + id: "parent-manager", + type: "manager", + pinnedAt: 2_000, + pinSortKey: "a", + }), + createThread({ + id: "child-manager", + type: "manager", + parentThreadId: "parent-manager", + pinnedAt: 1_000, + pinSortKey: "b", + }), + createThread({ + id: "nested-standard", + parentThreadId: "child-manager", + }), + ], + }); + + expect([...state.effectivePinnedThreadIds].sort()).toEqual([ + "child-manager", + "nested-standard", + "parent-manager", + ]); + expect(state.rootItems).toHaveLength(1); + const item = state.rootItems[0]; + if (!item || item.kind !== "manager") { + throw new Error("Expected pinned manager root item"); + } + expect(item.group.managerThread.id).toBe("parent-manager"); + expect(summarizeManagedItems(item.group.managedItems)).toEqual([ + { manager: "child-manager", items: ["nested-standard"] }, + ]); + }); }); diff --git a/apps/app/src/components/sidebar/pinnedSidebarThreads.ts b/apps/app/src/components/sidebar/pinnedSidebarThreads.ts index 356170348..2c514ce23 100644 --- a/apps/app/src/components/sidebar/pinnedSidebarThreads.ts +++ b/apps/app/src/components/sidebar/pinnedSidebarThreads.ts @@ -20,10 +20,22 @@ interface BuildPinnedSidebarStateArgs { } interface BuildPinnedManagerGroupArgs { - children: readonly ThreadListEntry[]; + descendants: readonly ThreadListEntry[]; managerThread: ThreadListEntry; } +interface CollectManagedDescendantsArgs { + childrenByManagerId: ReadonlyMap; + managerThreadId: string; + visitedManagerIds: ReadonlySet; +} + +interface HasPinnedManagerAncestorArgs { + pinnedManagerThreadIds: ReadonlySet; + thread: ThreadListEntry; + threadsById: ReadonlyMap; +} + function compareByPinnedFallback( left: ThreadListEntry, right: ThreadListEntry, @@ -56,10 +68,10 @@ function comparePinnedRoots( } function buildPinnedManagerGroup({ - children, + descendants, managerThread, }: BuildPinnedManagerGroupArgs): ManagerThreadGroup { - const groups = buildProjectThreadGroups([managerThread, ...children]); + const groups = buildProjectThreadGroups([managerThread, ...descendants]); const group = groups.managerThreadGroups[0]; if (group) { return group; @@ -75,9 +87,62 @@ function buildPinnedManagerGroup({ }; } +function collectManagedDescendants({ + childrenByManagerId, + managerThreadId, + visitedManagerIds, +}: CollectManagedDescendantsArgs): ThreadListEntry[] { + if (visitedManagerIds.has(managerThreadId)) { + return []; + } + + const nextVisitedManagerIds = new Set(visitedManagerIds); + nextVisitedManagerIds.add(managerThreadId); + const descendants: ThreadListEntry[] = []; + + for (const child of childrenByManagerId.get(managerThreadId) ?? []) { + descendants.push(child); + if (child.type === "manager") { + descendants.push( + ...collectManagedDescendants({ + childrenByManagerId, + managerThreadId: child.id, + visitedManagerIds: nextVisitedManagerIds, + }), + ); + } + } + + return descendants; +} + +function hasPinnedManagerAncestor({ + pinnedManagerThreadIds, + thread, + threadsById, +}: HasPinnedManagerAncestorArgs): boolean { + const visitedThreadIds = new Set(); + let parentThreadId = thread.parentThreadId; + + while (parentThreadId !== null) { + if (visitedThreadIds.has(parentThreadId)) { + return false; + } + if (pinnedManagerThreadIds.has(parentThreadId)) { + return true; + } + + visitedThreadIds.add(parentThreadId); + parentThreadId = threadsById.get(parentThreadId)?.parentThreadId ?? null; + } + + return false; +} + export function buildPinnedSidebarState({ threads, }: BuildPinnedSidebarStateArgs): PinnedSidebarState { + const threadsById = new Map(threads.map((thread) => [thread.id, thread])); const explicitlyPinnedThreads = threads.filter( (thread) => thread.pinnedAt !== null, ); @@ -89,7 +154,7 @@ export function buildPinnedSidebarState({ const childrenByManagerId = new Map(); for (const thread of threads) { - if (thread.type !== "standard" || thread.parentThreadId === null) { + if (thread.parentThreadId === null) { continue; } const managerChildren = childrenByManagerId.get(thread.parentThreadId); @@ -104,16 +169,23 @@ export function buildPinnedSidebarState({ explicitlyPinnedThreads.map((thread) => thread.id), ); for (const managerThreadId of pinnedManagerThreadIds) { - for (const child of childrenByManagerId.get(managerThreadId) ?? []) { - effectivePinnedThreadIds.add(child.id); + for (const descendant of collectManagedDescendants({ + childrenByManagerId, + managerThreadId, + visitedManagerIds: new Set(), + })) { + effectivePinnedThreadIds.add(descendant.id); } } const visiblePinnedRoots = explicitlyPinnedThreads .filter( (thread) => - thread.parentThreadId === null || - !pinnedManagerThreadIds.has(thread.parentThreadId), + !hasPinnedManagerAncestor({ + pinnedManagerThreadIds, + thread, + threadsById, + }), ) .sort(comparePinnedRoots); @@ -124,7 +196,11 @@ export function buildPinnedSidebarState({ ? { kind: "manager", group: buildPinnedManagerGroup({ - children: childrenByManagerId.get(thread.id) ?? [], + descendants: collectManagedDescendants({ + childrenByManagerId, + managerThreadId: thread.id, + visitedManagerIds: new Set(), + }), managerThread: thread, }), } diff --git a/apps/app/src/components/sidebar/projectThreadGroups.test.ts b/apps/app/src/components/sidebar/projectThreadGroups.test.ts index ff655359f..0b3b75232 100644 --- a/apps/app/src/components/sidebar/projectThreadGroups.test.ts +++ b/apps/app/src/components/sidebar/projectThreadGroups.test.ts @@ -42,25 +42,37 @@ function createThread( }; } -type ItemSummary = string | { env: string; threads: string[] }; +type ItemSummary = + | string + | { env: string; threads: string[] } + | { manager: string; items: ItemSummary[] }; function summarizeItems(items: readonly ProjectThreadItem[]): ItemSummary[] { - return items.map((item) => - item.kind === "thread" - ? item.thread.id - : { - env: item.group.environmentId, - threads: item.group.threads.map((thread) => thread.id), - }, - ); + return items.map((item) => { + if (item.kind === "thread") { + return item.thread.id; + } + if (item.kind === "manager") { + return { + manager: item.group.managerThread.id, + items: summarizeItems(item.group.managedItems), + }; + } + return { + env: item.group.environmentId, + threads: item.group.threads.map((thread) => thread.id), + }; + }); } function looseThreadIds(items: readonly ProjectThreadItem[]): string[] { return items.map((item) => { if (item.kind !== "thread") { - throw new Error( - `expected thread item, got env group ${item.group.environmentId}`, - ); + const itemDescription = + item.kind === "environment" + ? `env group ${item.group.environmentId}` + : `manager group ${item.group.managerThread.id}`; + throw new Error(`expected thread item, got ${itemDescription}`); } return item.thread.id; }); @@ -137,6 +149,54 @@ describe("buildProjectThreadGroups", () => { ]); }); + it("nests manager threads assigned to another manager", () => { + const groups = buildProjectThreadGroups([ + createThread({ + id: "parent-manager", + type: "manager", + createdAt: 100, + latestAttentionAt: 100, + }), + createThread({ + id: "child-manager", + type: "manager", + parentThreadId: "parent-manager", + createdAt: 90, + latestAttentionAt: 90, + }), + createThread({ + id: "nested-standard", + parentThreadId: "child-manager", + createdAt: 80, + latestAttentionAt: 80, + }), + createThread({ + id: "parent-standard", + parentThreadId: "parent-manager", + createdAt: 70, + latestAttentionAt: 70, + }), + createThread({ + id: "orphan-manager", + type: "manager", + parentThreadId: "missing-manager", + createdAt: 60, + latestAttentionAt: 60, + }), + ]); + + expect( + groups.managerThreadGroups.map((group) => group.managerThread.id), + ).toEqual(["parent-manager", "orphan-manager"]); + expect( + summarizeItems(groups.managerThreadGroups[0]?.managedItems ?? []), + ).toEqual([ + { manager: "child-manager", items: ["nested-standard"] }, + "parent-standard", + ]); + expect(groups.managerThreadGroups[0]?.stats.managedChildCount).toBe(2); + }); + it("sorts unmanaged standard threads with active rows before inactive attention recency", () => { const groups = buildProjectThreadGroups([ createThread({ diff --git a/apps/app/src/components/sidebar/projectThreadGroups.ts b/apps/app/src/components/sidebar/projectThreadGroups.ts index 7d29a5f97..3bc56adc6 100644 --- a/apps/app/src/components/sidebar/projectThreadGroups.ts +++ b/apps/app/src/components/sidebar/projectThreadGroups.ts @@ -23,7 +23,8 @@ export interface EnvironmentThreadGroup { // rather than two parallel arrays. export type ProjectThreadItem = | { kind: "thread"; thread: ThreadListEntry } - | { kind: "environment"; group: EnvironmentThreadGroup }; + | { kind: "environment"; group: EnvironmentThreadGroup } + | { kind: "manager"; group: ManagerThreadGroup }; export interface ManagerThreadGroup { managerThread: ThreadListEntry; @@ -49,6 +50,18 @@ interface KnownManagerParentArgs { thread: ThreadListEntry; } +interface BuildManagerThreadGroupArgs { + childrenByManagerId: ReadonlyMap; + managerThread: ThreadListEntry; + visitedManagerIds: ReadonlySet; +} + +interface BuildSortedProjectThreadItemsArgs { + childrenByManagerId: ReadonlyMap; + threads: readonly ThreadListEntry[]; + visitedManagerIds: ReadonlySet; +} + function compareByCreatedAtDescending( left: ThreadListEntry, right: ThreadListEntry, @@ -97,7 +110,9 @@ function compareStandardThreads( } function representativeThread(item: ProjectThreadItem): ThreadListEntry { - return item.kind === "thread" ? item.thread : item.group.threads[0]; + if (item.kind === "thread") return item.thread; + if (item.kind === "manager") return item.group.managerThread; + return item.group.threads[0]; } function compareProjectThreadItems( @@ -110,7 +125,9 @@ function compareProjectThreadItems( ); } -function buildSortedItems(threads: ThreadListEntry[]): ProjectThreadItem[] { +function buildSortedItems( + threads: readonly ThreadListEntry[], +): ProjectThreadItem[] { const { environmentThreadGroups, looseThreads } = bucketWorktreeEnvironmentGroups(threads); const items: ProjectThreadItem[] = [ @@ -128,13 +145,70 @@ function getKnownManagerParentId({ managerThreadIds, thread, }: KnownManagerParentArgs): string | null { - if (thread.type !== "standard") return null; if (thread.parentThreadId === null) return null; + if (thread.parentThreadId === thread.id) return null; if (!managerThreadIds.has(thread.parentThreadId)) return null; return thread.parentThreadId; } +function buildSortedProjectThreadItems({ + childrenByManagerId, + threads, + visitedManagerIds, +}: BuildSortedProjectThreadItemsArgs): ProjectThreadItem[] { + const leafThreads: ThreadListEntry[] = []; + const managerItems: ProjectThreadItem[] = []; + + for (const thread of threads) { + if (thread.type !== "manager") { + leafThreads.push(thread); + continue; + } + + if (visitedManagerIds.has(thread.id)) { + leafThreads.push(thread); + continue; + } + + managerItems.push({ + kind: "manager", + group: buildManagerThreadGroup({ + childrenByManagerId, + managerThread: thread, + visitedManagerIds, + }), + }); + } + + const items = [...buildSortedItems(leafThreads), ...managerItems]; + items.sort(compareProjectThreadItems); + return items; +} + +function buildManagerThreadGroup({ + childrenByManagerId, + managerThread, + visitedManagerIds, +}: BuildManagerThreadGroupArgs): ManagerThreadGroup { + const nextVisitedManagerIds = new Set(visitedManagerIds); + nextVisitedManagerIds.add(managerThread.id); + const children = childrenByManagerId.get(managerThread.id) ?? []; + + return { + managerThread, + managedItems: buildSortedProjectThreadItems({ + childrenByManagerId, + threads: children, + visitedManagerIds: nextVisitedManagerIds, + }), + stats: { + managedChildCount: children.length, + managedChildActivity: getCollapsedChildActivity(children), + }, + }; +} + export function buildProjectThreadGroups( projectThreads: ThreadListEntry[], ): ProjectThreadGroups { @@ -150,33 +224,33 @@ export function buildProjectThreadGroups( } for (const thread of projectThreads) { - if (thread.type !== "standard") continue; - const managerId = getKnownManagerParentId({ managerThreadIds, thread }); - if (managerId === null) { - unmanagedStandardThreads.push(thread); + if (managerId !== null) { + childrenByManagerId.get(managerId)?.push(thread); continue; } - childrenByManagerId.get(managerId)?.push(thread); + if (thread.type === "standard") { + unmanagedStandardThreads.push(thread); + } } - const managerThreadGroups: ManagerThreadGroup[] = managerThreads.map( - (managerThread) => { - const children = childrenByManagerId.get(managerThread.id) ?? []; - return { - managerThread, - managedItems: buildSortedItems(children), - stats: { - managedChildCount: children.length, - managedChildActivity: getCollapsedChildActivity(children), - }, - }; - }, + const rootManagerThreads = managerThreads.filter( + (managerThread) => + getKnownManagerParentId({ + managerThreadIds, + thread: managerThread, + }) === null, ); return { - managerThreadGroups, + managerThreadGroups: rootManagerThreads.map((managerThread) => + buildManagerThreadGroup({ + childrenByManagerId, + managerThread, + visitedManagerIds: new Set(), + }), + ), unmanagedItems: buildSortedItems(unmanagedStandardThreads), }; } @@ -192,7 +266,7 @@ interface BucketWorktreeEnvironmentGroupsResult { // that are siblings in the same render context (project-level or under a // single manager). function bucketWorktreeEnvironmentGroups( - threads: ThreadListEntry[], + threads: readonly ThreadListEntry[], ): BucketWorktreeEnvironmentGroupsResult { const threadsByEnvironmentId = new Map(); for (const thread of threads) {