diff --git a/src/App.tsx b/src/App.tsx index b4390a41e..e115b8737 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -469,6 +469,7 @@ function MainApp() { activeItems, approvals, threadsByWorkspace, + threadParentById, threadStatusById, threadListLoadingByWorkspace, threadListPagingByWorkspace, @@ -889,6 +890,7 @@ function MainApp() { groupedWorkspaces, hasWorkspaceGroups: workspaceGroups.length > 0, threadsByWorkspace, + threadParentById, threadStatusById, threadListLoadingByWorkspace, threadListPagingByWorkspace, diff --git a/src/features/app/components/Sidebar.tsx b/src/features/app/components/Sidebar.tsx index 47d65c745..519e7193f 100644 --- a/src/features/app/components/Sidebar.tsx +++ b/src/features/app/components/Sidebar.tsx @@ -1,7 +1,7 @@ import type { RateLimitSnapshot, ThreadSummary, WorkspaceInfo } from "../../../types"; import { FolderKanban, Layers, ScrollText, Settings } from "lucide-react"; import { createPortal } from "react-dom"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react"; import { Menu, MenuItem } from "@tauri-apps/api/menu"; import { LogicalPosition } from "@tauri-apps/api/dpi"; import { getCurrentWindow } from "@tauri-apps/api/window"; @@ -20,6 +20,7 @@ type SidebarProps = { groupedWorkspaces: WorkspaceGroupSection[]; hasWorkspaceGroups: boolean; threadsByWorkspace: Record; + threadParentById: Record; threadStatusById: Record< string, { isProcessing: boolean; hasUnread: boolean; isReviewing: boolean } @@ -54,6 +55,7 @@ export function Sidebar({ groupedWorkspaces, hasWorkspaceGroups, threadsByWorkspace, + threadParentById, threadStatusById, threadListLoadingByWorkspace, threadListPagingByWorkspace, @@ -110,6 +112,43 @@ export function Sidebar({ const sidebarBodyRef = useRef(null); const [scrollFade, setScrollFade] = useState({ top: false, bottom: false }); + const getThreadRows = useCallback( + (threads: ThreadSummary[], isExpanded: boolean) => { + const threadIds = new Set(threads.map((thread) => thread.id)); + const childrenByParent = new Map(); + const roots: ThreadSummary[] = []; + + threads.forEach((thread) => { + const parentId = threadParentById[thread.id]; + if (parentId && parentId !== thread.id && threadIds.has(parentId)) { + const list = childrenByParent.get(parentId) ?? []; + list.push(thread); + childrenByParent.set(parentId, list); + } else { + roots.push(thread); + } + }); + + const visibleRootCount = isExpanded ? roots.length : 3; + const visibleRoots = roots.slice(0, visibleRootCount); + const rows: Array<{ thread: ThreadSummary; depth: number }> = []; + const appendThread = (thread: ThreadSummary, depth: number) => { + rows.push({ thread, depth }); + const children = childrenByParent.get(thread.id) ?? []; + children.forEach((child) => appendThread(child, depth + 1)); + }; + + visibleRoots.forEach((thread) => appendThread(thread, 0)); + + return { + rows, + totalRoots: roots.length, + hasMoreRoots: roots.length > visibleRootCount, + }; + }, + [threadParentById], + ); + const updateScrollFade = useCallback(() => { const node = sidebarBodyRef.current; if (!node) { @@ -388,6 +427,11 @@ export function Sidebar({ {group.workspaces.map((entry) => { const threads = threadsByWorkspace[entry.id] ?? []; const isCollapsed = entry.settings.sidebarCollapsed; + const isExpanded = expandedWorkspaces.has(entry.id); + const { + rows: threadRows, + totalRoots: totalThreadRoots, + } = getThreadRows(threads, isExpanded); const showThreads = !isCollapsed && threads.length > 0; const isLoadingThreads = threadListLoadingByWorkspace[entry.id] ?? false; @@ -596,121 +640,157 @@ export function Sidebar({ {showWorktreeThreads && (
- {(expandedWorkspaces.has(worktree.id) - ? worktreeThreads - : worktreeThreads.slice(0, 3) - ).map((thread) => { - const relativeTime = getThreadTime(thread); + {(() => { + const isWorktreeExpanded = + expandedWorkspaces.has(worktree.id); + const { + rows: worktreeThreadRows, + totalRoots: totalWorktreeRoots, + } = getThreadRows( + worktreeThreads, + isWorktreeExpanded, + ); return ( -
- onSelectThread(worktree.id, thread.id) - } - onContextMenu={(event) => - showThreadMenu( - event, - worktree.id, - thread.id, - ) - } - role="button" - tabIndex={0} - onKeyDown={(event) => { - if ( - event.key === "Enter" || - event.key === " " - ) { - event.preventDefault(); - onSelectThread(worktree.id, thread.id); - } - }} - > - - - {thread.name} - -
- {relativeTime && ( - - {relativeTime} - - )} -
+ <> + {worktreeThreadRows.map( + ({ thread, depth }) => { + const relativeTime = + getThreadTime(thread); + const indentStyle = + depth > 0 + ? ({ + "--thread-indent": `${depth * 14}px`, + } as CSSProperties) + : undefined; + return ( +
+ onSelectThread( + worktree.id, + thread.id, + ) + } + onContextMenu={(event) => + showThreadMenu( + event, + worktree.id, + thread.id, + ) + } + role="button" + tabIndex={0} + onKeyDown={(event) => { + if ( + event.key === "Enter" || + event.key === " " + ) { + event.preventDefault(); + onSelectThread( + worktree.id, + thread.id, + ); + } + }} + > + + + {thread.name} + +
+ {relativeTime && ( + + {relativeTime} + + )} +
+ +
+
+
+ ); + }, + )} + {totalWorktreeRoots > 3 && ( + + )} + {isWorktreeExpanded && + worktreeNextCursor && ( -
-
-
+ )} + ); - })} - {worktreeThreads.length > 3 && ( - - )} - {expandedWorkspaces.has(worktree.id) && - worktreeNextCursor && ( - - )} + })()}
)} {showWorktreeLoader && ( @@ -731,11 +811,14 @@ export function Sidebar({ )} {showThreads && (
- {(expandedWorkspaces.has(entry.id) - ? threads - : threads.slice(0, 3) - ).map((thread) => { + {threadRows.map(({ thread, depth }) => { const relativeTime = getThreadTime(thread); + const indentStyle = + depth > 0 + ? ({ + "--thread-indent": `${depth * 14}px`, + } as CSSProperties) + : undefined; return (
onSelectThread(entry.id, thread.id)} onContextMenu={(event) => showThreadMenu(event, entry.id, thread.id) @@ -795,7 +879,7 @@ export function Sidebar({
); })} - {threads.length > 3 && ( + {totalThreadRoots > 3 && ( )} - {expandedWorkspaces.has(entry.id) && nextCursor && ( + {isExpanded && nextCursor && (