From 578bffe8c9c8df0b4e3aaa5ca8683caffba97d7b Mon Sep 17 00:00:00 2001 From: "wizzoapp[bot]" <254688279+wizzoapp[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 11:56:28 +0100 Subject: [PATCH] Add collapsible activity-sorted thread tree --- apps/web/src/components/Sidebar.logic.test.ts | 245 +++++++++++++++++- apps/web/src/components/Sidebar.logic.ts | 171 +++++++++++- apps/web/src/components/Sidebar.tsx | 158 ++++++++--- apps/web/src/uiStateStore.test.ts | 37 +++ apps/web/src/uiStateStore.ts | 32 +++ 5 files changed, 595 insertions(+), 48 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 2c8cdf7e0f5..fdc1aeb4783 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -2,8 +2,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test" import { buildSidebarThreadTreeRows, createThreadJumpHintVisibilityController, + filterSidebarThreadTreeRowsByExpansion, getSidebarThreadIdsToPrewarm, getVisibleSidebarThreadIds, + getVisibleSidebarThreadTreeRowsForPreview, resolveAdjacentThreadId, getFallbackThreadIdAfterDelete, getVisibleThreadsForProject, @@ -18,9 +20,12 @@ import { resolveSidebarStageBadgeLabel, resolveThreadRowClassName, resolveThreadStatusPill, + sidebarThreadExpansionKey, shouldClearThreadSelectionOnMouseDown, + sortSidebarThreadsByActivity, sortProjectsForSidebar, THREAD_JUMP_HINT_SHOW_DELAY_MS, + type SidebarThreadActivityInput, type SidebarThreadTreeInput, } from "./Sidebar.logic"; import { @@ -759,6 +764,17 @@ function makeTreeThread( }; } +function makeActivityTreeThread( + overrides: Partial & Pick, +): SidebarThreadActivityInput { + return { + ...makeTreeThread(overrides), + createdAt: overrides.createdAt ?? "2026-03-09T10:00:00.000Z", + updatedAt: overrides.updatedAt ?? "2026-03-09T10:00:00.000Z", + latestUserMessageAt: overrides.latestUserMessageAt ?? null, + }; +} + describe("buildSidebarThreadTreeRows", () => { it("places child threads directly under their parent with indentation metadata", () => { const parent = makeTreeThread({ id: ThreadId.make("parent") }); @@ -791,6 +807,89 @@ describe("buildSidebarThreadTreeRows", () => { expect(rows.map((row) => row.thread.id)).toEqual([parent.id, newerChild.id, middleThread.id]); }); + it("sorts sidebar threads active-first, then by latest activity", () => { + const oldActive = makeActivityTreeThread({ + id: ThreadId.make("old-active"), + updatedAt: "2026-03-09T10:01:00.000Z", + session: { + threadId: ThreadId.make("old-active"), + status: "running", + providerName: "Codex", + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: DEFAULT_RUNTIME_MODE, + activeTurnId: "turn-running" as never, + lastError: null, + updatedAt: "2026-03-09T10:01:00.000Z", + }, + }); + const recentIdle = makeActivityTreeThread({ + id: ThreadId.make("recent-idle"), + updatedAt: "2026-03-09T10:10:00.000Z", + }); + const olderIdle = makeActivityTreeThread({ + id: ThreadId.make("older-idle"), + updatedAt: "2026-03-09T10:05:00.000Z", + }); + + const sorted = sortSidebarThreadsByActivity([recentIdle, olderIdle, oldActive]); + + expect(sorted.map((thread) => thread.id)).toEqual([oldActive.id, recentIdle.id, olderIdle.id]); + }); + + it("uses the configured thread sort order after active threads", () => { + const newerCreated = makeActivityTreeThread({ + id: ThreadId.make("newer-created"), + createdAt: "2026-03-09T10:10:00.000Z", + updatedAt: "2026-03-09T10:01:00.000Z", + }); + const olderCreated = makeActivityTreeThread({ + id: ThreadId.make("older-created"), + createdAt: "2026-03-09T10:05:00.000Z", + updatedAt: "2026-03-09T10:20:00.000Z", + }); + + const sorted = sortSidebarThreadsByActivity([olderCreated, newerCreated], "created_at"); + + expect(sorted.map((thread) => thread.id)).toEqual([newerCreated.id, olderCreated.id]); + }); + + it("positions parent groups by the most active descendant and sorts children by activity", () => { + const oldParent = makeActivityTreeThread({ + id: ThreadId.make("old-parent"), + updatedAt: "2026-03-09T10:00:00.000Z", + }); + const activeChild = makeActivityTreeThread({ + id: ThreadId.make("active-child"), + parentThreadId: oldParent.id, + updatedAt: "2026-03-09T10:01:00.000Z", + latestTurn: { + ...makeLatestTurn(), + state: "running", + completedAt: null, + }, + }); + const recentChild = makeActivityTreeThread({ + id: ThreadId.make("recent-child"), + parentThreadId: oldParent.id, + updatedAt: "2026-03-09T10:20:00.000Z", + }); + const recentRoot = makeActivityTreeThread({ + id: ThreadId.make("recent-root"), + updatedAt: "2026-03-09T10:30:00.000Z", + }); + + const rows = buildSidebarThreadTreeRows( + sortSidebarThreadsByActivity([oldParent, recentRoot, recentChild, activeChild]), + ); + + expect(rows.map((row) => row.thread.id)).toEqual([ + oldParent.id, + activeChild.id, + recentChild.id, + recentRoot.id, + ]); + }); + it("rolls descendant running, done, and failed counts onto parent rows", () => { const parent = makeTreeThread({ id: ThreadId.make("parent") }); const running = makeTreeThread({ @@ -831,7 +930,7 @@ describe("buildSidebarThreadTreeRows", () => { }); }); - it("floats needs-you groups and child rows ahead of passive threads", () => { + it("keeps needs-you rollup metadata without overriding activity order", () => { const passiveParent = makeTreeThread({ id: ThreadId.make("passive-parent") }); const activeParent = makeTreeThread({ id: ThreadId.make("active-parent") }); const passiveChild = makeTreeThread({ @@ -852,12 +951,12 @@ describe("buildSidebarThreadTreeRows", () => { ]); expect(rows.map((row) => row.thread.id)).toEqual([ + passiveParent.id, activeParent.id, - needsYouChild.id, passiveChild.id, - passiveParent.id, + needsYouChild.id, ]); - expect(rows[0]).toMatchObject({ + expect(rows[1]).toMatchObject({ hasNeedsYou: false, hasDescendantNeedsYou: true, rollup: { @@ -865,7 +964,143 @@ describe("buildSidebarThreadTreeRows", () => { running: 0, }, }); - expect(rows[1]).toMatchObject({ hasNeedsYou: true }); + expect(rows[3]).toMatchObject({ hasNeedsYou: true }); + }); + + it("filters descendants of collapsed parent thread groups", () => { + const parent = makeTreeThread({ id: ThreadId.make("parent") }); + const child = makeTreeThread({ + id: ThreadId.make("child"), + parentThreadId: parent.id, + }); + const grandchild = makeTreeThread({ + id: ThreadId.make("grandchild"), + parentThreadId: child.id, + }); + const sibling = makeTreeThread({ id: ThreadId.make("sibling") }); + const rows = buildSidebarThreadTreeRows([parent, child, grandchild, sibling]); + + const filtered = filterSidebarThreadTreeRowsByExpansion(rows, { + [sidebarThreadExpansionKey(parent)]: false, + }); + + expect(filtered.map((row) => row.thread.id)).toEqual([parent.id, sibling.id]); + }); + + it("keeps the active descendant and its ancestors visible through collapsed parent groups", () => { + const parent = makeTreeThread({ id: ThreadId.make("parent") }); + const child = makeTreeThread({ + id: ThreadId.make("child"), + parentThreadId: parent.id, + }); + const grandchild = makeTreeThread({ + id: ThreadId.make("grandchild"), + parentThreadId: child.id, + }); + const siblingChild = makeTreeThread({ + id: ThreadId.make("sibling-child"), + parentThreadId: parent.id, + }); + const rows = buildSidebarThreadTreeRows([parent, child, grandchild, siblingChild]); + + const filtered = filterSidebarThreadTreeRowsByExpansion( + rows, + { + [sidebarThreadExpansionKey(parent)]: false, + [sidebarThreadExpansionKey(child)]: false, + }, + { activeThreadKey: sidebarThreadExpansionKey(grandchild) }, + ); + + expect(filtered.map((row) => row.thread.id)).toEqual([parent.id, child.id, grandchild.id]); + }); + + it("keeps the active tree path visible when the folded preview would slice it out", () => { + const earlyRows = Array.from({ length: 5 }, (_, index) => + makeTreeThread({ id: ThreadId.make(`early-${index + 1}`) }), + ); + const parent = makeTreeThread({ id: ThreadId.make("parent") }); + const child = makeTreeThread({ + id: ThreadId.make("child"), + parentThreadId: parent.id, + }); + const grandchild = makeTreeThread({ + id: ThreadId.make("grandchild"), + parentThreadId: child.id, + }); + const rows = buildSidebarThreadTreeRows([...earlyRows, parent, child, grandchild]); + const filtered = filterSidebarThreadTreeRowsByExpansion( + rows, + { + [sidebarThreadExpansionKey(parent)]: false, + [sidebarThreadExpansionKey(child)]: false, + }, + { activeThreadKey: sidebarThreadExpansionKey(grandchild) }, + ); + + const preview = getVisibleSidebarThreadTreeRowsForPreview({ + activeThreadKey: sidebarThreadExpansionKey(grandchild), + isThreadListExpanded: false, + previewLimit: 3, + rows: filtered, + }); + + expect(preview.hasOverflowingThreads).toBe(true); + expect(preview.visibleRows.map((row) => row.thread.id)).toEqual([ + ThreadId.make("early-1"), + ThreadId.make("early-2"), + ThreadId.make("early-3"), + parent.id, + child.id, + grandchild.id, + ]); + expect(preview.hiddenRows.map((row) => row.thread.id)).toEqual([ + ThreadId.make("early-4"), + ThreadId.make("early-5"), + ]); + }); + + it("does not report folded preview overflow when the active path leaves no hidden rows", () => { + const threads = Array.from({ length: 7 }, (_, index) => + makeTreeThread({ id: ThreadId.make(`thread-${index + 1}`) }), + ); + const rows = buildSidebarThreadTreeRows(threads); + + const preview = getVisibleSidebarThreadTreeRowsForPreview({ + activeThreadKey: sidebarThreadExpansionKey(threads[6]!), + isThreadListExpanded: false, + previewLimit: 6, + rows, + }); + + expect(preview.hasOverflowingThreads).toBe(false); + expect(preview.visibleRows.map((row) => row.thread.id)).toEqual( + threads.map((thread) => thread.id), + ); + expect(preview.hiddenRows).toEqual([]); + }); + + it("keeps child siblings visible when only one nested parent is collapsed", () => { + const parent = makeTreeThread({ id: ThreadId.make("parent") }); + const child = makeTreeThread({ + id: ThreadId.make("child"), + parentThreadId: parent.id, + }); + const grandchild = makeTreeThread({ + id: ThreadId.make("grandchild"), + parentThreadId: child.id, + }); + const siblingChild = makeTreeThread({ + id: ThreadId.make("sibling-child"), + parentThreadId: parent.id, + }); + const rows = buildSidebarThreadTreeRows([parent, child, grandchild, siblingChild]); + + const filtered = filterSidebarThreadTreeRowsByExpansion(rows, { + [sidebarThreadExpansionKey(child)]: false, + }); + + expect(filtered.map((row) => row.thread.id)).toEqual([parent.id, child.id, siblingChild.id]); }); it("keeps orphaned and cyclic child links visible as root rows", () => { diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index d9b5493ff4c..59281c80a0c 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -74,6 +74,7 @@ export type SidebarThreadTreeInput = Pick< | "parentThreadId" | "session" >; +export type SidebarThreadActivityInput = SidebarThreadTreeInput & ThreadSortInput; export interface SidebarThreadRollupCounts { readonly needsYou: number; @@ -536,6 +537,12 @@ function sidebarThreadTreeKey(thread: Pick, +): string { + return `${thread.environmentId}:${thread.id}`; +} + function sidebarThreadParentTreeKey(thread: SidebarThreadTreeInput): string | null { if (thread.parentThreadId === null) { return null; @@ -543,6 +550,157 @@ function sidebarThreadParentTreeKey(thread: SidebarThreadTreeInput): string | nu return `${thread.environmentId}\u0000${thread.parentThreadId}`; } +export function isSidebarThreadActiveForSort( + thread: Pick, +): boolean { + return ( + thread.session?.status === "starting" || + thread.session?.status === "running" || + (thread.session?.status === "waiting" && thread.session.activeTurnId !== null) || + thread.latestTurn?.state === "running" + ); +} + +export function compareSidebarThreadActivity( + left: SidebarThreadActivityInput, + right: SidebarThreadActivityInput, + sortOrder: SidebarThreadSortOrder = "updated_at", +): number { + const leftActive = isSidebarThreadActiveForSort(left) ? 1 : 0; + const rightActive = isSidebarThreadActiveForSort(right) ? 1 : 0; + const byActive = rightActive - leftActive; + if (byActive !== 0) { + return byActive; + } + + const rightTimestamp = getThreadSortTimestamp(right, sortOrder); + const leftTimestamp = getThreadSortTimestamp(left, sortOrder); + const byTimestamp = + rightTimestamp === leftTimestamp ? 0 : rightTimestamp > leftTimestamp ? 1 : -1; + if (byTimestamp !== 0) { + return byTimestamp; + } + + return right.id.localeCompare(left.id); +} + +export function sortSidebarThreadsByActivity( + threads: readonly TThread[], + sortOrder: SidebarThreadSortOrder = "updated_at", +): TThread[] { + return [...threads].toSorted((left, right) => + compareSidebarThreadActivity(left, right, sortOrder), + ); +} + +function getActiveSidebarThreadTreePathIndexes( + rows: readonly SidebarThreadTreeRow[], + activeThreadKey: string | null | undefined, +): Set { + const pathIndexes = new Set(); + if (activeThreadKey === null || activeThreadKey === undefined) { + return pathIndexes; + } + + const activeRowIndex = rows.findIndex( + (row) => sidebarThreadExpansionKey(row.thread) === activeThreadKey, + ); + if (activeRowIndex < 0) { + return pathIndexes; + } + + pathIndexes.add(activeRowIndex); + const activeRow = rows[activeRowIndex]; + if (!activeRow) { + return pathIndexes; + } + + let nextAncestorDepth = activeRow.depth - 1; + for (let index = activeRowIndex - 1; index >= 0 && nextAncestorDepth >= 0; index -= 1) { + const row = rows[index]; + if (row?.depth === nextAncestorDepth) { + pathIndexes.add(index); + nextAncestorDepth -= 1; + } + } + + return pathIndexes; +} + +export function filterSidebarThreadTreeRowsByExpansion( + rows: readonly SidebarThreadTreeRow[], + threadTreeExpandedById: Readonly>, + options: { readonly activeThreadKey?: string | null } = {}, +): SidebarThreadTreeRow[] { + const forceVisibleIndexes = getActiveSidebarThreadTreePathIndexes(rows, options.activeThreadKey); + + const visibleRows: SidebarThreadTreeRow[] = []; + let collapsedAncestorDepth: number | null = null; + + for (const [index, row] of rows.entries()) { + if (collapsedAncestorDepth !== null) { + if (row.depth > collapsedAncestorDepth) { + if (!forceVisibleIndexes.has(index)) { + continue; + } + } else { + collapsedAncestorDepth = null; + } + } + + visibleRows.push(row); + + if ( + row.directChildCount > 0 && + threadTreeExpandedById[sidebarThreadExpansionKey(row.thread)] === false + ) { + collapsedAncestorDepth = + collapsedAncestorDepth === null ? row.depth : Math.min(collapsedAncestorDepth, row.depth); + } + } + + return visibleRows; +} + +export function getVisibleSidebarThreadTreeRowsForPreview< + TThread extends SidebarThreadTreeInput, +>(input: { + readonly rows: readonly SidebarThreadTreeRow[]; + readonly activeThreadKey?: string | null; + readonly isThreadListExpanded: boolean; + readonly previewLimit: number; +}): { + readonly hasOverflowingThreads: boolean; + readonly visibleRows: SidebarThreadTreeRow[]; + readonly hiddenRows: SidebarThreadTreeRow[]; +} { + const { activeThreadKey, isThreadListExpanded, previewLimit, rows } = input; + const hasPreviewOverflow = rows.length > previewLimit; + + if (!hasPreviewOverflow || isThreadListExpanded) { + return { + hasOverflowingThreads: hasPreviewOverflow, + hiddenRows: [], + visibleRows: [...rows], + }; + } + + const visibleIndexes = new Set(); + for (const index of rows.slice(0, previewLimit).keys()) { + visibleIndexes.add(index); + } + for (const index of getActiveSidebarThreadTreePathIndexes(rows, activeThreadKey)) { + visibleIndexes.add(index); + } + const hiddenRows = rows.filter((_, index) => !visibleIndexes.has(index)); + + return { + hasOverflowingThreads: hiddenRows.length > 0, + hiddenRows, + visibleRows: rows.filter((_, index) => visibleIndexes.has(index)), + }; +} + export function buildSidebarThreadTreeRows( sortedThreads: readonly TThread[], ): SidebarThreadTreeRow[] { @@ -623,16 +781,11 @@ export function buildSidebarThreadTreeRows + const orderByTreeSort = (threads: readonly TThread[]) => [...threads].toSorted((left, right) => { const leftMetadata = collectMetadata(left); const rightMetadata = collectMetadata(right); - const leftPriority = leftMetadata.hasNeedsYou || leftMetadata.hasDescendantNeedsYou ? 1 : 0; - const rightPriority = - rightMetadata.hasNeedsYou || rightMetadata.hasDescendantNeedsYou ? 1 : 0; - return ( - rightPriority - leftPriority || leftMetadata.bestSortIndex - rightMetadata.bestSortIndex - ); + return leftMetadata.bestSortIndex - rightMetadata.bestSortIndex; }); const rows: SidebarThreadTreeRow[] = []; @@ -654,12 +807,12 @@ export function buildSidebarThreadTreeRows void; attemptArchiveThread: (threadRef: ScopedThreadRef) => Promise; openPrLink: (event: React.MouseEvent, prUrl: string) => void; + isThreadTreeExpanded: boolean; + toggleThreadTreeExpanded: (threadKey: string) => void; } export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) { @@ -438,6 +444,8 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr cancelRename, attemptArchiveThread, openPrLink, + isThreadTreeExpanded, + toggleThreadTreeExpanded, row, } = props; const thread = row.thread; @@ -584,6 +592,33 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr }, [navigateToThread, threadRef], ); + const handleThreadTreeTogglePointerDown = useCallback( + (event: React.PointerEvent) => { + event.stopPropagation(); + }, + [], + ); + const handleThreadTreeToggleClick = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + toggleThreadTreeExpanded(threadKey); + }, + [threadKey, toggleThreadTreeExpanded], + ); + const handleThreadTreeToggleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + event.stopPropagation(); + }, + [], + ); + const handleThreadTreeToggleDoubleClick = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + }, + [], + ); const handleRowContextMenu = useCallback( (event: React.MouseEvent) => { event.preventDefault(); @@ -729,6 +764,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr ); const rowButtonRender = useMemo(() =>
, []); const rowPaddingLeft = `${8 + Math.min(row.depth, 4) * 12}px`; + const canToggleThreadTree = row.directChildCount > 0; return ( ) : null} + {canToggleThreadTree ? ( + + ) : null} {prStatus && ( void; attemptArchiveThread: (threadRef: ScopedThreadRef) => Promise; openPrLink: (event: React.MouseEvent, prUrl: string) => void; + threadTreeExpandedById: Readonly>; + toggleThreadTreeExpanded: (threadKey: string) => void; expandThreadListForProject: (projectKey: string) => void; collapseThreadListForProject: (projectKey: string) => void; } @@ -1041,6 +1099,8 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( cancelRename, attemptArchiveThread, openPrLink, + threadTreeExpandedById, + toggleThreadTreeExpanded, expandThreadListForProject, collapseThreadListForProject, } = props; @@ -1093,6 +1153,11 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( cancelRename={cancelRename} attemptArchiveThread={attemptArchiveThread} openPrLink={openPrLink} + isThreadTreeExpanded={resolveThreadTreeExpanded( + threadTreeExpandedById, + sidebarThreadExpansionKey(thread), + )} + toggleThreadTreeExpanded={toggleThreadTreeExpanded} /> ); })} @@ -1200,6 +1265,8 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const { isMobile, setOpenMobile } = useSidebar(); const markThreadUnread = useUiStateStore((state) => state.markThreadUnread); const setProjectExpanded = useUiStateStore((state) => state.setProjectExpanded); + const threadTreeExpandedById = useUiStateStore((state) => state.threadTreeExpandedById); + const setThreadTreeExpanded = useUiStateStore((state) => state.setThreadTreeExpanded); const toggleThreadSelection = useThreadSelectionStore((state) => state.toggleThread); const rangeSelectTo = useThreadSelectionStore((state) => state.rangeSelectTo); const clearSelection = useThreadSelectionStore((state) => state.clearSelection); @@ -1322,6 +1389,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec projectStatus, visibleProjectThreads, visibleProjectThreadRows, + expandedProjectThreadRows, orderedProjectThreadKeys, } = useMemo(() => { const lastVisitedAtByThreadKey = new Map( @@ -1341,7 +1409,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec }, }); }; - const visibleProjectThreads = sortThreads( + const visibleProjectThreads = sortSidebarThreadsByActivity( projectThreads.filter((thread) => thread.archivedAt === null), threadSortOrder, ); @@ -1349,15 +1417,27 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec visibleProjectThreads.map((thread) => resolveProjectThreadStatus(thread)), ); const visibleProjectThreadRows = buildSidebarThreadTreeRows(visibleProjectThreads); + const expandedProjectThreadRows = filterSidebarThreadTreeRowsByExpansion( + visibleProjectThreadRows, + threadTreeExpandedById, + { activeThreadKey: activeRouteThreadKey }, + ); return { - orderedProjectThreadKeys: visibleProjectThreadRows.map((row) => + orderedProjectThreadKeys: expandedProjectThreadRows.map((row) => scopedThreadKey(scopeThreadRef(row.thread.environmentId, row.thread.id)), ), + expandedProjectThreadRows, projectStatus, visibleProjectThreadRows, visibleProjectThreads, }; - }, [projectThreads, threadLastVisitedAts, threadSortOrder]); + }, [ + activeRouteThreadKey, + projectThreads, + threadLastVisitedAts, + threadSortOrder, + threadTreeExpandedById, + ]); const pinnedCollapsedRow = useMemo(() => { const activeThreadKey = activeRouteThreadKey ?? undefined; if (!activeThreadKey || projectExpanded) { @@ -1396,29 +1476,17 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec }, }); }; - const hasOverflowingThreads = visibleProjectThreadRows.length > sidebarThreadPreviewCount; - const previewRows = - isThreadListExpanded || !hasOverflowingThreads - ? visibleProjectThreadRows - : visibleProjectThreadRows.slice(0, sidebarThreadPreviewCount); - const visibleThreadKeys = new Set( - [...previewRows, ...(pinnedCollapsedRow ? [pinnedCollapsedRow] : [])].map((row) => - scopedThreadKey(scopeThreadRef(row.thread.environmentId, row.thread.id)), - ), - ); - const renderedThreadRows = pinnedCollapsedRow - ? [pinnedCollapsedRow] - : visibleProjectThreadRows.filter((row) => - visibleThreadKeys.has( - scopedThreadKey(scopeThreadRef(row.thread.environmentId, row.thread.id)), - ), - ); - const hiddenRows = visibleProjectThreadRows.filter( - (row) => - !visibleThreadKeys.has( - scopedThreadKey(scopeThreadRef(row.thread.environmentId, row.thread.id)), - ), - ); + const { + hasOverflowingThreads, + hiddenRows, + visibleRows: previewRows, + } = getVisibleSidebarThreadTreeRowsForPreview({ + activeThreadKey: activeRouteThreadKey, + isThreadListExpanded, + previewLimit: sidebarThreadPreviewCount, + rows: expandedProjectThreadRows, + }); + const renderedThreadRows = pinnedCollapsedRow ? [pinnedCollapsedRow] : previewRows; return { hasOverflowingThreads, hiddenThreadStatus: resolveProjectStatusIndicator( @@ -1429,16 +1497,28 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec shouldShowThreadPanel: projectExpanded || pinnedCollapsedRow !== null, }; }, [ + expandedProjectThreadRows, + activeRouteThreadKey, isThreadListExpanded, pinnedCollapsedRow, projectExpanded, projectThreads, sidebarThreadPreviewCount, threadLastVisitedAts, - visibleProjectThreadRows, visibleProjectThreads, ]); + const toggleThreadTreeExpanded = useCallback( + (threadKey: string) => { + const expanded = resolveThreadTreeExpanded( + useUiStateStore.getState().threadTreeExpandedById, + threadKey, + ); + setThreadTreeExpanded(threadKey, !expanded); + }, + [setThreadTreeExpanded], + ); + const handleProjectButtonClick = useCallback( (event: React.MouseEvent) => { if (suppressProjectClickForContextMenuRef.current) { @@ -2464,6 +2544,8 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec cancelRename={cancelRename} attemptArchiveThread={attemptArchiveThread} openPrLink={openPrLink} + threadTreeExpandedById={threadTreeExpandedById} + toggleThreadTreeExpanded={toggleThreadTreeExpanded} expandThreadListForProject={expandThreadListForProject} collapseThreadListForProject={collapseThreadListForProject} /> @@ -3262,6 +3344,7 @@ export default function Sidebar() { const projects = useProjects(); const sidebarThreads = useThreadShells(); const projectExpandedById = useUiStateStore((store) => store.projectExpandedById); + const threadTreeExpandedById = useUiStateStore((store) => store.threadTreeExpandedById); const projectOrder = useUiStateStore((store) => store.projectOrder); const reorderProjects = useUiStateStore((store) => store.reorderProjects); const navigate = useNavigate(); @@ -3560,13 +3643,18 @@ export default function Sidebar() { const visibleSidebarThreadKeys = useMemo( () => sortedProjects.flatMap((project) => { - const projectThreads = sortThreads( + const projectThreads = sortSidebarThreadsByActivity( (threadsByProjectKey.get(project.projectKey) ?? []).filter( (thread) => thread.archivedAt === null, ), sidebarThreadSortOrder, ); const projectThreadRows = buildSidebarThreadTreeRows(projectThreads); + const expandedProjectThreadRows = filterSidebarThreadTreeRowsByExpansion( + projectThreadRows, + threadTreeExpandedById, + { activeThreadKey: routeThreadKey }, + ); const projectExpanded = resolveProjectExpanded( projectExpandedById, projectExpansionPreferenceKeys(project), @@ -3585,11 +3673,12 @@ export default function Sidebar() { return []; } const isThreadListExpanded = expandedThreadListsByProject.has(project.projectKey); - const hasOverflowingThreads = projectThreadRows.length > sidebarThreadPreviewCount; - const previewRows = - isThreadListExpanded || !hasOverflowingThreads - ? projectThreadRows - : projectThreadRows.slice(0, sidebarThreadPreviewCount); + const { visibleRows: previewRows } = getVisibleSidebarThreadTreeRowsForPreview({ + activeThreadKey: routeThreadKey, + isThreadListExpanded, + previewLimit: sidebarThreadPreviewCount, + rows: expandedProjectThreadRows, + }); const renderedRows = pinnedCollapsedRow ? [pinnedCollapsedRow] : previewRows; return renderedRows.map((row) => scopedThreadKey(scopeThreadRef(row.thread.environmentId, row.thread.id)), @@ -3600,6 +3689,7 @@ export default function Sidebar() { sidebarThreadPreviewCount, expandedThreadListsByProject, projectExpandedById, + threadTreeExpandedById, routeThreadKey, sortedProjects, threadsByProjectKey, diff --git a/apps/web/src/uiStateStore.test.ts b/apps/web/src/uiStateStore.test.ts index 0fbbd79ec27..b375b3541ff 100644 --- a/apps/web/src/uiStateStore.test.ts +++ b/apps/web/src/uiStateStore.test.ts @@ -11,9 +11,11 @@ import { persistState, reorderProjects, resolveProjectExpanded, + resolveThreadTreeExpanded, setDefaultAdvertisedEndpointKey, setProjectExpanded, setThreadChangedFilesExpanded, + setThreadTreeExpanded, type UiState, } from "./uiStateStore"; @@ -22,6 +24,7 @@ function makeUiState(overrides: Partial = {}): UiState { projectExpandedById: {}, projectOrder: [], threadLastVisitedAtById: {}, + threadTreeExpandedById: {}, threadChangedFilesExpandedById: {}, defaultAdvertisedEndpointKey: null, ...overrides, @@ -131,6 +134,25 @@ describe("uiStateStore pure functions", () => { ).toEqual({}); }); + it("persists parent thread tree expansion with expanded as the default", () => { + const threadId = ThreadId.make("parent-thread"); + const initialState = makeUiState(); + + expect(resolveThreadTreeExpanded(initialState.threadTreeExpandedById, threadId)).toBe(true); + + const collapsed = setThreadTreeExpanded(initialState, threadId, false); + + expect(resolveThreadTreeExpanded(collapsed.threadTreeExpandedById, threadId)).toBe(false); + expect(setThreadTreeExpanded(collapsed, threadId, false)).toBe(collapsed); + expect( + resolveThreadTreeExpanded(collapsed.threadTreeExpandedById, ThreadId.make("other")), + ).toBe(true); + + const expanded = setThreadTreeExpanded(collapsed, threadId, true); + + expect(resolveThreadTreeExpanded(expanded.threadTreeExpandedById, threadId)).toBe(true); + }); + it("stores the endpoint preference by stable key", () => { const next = setDefaultAdvertisedEndpointKey(makeUiState(), "desktop-core:lan:http"); @@ -154,6 +176,11 @@ describe("parsePersistedState", () => { "environment:thread-1": "2026-02-25T12:35:00.000Z", invalid: "not-a-date", }, + threadTreeExpandedById: { + "environment:thread-1": false, + "environment:thread-2": true, + invalid: "no" as unknown as boolean, + }, defaultAdvertisedEndpointKey: "desktop-core:lan:http", threadChangedFilesExpandedById: { "environment:thread-1": { @@ -171,6 +198,10 @@ describe("parsePersistedState", () => { threadLastVisitedAtById: { "environment:thread-1": "2026-02-25T12:35:00.000Z", }, + threadTreeExpandedById: { + "environment:thread-1": false, + "environment:thread-2": true, + }, defaultAdvertisedEndpointKey: "desktop-core:lan:http", threadChangedFilesExpandedById: { "environment:thread-1": { @@ -255,6 +286,9 @@ describe("uiStateStore persistence", () => { threadLastVisitedAtById: { "environment:thread-1": "2026-02-25T12:35:00.000Z", }, + threadTreeExpandedById: { + "environment:thread-1": false, + }, threadChangedFilesExpandedById: { "environment:thread-1": { "turn-1": false, @@ -277,6 +311,9 @@ describe("uiStateStore persistence", () => { threadLastVisitedAtById: { "environment:thread-1": "2026-02-25T12:35:00.000Z", }, + threadTreeExpandedById: { + "environment:thread-1": false, + }, defaultAdvertisedEndpointKey: "desktop-core:lan:http", threadChangedFilesExpandedById: { "environment:thread-1": { diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index 4a97f0542b4..64e6f4a07ba 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -20,6 +20,7 @@ export interface PersistedUiState { projectExpandedById?: Record; projectOrder?: string[]; threadLastVisitedAtById?: Record; + threadTreeExpandedById?: Record; collapsedProjectCwds?: string[]; expandedProjectCwds?: string[]; projectOrderCwds?: string[]; @@ -34,6 +35,7 @@ export interface UiProjectState { export interface UiThreadState { threadLastVisitedAtById: Record; + threadTreeExpandedById: Record; threadChangedFilesExpandedById: Record>; } @@ -47,6 +49,7 @@ const initialState: UiState = { projectExpandedById: {}, projectOrder: [], threadLastVisitedAtById: {}, + threadTreeExpandedById: {}, threadChangedFilesExpandedById: {}, defaultAdvertisedEndpointKey: null, }; @@ -124,6 +127,7 @@ export function parsePersistedState(parsed: PersistedUiState): UiState { projectExpandedById, projectOrder, threadLastVisitedAtById: sanitizeTimestampRecord(parsed.threadLastVisitedAtById), + threadTreeExpandedById: sanitizeBooleanRecord(parsed.threadTreeExpandedById), threadChangedFilesExpandedById: sanitizePersistedThreadChangedFilesExpanded( parsed.threadChangedFilesExpandedById, ), @@ -209,6 +213,7 @@ export function persistState(state: UiState): void { projectExpandedById, projectOrder: state.projectOrder, threadLastVisitedAtById: state.threadLastVisitedAtById, + threadTreeExpandedById: state.threadTreeExpandedById, defaultAdvertisedEndpointKey: state.defaultAdvertisedEndpointKey, threadChangedFilesExpandedById, } satisfies PersistedUiState), @@ -323,6 +328,30 @@ export function setThreadChangedFilesExpanded( }; } +export function resolveThreadTreeExpanded( + threadTreeExpandedById: Readonly>, + threadId: string, +): boolean { + return threadTreeExpandedById[threadId] ?? true; +} + +export function setThreadTreeExpanded( + state: UiState, + threadId: string, + expanded: boolean, +): UiState { + if (state.threadTreeExpandedById[threadId] === expanded) { + return state; + } + return { + ...state, + threadTreeExpandedById: { + ...state.threadTreeExpandedById, + [threadId]: expanded, + }, + }; +} + export function setDefaultAdvertisedEndpointKey(state: UiState, key: string | null): UiState { const nextKey = key && key.length > 0 ? key : null; if (state.defaultAdvertisedEndpointKey === nextKey) { @@ -415,6 +444,7 @@ interface UiStateStore extends UiState { markThreadVisited: (threadId: string, visitedAt: string) => void; markThreadUnread: (threadId: string, latestTurnCompletedAt: string | null | undefined) => void; setThreadChangedFilesExpanded: (threadId: string, turnId: string, expanded: boolean) => void; + setThreadTreeExpanded: (threadId: string, expanded: boolean) => void; setDefaultAdvertisedEndpointKey: (key: string | null) => void; setProjectExpanded: (projectIds: string | readonly string[], expanded: boolean) => void; reorderProjects: ( @@ -432,6 +462,8 @@ export const useUiStateStore = create((set) => ({ set((state) => markThreadUnread(state, threadId, latestTurnCompletedAt)), setThreadChangedFilesExpanded: (threadId, turnId, expanded) => set((state) => setThreadChangedFilesExpanded(state, threadId, turnId, expanded)), + setThreadTreeExpanded: (threadId, expanded) => + set((state) => setThreadTreeExpanded(state, threadId, expanded)), setDefaultAdvertisedEndpointKey: (key) => set((state) => setDefaultAdvertisedEndpointKey(state, key)), setProjectExpanded: (projectIds, expanded) =>