diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 83a5b2c72..8788ffb6a 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1144,6 +1144,177 @@ export default function Sidebar() { }); }, []); + const renderThreadRowButton = ( + thread: (typeof threads)[number], + orderedProjectThreadIds: readonly ThreadId[], + ) => { + const isActive = routeThreadId === thread.id; + const isSelected = selectedThreadIds.has(thread.id); + const isHighlighted = isActive || isSelected; + const threadStatus = resolveThreadStatusPill({ + thread, + hasPendingApprovals: derivePendingApprovals(thread.activities).length > 0, + hasPendingUserInput: derivePendingUserInputs(thread.activities).length > 0, + }); + const prStatus = prStatusIndicator(prByThreadId.get(thread.id) ?? null); + const terminalStatus = terminalStatusFromRunningIds( + selectThreadTerminalState(terminalStateByThreadId, thread.id).runningTerminalIds, + ); + + return ( + } + size="sm" + isActive={isActive} + className={resolveThreadRowClassName({ + isActive, + isSelected, + })} + data-thread-item + onClick={(event) => { + handleThreadClick(event, thread.id, orderedProjectThreadIds); + }} + onKeyDown={(event) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + if (selectedThreadIds.size > 0) { + clearSelection(); + } + setSelectionAnchor(thread.id); + void navigate({ + to: "/$threadId", + params: { threadId: thread.id }, + }); + }} + onContextMenu={(event) => { + event.preventDefault(); + if (selectedThreadIds.size > 0 && selectedThreadIds.has(thread.id)) { + void handleMultiSelectContextMenu({ + x: event.clientX, + y: event.clientY, + }); + } else { + if (selectedThreadIds.size > 0) { + clearSelection(); + } + void handleThreadContextMenu(thread.id, { + x: event.clientX, + y: event.clientY, + }); + } + }} + > +
+ {prStatus && ( + + { + openPrLink(event, prStatus.url); + }} + > + + + } + /> + {prStatus.tooltip} + + )} + {threadStatus && ( + + + {threadStatus.label} + + )} + {renamingThreadId === thread.id ? ( + { + if (el && renamingInputRef.current !== el) { + renamingInputRef.current = el; + el.focus(); + el.select(); + } + }} + className="min-w-0 flex-1 truncate text-xs bg-transparent outline-none border border-ring rounded px-0.5" + value={renamingTitle} + onChange={(e) => setRenamingTitle(e.target.value)} + onKeyDown={(e) => { + e.stopPropagation(); + if (e.key === "Enter") { + e.preventDefault(); + renamingCommittedRef.current = true; + void commitRename(thread.id, renamingTitle, thread.title); + } else if (e.key === "Escape") { + e.preventDefault(); + renamingCommittedRef.current = true; + cancelRename(); + } + }} + onBlur={() => { + if (!renamingCommittedRef.current) { + void commitRename(thread.id, renamingTitle, thread.title); + } + }} + onClick={(e) => e.stopPropagation()} + /> + ) : ( + {thread.title} + )} +
+
+ {terminalStatus && ( + + + + )} + + {formatRelativeTime(thread.createdAt)} + +
+
+ ); + }; + + const renderThreadListItem = ( + thread: (typeof threads)[number], + orderedProjectThreadIds: readonly ThreadId[], + ) => ( + + {renderThreadRowButton(thread, orderedProjectThreadIds)} + + ); + + const renderThreadListToggleButton = (label: "Show more" | "Show less", onClick: () => void) => ( + } + data-thread-selection-safe + size="sm" + className="h-6 w-full translate-x-0 justify-start px-2 text-left text-[10px] text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground/80" + onClick={onClick} + > + {label} + + ); + const wordmark = (
@@ -1339,13 +1510,39 @@ export default function Sidebar() { if (byDate !== 0) return byDate; return b.id.localeCompare(a.id); }); + const activeThreadIndex = + routeThreadId !== null + ? projectThreads.findIndex((thread) => thread.id === routeThreadId) + : -1; + const activeThread = + activeThreadIndex >= 0 ? (projectThreads[activeThreadIndex] ?? null) : null; const isThreadListExpanded = expandedThreadListsByProject.has(project.id); const hasHiddenThreads = projectThreads.length > THREAD_PREVIEW_LIMIT; + const shouldForceThreadListOpen = + activeThread !== null && activeThreadIndex >= THREAD_PREVIEW_LIMIT; + const shouldShowExpandedThreadList = + isThreadListExpanded || shouldForceThreadListOpen; const visibleThreads = - hasHiddenThreads && !isThreadListExpanded + hasHiddenThreads && !shouldShowExpandedThreadList ? projectThreads.slice(0, THREAD_PREVIEW_LIMIT) : projectThreads; const orderedProjectThreadIds = projectThreads.map((t) => t.id); + const visibleActiveThreadIndex = + activeThread !== null + ? visibleThreads.findIndex((thread) => thread.id === activeThread.id) + : -1; + const shouldSplitAroundActiveThread = + activeThread !== null && visibleActiveThreadIndex >= 0; + const threadsAboveActive = shouldSplitAroundActiveThread + ? visibleThreads.slice(0, visibleActiveThreadIndex) + : []; + const threadsBelowActive = shouldSplitAroundActiveThread + ? visibleThreads.slice(visibleActiveThreadIndex + 1) + : []; + const canShowMoreThreads = + hasHiddenThreads && !isThreadListExpanded && !shouldForceThreadListOpen; + const canShowLessThreads = + hasHiddenThreads && isThreadListExpanded && !shouldForceThreadListOpen; return ( @@ -1413,222 +1610,91 @@ export default function Sidebar() {
- + {shouldSplitAroundActiveThread && activeThread ? ( - {visibleThreads.map((thread) => { - const isActive = routeThreadId === thread.id; - const isSelected = selectedThreadIds.has(thread.id); - const isHighlighted = isActive || isSelected; - const threadStatus = resolveThreadStatusPill({ - thread, - hasPendingApprovals: - derivePendingApprovals(thread.activities).length > 0, - hasPendingUserInput: - derivePendingUserInputs(thread.activities).length > 0, - }); - const prStatus = prStatusIndicator( - prByThreadId.get(thread.id) ?? null, - ); - const terminalStatus = terminalStatusFromRunningIds( - selectThreadTerminalState(terminalStateByThreadId, thread.id) - .runningTerminalIds, - ); - - return ( - - } - size="sm" - isActive={isActive} - className={resolveThreadRowClassName({ - isActive, - isSelected, - })} - onClick={(event) => { - handleThreadClick( - event, - thread.id, - orderedProjectThreadIds, - ); - }} - onKeyDown={(event) => { - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - if (selectedThreadIds.size > 0) { - clearSelection(); - } - setSelectionAnchor(thread.id); - void navigate({ - to: "/$threadId", - params: { threadId: thread.id }, - }); - }} - onContextMenu={(event) => { - event.preventDefault(); - if ( - selectedThreadIds.size > 0 && - selectedThreadIds.has(thread.id) - ) { - void handleMultiSelectContextMenu({ - x: event.clientX, - y: event.clientY, - }); - } else { - if (selectedThreadIds.size > 0) { - clearSelection(); - } - void handleThreadContextMenu(thread.id, { - x: event.clientX, - y: event.clientY, - }); - } - }} - > -
- {prStatus && ( - - { - openPrLink(event, prStatus.url); - }} - > - - - } - /> - - {prStatus.tooltip} - - - )} - {threadStatus && ( - 0 ? ( + + + +
+ {threadsAboveActive.map((thread) => ( +
- - - {threadStatus.label} - - - )} - {renamingThreadId === thread.id ? ( - { - if (el && renamingInputRef.current !== el) { - renamingInputRef.current = el; - el.focus(); - el.select(); - } - }} - className="min-w-0 flex-1 truncate text-xs bg-transparent outline-none border border-ring rounded px-0.5" - value={renamingTitle} - onChange={(e) => setRenamingTitle(e.target.value)} - onKeyDown={(e) => { - e.stopPropagation(); - if (e.key === "Enter") { - e.preventDefault(); - renamingCommittedRef.current = true; - void commitRename( - thread.id, - renamingTitle, - thread.title, - ); - } else if (e.key === "Escape") { - e.preventDefault(); - renamingCommittedRef.current = true; - cancelRename(); - } - }} - onBlur={() => { - if (!renamingCommittedRef.current) { - void commitRename( - thread.id, - renamingTitle, - thread.title, - ); - } - }} - onClick={(e) => e.stopPropagation()} - /> - ) : ( - - {thread.title} - - )} + {renderThreadRowButton(thread, orderedProjectThreadIds)} +
+ ))}
-
- {terminalStatus && ( - + + + ) : null} + + {renderThreadListItem(activeThread, orderedProjectThreadIds)} + + {threadsBelowActive.length > 0 || + canShowMoreThreads || + canShowLessThreads ? ( + + + +
+ {threadsBelowActive.map((thread) => ( +
- - - )} - - {formatRelativeTime(thread.createdAt)} - + {renderThreadRowButton(thread, orderedProjectThreadIds)} +
+ ))} + + {canShowMoreThreads ? ( +
+ {renderThreadListToggleButton("Show more", () => { + expandThreadListForProject(project.id); + })} +
+ ) : null} + + {canShowLessThreads ? ( +
+ {renderThreadListToggleButton("Show less", () => { + collapseThreadListForProject(project.id); + })} +
+ ) : null}
- -
- ); - })} - - {hasHiddenThreads && !isThreadListExpanded && ( - - } - data-thread-selection-safe - size="sm" - className="h-6 w-full translate-x-0 justify-start px-2 text-left text-[10px] text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground/80" - onClick={() => { - expandThreadListForProject(project.id); - }} - > - Show more - - - )} - {hasHiddenThreads && isThreadListExpanded && ( - - } - data-thread-selection-safe - size="sm" - className="h-6 w-full translate-x-0 justify-start px-2 text-left text-[10px] text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground/80" - onClick={() => { - collapseThreadListForProject(project.id); - }} - > - Show less - + + - )} + ) : null} - + ) : ( + + + {visibleThreads.map((thread) => + renderThreadListItem(thread, orderedProjectThreadIds), + )} + + {canShowMoreThreads ? ( + + {renderThreadListToggleButton("Show more", () => { + expandThreadListForProject(project.id); + })} + + ) : null} + + {canShowLessThreads ? ( + + {renderThreadListToggleButton("Show less", () => { + collapseThreadListForProject(project.id); + })} + + ) : null} + + + )} )}