diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index b159ac84d..bc01782fa 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -17,8 +17,7 @@ import { } from "lucide-react"; import { ThemeModeSwitcher } from "./ThemeModeSwitcher"; import { OkCodeMark } from "./OkCodeMark"; -import { autoAnimate } from "@formkit/auto-animate"; -import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent } from "react"; +import { memo, useCallback, useEffect, useMemo, useRef, useState, type MouseEvent } from "react"; import { DndContext, type DragCancelEvent, @@ -123,6 +122,7 @@ import { import { useClientMode } from "~/hooks/useClientMode"; import { getProjectColor } from "~/projectColors"; import type { Thread } from "../types"; +import type { ThreadId as ThreadIdType } from "@okcode/contracts"; const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; const THREAD_PREVIEW_LIMIT = 10; @@ -135,10 +135,6 @@ const SIDEBAR_THREAD_SORT_LABELS: Record = { updated_at: "Last user message", created_at: "Created at", }; -const SIDEBAR_LIST_ANIMATION_OPTIONS = { - duration: 180, - easing: "ease-out", -} as const; const loadedProjectFaviconSrcs = new Set(); function formatRelativeTime(iso: string): string { @@ -356,6 +352,235 @@ function SortableProjectItem({ ); } +function useMinuteTick(): number { + const [tick, setTick] = useState(0); + useEffect(() => { + const id = setInterval(() => setTick((t) => t + 1), 60_000); + return () => clearInterval(id); + }, []); + return tick; +} + +interface MemoizedThreadRowProps { + thread: Thread; + isActive: boolean; + isSelected: boolean; + routeThreadId: ThreadIdType | null; + pColor: ReturnType; + prByThreadId: Map; + terminalStateByThreadId: Record; + orderedProjectThreadIds: ThreadIdType[]; + selectedThreadIds: ReadonlySet; + editingThreadId: ThreadIdType | null; + editingThreadTitle: string; + bindInputRef: (node: HTMLInputElement | null) => void; + startEditing: (opts: { threadId: ThreadIdType; title: string }) => void; + setDraftTitle: (title: string) => void; + commitEditing: () => Promise | void; + cancelEditing: () => void; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + navigate: (...args: any[]) => any; + clearSelection: () => void; + setSelectionAnchor: (threadId: ThreadIdType) => void; + handleThreadClick: (event: MouseEvent, threadId: ThreadIdType, orderedProjectThreadIds: readonly ThreadIdType[]) => void; + handleThreadContextMenu: (threadId: ThreadIdType, position: { x: number; y: number }) => Promise; + handleMultiSelectContextMenu: (position: { x: number; y: number }) => Promise; + openPrLink: (event: React.MouseEvent, prUrl: string) => void; + formatRelativeTimeFn: (iso: string) => string; +} + +const MemoizedThreadRow = memo( + function ThreadRow({ + thread, + isActive, + isSelected, + pColor, + prByThreadId, + terminalStateByThreadId, + orderedProjectThreadIds, + selectedThreadIds, + editingThreadId, + editingThreadTitle, + bindInputRef, + startEditing, + setDraftTitle, + commitEditing, + cancelEditing, + navigate, + clearSelection, + setSelectionAnchor, + handleThreadClick, + handleThreadContextMenu, + handleMultiSelectContextMenu, + openPrLink, + formatRelativeTimeFn, + }: MemoizedThreadRowProps) { + 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 ( + + {/* Project color accent bar */} + + ); + }, + (prev, next) => { + if (prev.isActive !== next.isActive) return false; + if (prev.isSelected !== next.isSelected) return false; + if (prev.thread.title !== next.thread.title) return false; + if (prev.thread.updatedAt !== next.thread.updatedAt) return false; + if (prev.thread.createdAt !== next.thread.createdAt) return false; + if ((prev.editingThreadId === prev.thread.id) !== (next.editingThreadId === next.thread.id)) + return false; + if (prev.editingThreadTitle !== next.editingThreadTitle) return false; + // Check terminal state for this specific thread + const prevTerminal = selectThreadTerminalState(prev.terminalStateByThreadId, prev.thread.id); + const nextTerminal = selectThreadTerminalState(next.terminalStateByThreadId, next.thread.id); + if (prevTerminal.runningTerminalIds.length !== nextTerminal.runningTerminalIds.length) + return false; + // Check PR status for this specific thread + if (prev.prByThreadId.get(prev.thread.id) !== next.prByThreadId.get(next.thread.id)) + return false; + // Check if formatRelativeTimeFn changed (tied to minuteTick) + if (prev.formatRelativeTimeFn !== next.formatRelativeTimeFn) return false; + // Check thread activities (for status pill) + if (prev.thread.activities !== next.thread.activities) return false; + return true; + }, +); + export default function Sidebar() { const clientMode = useClientMode(); const projects = useStore((store) => store.projects); @@ -1108,24 +1333,6 @@ export default function Sidebar() { dragInProgressRef.current = false; }, []); - const animatedProjectListsRef = useRef(new WeakSet()); - const attachProjectListAutoAnimateRef = useCallback((node: HTMLElement | null) => { - if (!node || animatedProjectListsRef.current.has(node)) { - return; - } - autoAnimate(node, SIDEBAR_LIST_ANIMATION_OPTIONS); - animatedProjectListsRef.current.add(node); - }, []); - - const animatedThreadListsRef = useRef(new WeakSet()); - const attachThreadListAutoAnimateRef = useCallback((node: HTMLElement | null) => { - if (!node || animatedThreadListsRef.current.has(node)) { - return; - } - autoAnimate(node, SIDEBAR_LIST_ANIMATION_OPTIONS); - animatedThreadListsRef.current.add(node); - }, []); - const handleProjectTitlePointerDownCapture = useCallback(() => { suppressProjectClickAfterDragRef.current = false; }, []); @@ -1174,6 +1381,12 @@ export default function Sidebar() { }); }, [projects, threads]); const isManualProjectSorting = appSettings.sidebarProjectSortOrder === "manual"; + const minuteTick = useMinuteTick(); + const memoizedFormatRelativeTime = useCallback( + (iso: string) => formatRelativeTime(iso), + // eslint-disable-next-line react-hooks/exhaustive-deps + [minuteTick], + ); function renderProjectItem( project: (typeof sortedProjects)[number], @@ -1211,151 +1424,6 @@ export default function Sidebar() { }); const orderedProjectThreadIds = projectThreads.map((thread) => thread.id); const renderedThreads = pinnedCollapsedThread ? [pinnedCollapsedThread] : visibleThreads; - const renderThreadRow = (thread: (typeof projectThreads)[number]) => { - 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 ( - - {/* Project color accent bar */} - - ); - }; - const pColor = getProjectColor(project.id); const isDark = resolvedTheme === "dark"; @@ -1506,10 +1574,37 @@ export default function Sidebar() { - {renderedThreads.map((thread) => renderThreadRow(thread))} + {renderedThreads.map((thread) => ( + + ))} {project.expanded && hasHiddenThreads && !isThreadListExpanded && ( @@ -1571,7 +1666,7 @@ export default function Sidebar() { {!filesCollapsedByProject.has(project.id) && ( @@ -2141,7 +2236,7 @@ export default function Sidebar() { ) : ( - + {sortedProjects.map((project) => ( {renderProjectItem(project, null)}