diff --git a/src/features/app/components/PinnedThreadList.test.tsx b/src/features/app/components/PinnedThreadList.test.tsx index 704f91bfa..6ed7210ab 100644 --- a/src/features/app/components/PinnedThreadList.test.tsx +++ b/src/features/app/components/PinnedThreadList.test.tsx @@ -22,12 +22,13 @@ const statusMap = { }; const baseProps = { - rows: [{ thread, depth: 0, workspaceId: "ws-1" }], + rows: [{ thread, depth: 0, workspaceId: "ws-1", hasChildren: false, isCollapsed: false }], activeWorkspaceId: "ws-1", activeThreadId: "thread-1", threadStatusById: statusMap, getThreadTime: () => "1h", isThreadPinned: () => true, + onToggleThreadCollapsed: vi.fn(), onSelectThread: vi.fn(), onShowThreadMenu: vi.fn(), }; @@ -76,8 +77,8 @@ describe("PinnedThreadList", () => { { const { container } = render( { expect(row?.querySelector(".thread-status")?.className).toContain("unread"); expect(row?.querySelector(".thread-status")?.className).not.toContain("processing"); }); + + it("renders a collapse toggle for pinned threads with children", () => { + const onToggleThreadCollapsed = vi.fn(); + const onSelectThread = vi.fn(); + + render( + , + ); + + const toggle = screen.getByRole("button", { name: "Expand subagent threads" }); + fireEvent.click(toggle); + + expect(onToggleThreadCollapsed).toHaveBeenCalledWith("ws-1", "thread-1"); + expect(onSelectThread).not.toHaveBeenCalled(); + }); }); diff --git a/src/features/app/components/PinnedThreadList.tsx b/src/features/app/components/PinnedThreadList.tsx index 5def5ee6d..b820f93ff 100644 --- a/src/features/app/components/PinnedThreadList.tsx +++ b/src/features/app/components/PinnedThreadList.tsx @@ -8,6 +8,8 @@ type PinnedThreadRow = { thread: ThreadSummary; depth: number; workspaceId: string; + hasChildren: boolean; + isCollapsed: boolean; }; type PinnedThreadListProps = { @@ -20,6 +22,7 @@ type PinnedThreadListProps = { getThreadTime: (thread: ThreadSummary) => string | null; getThreadArgsBadge?: (workspaceId: string, threadId: string) => string | null; isThreadPinned: (workspaceId: string, threadId: string) => boolean; + onToggleThreadCollapsed?: (workspaceId: string, threadId: string) => void; onSelectThread: (workspaceId: string, threadId: string) => void; onShowThreadMenu: ( event: MouseEvent, @@ -39,12 +42,13 @@ export function PinnedThreadList({ getThreadTime, getThreadArgsBadge, isThreadPinned, + onToggleThreadCollapsed, onSelectThread, onShowThreadMenu, }: PinnedThreadListProps) { return (
- {rows.map(({ thread, depth, workspaceId }) => { + {rows.map(({ thread, depth, workspaceId, hasChildren, isCollapsed }) => { return ( ()); + const [collapsedThreadTreesVersion, setCollapsedThreadTreesVersion] = useState(0); + const isThreadCollapsed = useCallback( + (workspaceId: string, threadId: string) => + collapsedThreadTreesRef.current.has(`${workspaceId}:${threadId}`), + [], + ); + const toggleThreadCollapsed = useCallback( + (workspaceId: string, threadId: string) => { + const key = `${workspaceId}:${threadId}`; + const collapsed = collapsedThreadTreesRef.current; + if (collapsed.has(key)) { + collapsed.delete(key); + } else { + collapsed.add(key); + } + setCollapsedThreadTreesVersion((prev) => prev + 1); + }, + [], + ); const isWorkspaceMatch = useCallback( (workspace: WorkspaceInfo) => { @@ -302,7 +324,12 @@ export const Sidebar = memo(function Sidebar({ ); const pinnedThreadRows = useMemo(() => { - type ThreadRow = { thread: ThreadSummary; depth: number }; + type ThreadRow = { + thread: ThreadSummary; + depth: number; + hasChildren: boolean; + isCollapsed: boolean; + }; const groups: Array<{ pinTime: number; workspaceId: string; @@ -323,6 +350,8 @@ export const Sidebar = memo(function Sidebar({ workspace.id, getPinTimestamp, pinnedThreadsVersion, + collapsedThreadTreesVersion, + isThreadCollapsed, ); if (!pinnedRows.length) { return; @@ -369,6 +398,8 @@ export const Sidebar = memo(function Sidebar({ getThreadRows, getPinTimestamp, pinnedThreadsVersion, + collapsedThreadTreesVersion, + isThreadCollapsed, isWorkspaceMatch, ]); @@ -531,6 +562,8 @@ export const Sidebar = memo(function Sidebar({ workspace.id, getPinTimestamp, pinnedThreadsVersion, + collapsedThreadTreesVersion, + isThreadCollapsed, ); if (!unpinnedRows.length) { return; @@ -596,6 +629,8 @@ export const Sidebar = memo(function Sidebar({ getSortTimestamp, getThreadRows, pinnedThreadsVersion, + collapsedThreadTreesVersion, + isThreadCollapsed, threadListOrganizeMode, threadsByWorkspace, ]); @@ -604,18 +639,22 @@ export const Sidebar = memo(function Sidebar({ () => [ sortedGroupedWorkspaces, flatThreadRows, + pinnedThreadRows, threadsByWorkspace, expandedWorkspaces, normalizedQuery, threadListOrganizeMode, + collapsedThreadTreesVersion, ], [ sortedGroupedWorkspaces, flatThreadRows, + pinnedThreadRows, threadsByWorkspace, expandedWorkspaces, normalizedQuery, threadListOrganizeMode, + collapsedThreadTreesVersion, ], ); const { sidebarBodyRef, scrollFade, updateScrollFade } = @@ -871,6 +910,7 @@ export const Sidebar = memo(function Sidebar({ getThreadTime={getThreadTime} getThreadArgsBadge={getThreadArgsBadge} isThreadPinned={isThreadPinned} + onToggleThreadCollapsed={toggleThreadCollapsed} onSelectThread={onSelectThread} onShowThreadMenu={showThreadMenu} getWorkspaceLabel={isThreadsOnlyMode ? getWorkspaceLabel : undefined} @@ -904,6 +944,7 @@ export const Sidebar = memo(function Sidebar({ getThreadTime={getThreadTime} getThreadArgsBadge={getThreadArgsBadge} isThreadPinned={isThreadPinned} + onToggleThreadCollapsed={toggleThreadCollapsed} onSelectThread={onSelectThread} onShowThreadMenu={showThreadMenu} getWorkspaceLabel={getWorkspaceLabel} @@ -970,6 +1011,8 @@ export const Sidebar = memo(function Sidebar({ entry.id, getPinTimestamp, pinnedThreadsVersion, + collapsedThreadTreesVersion, + isThreadCollapsed, ); const nextCursor = threadListCursorByWorkspace[entry.id] ?? null; @@ -1097,6 +1140,8 @@ export const Sidebar = memo(function Sidebar({ isThreadPinned={isThreadPinned} getPinTimestamp={getPinTimestamp} pinnedThreadsVersion={pinnedThreadsVersion} + collapsedThreadTreesVersion={collapsedThreadTreesVersion} + isThreadCollapsed={isThreadCollapsed} onSelectWorkspace={onSelectWorkspace} onConnectWorkspace={onConnectWorkspace} onToggleWorkspaceCollapse={onToggleWorkspaceCollapse} @@ -1105,6 +1150,7 @@ export const Sidebar = memo(function Sidebar({ onShowWorktreeMenu={showCloneMenu} onToggleExpanded={handleToggleExpanded} onLoadOlderThreads={onLoadOlderThreads} + onToggleThreadCollapsed={toggleThreadCollapsed} sectionLabel="Clone agents" sectionIcon={ @@ -1131,6 +1177,8 @@ export const Sidebar = memo(function Sidebar({ isThreadPinned={isThreadPinned} getPinTimestamp={getPinTimestamp} pinnedThreadsVersion={pinnedThreadsVersion} + collapsedThreadTreesVersion={collapsedThreadTreesVersion} + isThreadCollapsed={isThreadCollapsed} onSelectWorkspace={onSelectWorkspace} onConnectWorkspace={onConnectWorkspace} onToggleWorkspaceCollapse={onToggleWorkspaceCollapse} @@ -1139,6 +1187,7 @@ export const Sidebar = memo(function Sidebar({ onShowWorktreeMenu={showWorktreeMenu} onToggleExpanded={handleToggleExpanded} onLoadOlderThreads={onLoadOlderThreads} + onToggleThreadCollapsed={toggleThreadCollapsed} /> )} {showThreadList && ( @@ -1159,6 +1208,7 @@ export const Sidebar = memo(function Sidebar({ isThreadPinned={isThreadPinned} onToggleExpanded={handleToggleExpanded} onLoadOlderThreads={onLoadOlderThreads} + onToggleThreadCollapsed={toggleThreadCollapsed} onSelectThread={onSelectThread} onShowThreadMenu={showThreadMenu} /> diff --git a/src/features/app/components/ThreadList.test.tsx b/src/features/app/components/ThreadList.test.tsx index 0451cab03..1b8d56f41 100644 --- a/src/features/app/components/ThreadList.test.tsx +++ b/src/features/app/components/ThreadList.test.tsx @@ -24,7 +24,7 @@ const statusMap = { const baseProps = { workspaceId: "ws-1", pinnedRows: [], - unpinnedRows: [{ thread, depth: 0 }], + unpinnedRows: [{ thread, depth: 0, hasChildren: false, isCollapsed: false }], totalThreadRoots: 1, isExpanded: false, nextCursor: null, @@ -37,6 +37,7 @@ const baseProps = { isThreadPinned: () => false, onToggleExpanded: vi.fn(), onLoadOlderThreads: vi.fn(), + onToggleThreadCollapsed: vi.fn(), onSelectThread: vi.fn(), onShowThreadMenu: vi.fn(), }; @@ -111,8 +112,8 @@ describe("ThreadList", () => { {...baseProps} nested unpinnedRows={[ - { thread, depth: 0 }, - { thread: nestedThread, depth: 1 }, + { thread, depth: 0, hasChildren: false, isCollapsed: false }, + { thread: nestedThread, depth: 1, hasChildren: false, isCollapsed: false }, ]} onShowThreadMenu={onShowThreadMenu} />, @@ -134,10 +135,30 @@ describe("ThreadList", () => { ); }); + it("renders a collapse toggle for threads with children", () => { + const onToggleThreadCollapsed = vi.fn(); + const onSelectThread = vi.fn(); + render( + , + ); + + const toggle = screen.getByRole("button", { name: "Expand subagent threads" }); + fireEvent.click(toggle); + + expect(onToggleThreadCollapsed).toHaveBeenCalledWith("ws-1", "thread-1"); + expect(onSelectThread).not.toHaveBeenCalled(); + }); + it("shows blue unread-style status when a thread is waiting for user input", () => { const { container } = render( boolean; onToggleExpanded: (workspaceId: string) => void; onLoadOlderThreads: (workspaceId: string) => void; + onToggleThreadCollapsed?: (workspaceId: string, threadId: string) => void; onSelectThread: (workspaceId: string, threadId: string) => void; onShowThreadMenu: ( event: MouseEvent, @@ -56,6 +59,7 @@ export function ThreadList({ isThreadPinned, onToggleExpanded, onLoadOlderThreads, + onToggleThreadCollapsed, onSelectThread, onShowThreadMenu, }: ThreadListProps) { @@ -63,13 +67,16 @@ export function ThreadList({ return (
- {pinnedRows.map(({ thread, depth }) => ( + {pinnedRows.map(({ thread, depth, hasChildren, isCollapsed }) => ( 0 && unpinnedRows.length > 0 && (