From 3f5151d424d9e7c6a239c598a3bd41787bb971e3 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Fri, 3 Jul 2026 18:44:10 +0300 Subject: [PATCH] feat(ui): unify list virtualization on @tanstack/react-virtual and polish scroll behavior - Migrate sidebar session groups, git changes panel, virtualized code blocks, and JSON tree viewer from virtua to @tanstack/react-virtual; virtua remains only inside the Pierre diff viewer integration - Sidebar: preserve scroll position when virtualization enables mid-session (enable only once the ancestor scroll element is resolved, seed initial offset from its live scrollTop, render plain rows for the single pre-paint frame); disable native scroll anchoring on the sessions scroller; keep row spacing identical between plain and virtualized modes; absolute row positioning so variable-height rows cannot drift past the container - Chat: expand tool/thinking blocks downward by only adjusting scroll for rows growing above the viewport; raise the desktop history-load lead to 1.5 viewports so prepends land above the visible area - Git changes: compute the prefetch window from the first visible row, skipping overscan rows above the viewport - Sidebar rows: make the whole highlighted row area clickable, guarded against double-firing from interactive children --- .../ui/src/components/chat/MessageList.tsx | 17 +++- .../chat/hooks/useChatTimelineController.ts | 14 ++- .../message/parts/VirtualizedCodeBlock.tsx | 49 ++++++---- .../session/sidebar/SessionGroupSection.tsx | 93 ++++++++++++++++--- .../session/sidebar/SessionNodeItem.tsx | 15 ++- .../session/sidebar/SidebarProjectsList.tsx | 7 +- .../ui/src/components/ui/JsonTreeViewer.tsx | 45 +++++---- .../src/components/views/git/ChangesPanel.tsx | 81 +++++++++------- 8 files changed, 234 insertions(+), 87 deletions(-) diff --git a/packages/ui/src/components/chat/MessageList.tsx b/packages/ui/src/components/chat/MessageList.tsx index bc45683d..dbac2f50 100644 --- a/packages/ui/src/components/chat/MessageList.tsx +++ b/packages/ui/src/components/chat/MessageList.tsx @@ -50,7 +50,7 @@ const TANSTACK_MOBILE_OVERSCAN = 16; const resolveTanstackOverscan = (): number => ( isMobileSurfaceRuntime() ? TANSTACK_MOBILE_OVERSCAN : TANSTACK_OVERSCAN ); -// Post-prepend anchor hold (upstream parity): measurements of freshly +// Post-prepend anchor hold: measurements of freshly // prepended rows settle over multiple frames, so a single restore can be // invalidated by the next measurement pass. Re-assert the anchor until it // holds still for STABLE_FRAMES consecutive frames, giving up at MAX_FRAMES. @@ -61,6 +61,8 @@ const ANCHOR_HOLD_MAX_FRAMES = 180; const TANSTACK_ESTIMATE_MIN_SAMPLES = 5; const TANSTACK_ESTIMATE_MIN = 120; const TANSTACK_ESTIMATE_MAX = 1200; +// "At bottom" tolerance for resize-adjustment decisions. +const TANSTACK_AT_END_THRESHOLD_PX = 80; // Quiet-window prepend on mobile: while a touch drag or momentum scroll is // active, iOS owns the scroll position and ANY geometry change above the @@ -1098,7 +1100,7 @@ const StaticHistoryList = React.memo(({ entries, engine, contentRef, scrollRef, scrollToFn: (offset, options, instance) => { // Expose the new total height before core writes an anchor // correction so the browser does not clamp the offset to the old - // height (upstream parity). + // height. const sizeElement = sizeContainerRef.current; if (sizeElement) sizeElement.style.height = `${instance.getTotalSize()}px`; elementScroll(offset, options, instance); @@ -1111,6 +1113,17 @@ const StaticHistoryList = React.memo(({ entries, engine, contentRef, scrollRef, initialOffset: () => Number.MAX_SAFE_INTEGER, initialMeasurementsCache: initialMeasurements, }); + // Only compensate scroll for rows growing ABOVE the viewport (history + // remeasures, prepended pages). A row growing inside the viewport — + // expanding a tool call or thinking block — must grow DOWNWARD naturally; + // the end-anchored default made it expand upward. At the bottom, + // app-level auto-follow owns pinning, so skip there too instead of + // double-writing. (This is an instance field, not a constructor option.) + tanstackVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) => { + if (instance.isAtEnd(TANSTACK_AT_END_THRESHOLD_PX)) return false; + const firstVisibleIndex = instance.range?.startIndex; + return firstVisibleIndex !== undefined && item.index < firstVisibleIndex; + }; React.useEffect(() => { if (!isTanstack) return; diff --git a/packages/ui/src/components/chat/hooks/useChatTimelineController.ts b/packages/ui/src/components/chat/hooks/useChatTimelineController.ts index 8112d62b..2926ca66 100644 --- a/packages/ui/src/components/chat/hooks/useChatTimelineController.ts +++ b/packages/ui/src/components/chat/hooks/useChatTimelineController.ts @@ -59,7 +59,17 @@ export interface UseChatTimelineControllerResult { } const TURN_MODEL_CACHE_MAX = 30 -const HISTORY_SCROLL_THRESHOLD = 200 +// Desktop load-older lead distance. Trigger well before the top: the fetch +// then completes and the prepend lands ABOVE the viewport, where key-anchored +// compensation is exact and invisible. A short lead (the old 200px) let the +// user reach the estimated-height region near the absolute top mid-fetch, +// where the post-insert restore is least precise and reads as a small jump. +const HISTORY_SCROLL_THRESHOLD_MIN_PX = 1200 +const HISTORY_SCROLL_VIEWPORT_FACTOR = 1.5 +const resolveHistoryScrollThreshold = (clientHeight: number): number => Math.max( + HISTORY_SCROLL_THRESHOLD_MIN_PX, + clientHeight * HISTORY_SCROLL_VIEWPORT_FACTOR, +) const VSCODE_TURN_MODEL_CACHE_MAX = 4 const VSCODE_TURN_MODEL_CACHE_MAX_MESSAGES = 30 const MOBILE_TURN_MODEL_CACHE_MAX = 4 @@ -692,7 +702,7 @@ export const useChatTimelineController = ({ const container = scrollRef.current; if (!container) return; if (isPinnedRef.current) return; - if (container.scrollTop >= HISTORY_SCROLL_THRESHOLD) return; + if (container.scrollTop >= resolveHistoryScrollThreshold(container.clientHeight)) return; if (!historySignalsRef.current.canLoadEarlier) return; if (isLoadingOlderRef.current || pendingRevealWorkRef.current) return; diff --git a/packages/ui/src/components/chat/message/parts/VirtualizedCodeBlock.tsx b/packages/ui/src/components/chat/message/parts/VirtualizedCodeBlock.tsx index 028f889a..11e7763f 100644 --- a/packages/ui/src/components/chat/message/parts/VirtualizedCodeBlock.tsx +++ b/packages/ui/src/components/chat/message/parts/VirtualizedCodeBlock.tsx @@ -3,7 +3,7 @@ * * Renders large code/read outputs without mounting one highlighter per line: * 1. ONE worker tokenization of the whole block (off the main thread) - * 2. virtua to only render visible rows + * 2. @tanstack/react-virtual to only render visible rows * * Tokenizing the whole block at once also preserves cross-line syntax context * (multi-line strings/comments) that per-line highlighting loses. Colors resolve @@ -11,7 +11,7 @@ */ import React from 'react'; -import { Virtualizer } from 'virtua'; +import { useVirtualizer } from '@tanstack/react-virtual'; import { useThemeSystem } from '@/contexts/useThemeSystem'; import { getMarkdownSyntaxVars } from '@/components/chat/markdown/markdownTheme'; import { useWorkerHighlightedLines } from '@/components/code/useWorkerHighlightedLines'; @@ -114,28 +114,41 @@ const VirtualizedRows: React.FC = React.memo(({ const parentRef = React.useRef(null); const viewportHeight = `min(${lines.length * ROW_HEIGHT}px, ${maxHeight})`; + const virtualizer = useVirtualizer({ + count: lines.length, + getScrollElement: () => parentRef.current, + estimateSize: () => ROW_HEIGHT, + overscan: 20, + }); + const virtualItems = virtualizer.getVirtualItems(); + return (
- - {(line, index) => ( - - )} - +
+ {virtualItems.map((item) => { + const line = lines[item.index]; + if (!line) return null; + return ( +
+ +
+ ); + })} +
); }); diff --git a/packages/ui/src/components/session/sidebar/SessionGroupSection.tsx b/packages/ui/src/components/session/sidebar/SessionGroupSection.tsx index 1e854bca..3eb58a2b 100644 --- a/packages/ui/src/components/session/sidebar/SessionGroupSection.tsx +++ b/packages/ui/src/components/session/sidebar/SessionGroupSection.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Virtualizer } from 'virtua'; +import { useVirtualizer } from '@tanstack/react-virtual'; import type { Session } from '@opencode-ai/sdk/v2'; // Archived buckets routinely grow into the hundreds/thousands; virtualize @@ -581,7 +581,6 @@ function SessionGroupSectionBase(props: Props): React.ReactNode { }); const archivedVirtualContainerRef = React.useRef(null); - const archivedScrollRef = React.useRef(null); const [archivedScrollEl, setArchivedScrollEl] = React.useState(null); // Offset of the virtual container from the scroll element's content origin. // virtua reads startMargin from Virtualizer options and uses it @@ -617,7 +616,6 @@ function SessionGroupSectionBase(props: Props): React.ReactNode { React.useLayoutEffect(() => { if (!shouldVirtualize) { if (archivedScrollEl !== null) setArchivedScrollEl(null); - archivedScrollRef.current = null; if (archivedScrollMargin !== 0) setArchivedScrollMargin(0); return; } @@ -632,7 +630,6 @@ function SessionGroupSectionBase(props: Props): React.ReactNode { if (providedScrollEl && providedScrollEl.contains(container)) { scrollEl = providedScrollEl; if (scrollEl !== archivedScrollEl) { - archivedScrollRef.current = scrollEl; setArchivedScrollEl(scrollEl); return; } @@ -649,7 +646,6 @@ function SessionGroupSectionBase(props: Props): React.ReactNode { el = el.parentElement; } if (scrollEl !== archivedScrollEl) { - archivedScrollRef.current = scrollEl; setArchivedScrollEl(scrollEl); return; } @@ -661,6 +657,31 @@ function SessionGroupSectionBase(props: Props): React.ReactNode { setArchivedScrollMargin((prev) => (Math.abs(prev - offset) < 1 ? prev : offset)); }); + // The scroll element is an ANCESTOR of this section (the sidebar's + // ScrollableOverlay), so scrollMargin translates its scrollTop into + // container-relative coordinates — the tanstack equivalent of virtua's + // startMargin this replaces. + // Enable ONLY once the ancestor scroll element is resolved. While the + // virtualizer is disabled the core resets its cached scroll offset, so the + // first enabled read takes initialOffset() from the LIVE scrollTop below — + // making the core's attach-time scrollTo target the current position (a + // visual no-op) instead of a stale 0 that reset the sidebar to the top. + // The core only learns the offset from scroll events after that, so this + // initial seeding is what makes the first render window correct too. + const virtualizerReady = shouldVirtualize && archivedScrollEl !== null; + const sessionVirtualizer = useVirtualizer({ + count: visibleSessions.length, + enabled: virtualizerReady, + getScrollElement: () => archivedScrollEl, + initialOffset: () => archivedScrollEl?.scrollTop ?? 0, + estimateSize: () => ARCHIVED_ROW_ESTIMATE_PX, + // Expanded parents render children inline and dwarf the row estimate; + // widen the window so their extra height stays covered. + overscan: hasExpandedParent ? 20 : 8, + scrollMargin: archivedScrollMargin, + getItemKey: (index) => visibleSessions[index]?.session.id ?? index, + }); + // Hooks below MUST stay above the search-empty early-return so they // fire in the same order every render — rules-of-hooks. const collectGroupSessions = React.useCallback((nodes: SessionNode[]): Session[] => { @@ -913,21 +934,63 @@ function SessionGroupSectionBase(props: Props): React.ReactNode { {renderFolderItems()} {shouldVirtualize ? (
- - {(node) => renderSessionNode(node, 0, group.directory, projectId, group.isArchivedBucket === true, undefined, 'project', { + {!virtualizerReady ? ( + // At most one pre-paint frame: this wrapper must exist for the + // layout effect to resolve the ancestor scroll element, which + // re-renders synchronously before paint. Rendering the plain rows + // meanwhile keeps the container's height real so the scroller + // never collapses/clamps during the flip. + visibleSessions.map((node) => renderSessionNode(node, 0, group.directory, projectId, group.isArchivedBucket === true, undefined, 'project', { subtreeContainsActive, subtreeContainsEditing, menuOpenSessionId, nodeStructureKey: resolveNodeStructureKey(node), childRenderExtrasFor, - }) as React.ReactElement} - + })) + ) : ( +
+ {/* Absolutely positioned rows (canonical tanstack layout): with + variable-height rows, flow-stacking can drift from the computed + total height until measurements settle and overlap the content + below the group. Per-item offsets cannot drift. item.start + includes scrollMargin (ancestor-scroll offset), so subtract it. */} + {sessionVirtualizer.getVirtualItems().map((item) => { + const node = visibleSessions[item.index]; + if (!node) return null; + return ( +
+ {renderSessionNode(node, 0, group.directory, projectId, group.isArchivedBucket === true, undefined, 'project', { + subtreeContainsActive, + subtreeContainsEditing, + menuOpenSessionId, + nodeStructureKey: resolveNodeStructureKey(node), + childRenderExtrasFor, + })} +
+ ); + })} +
+ )}
) : ( visibleSessions.map((node) => renderSessionNode(node, 0, group.directory, projectId, group.isArchivedBucket === true, undefined, 'project', { diff --git a/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx b/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx index 89dc1620..3fad43f6 100644 --- a/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx +++ b/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx @@ -747,6 +747,18 @@ function SessionNodeItemComponent(props: Props): React.ReactNode { handleSessionSelect(session.id, sessionDirectory, projectId); }; + // The selection/active highlight covers the WHOLE row box (gutter, edge + // paddings), while the primary click target is the inner title button. + // Make the rest of the highlighted box clickable too — but only for clicks + // that did not originate from an interactive child (title button, chevron, + // action menu), so nothing double-fires. + const handleRowBackgroundClick = (event: React.MouseEvent) => { + if (event.defaultPrevented) return; + const target = event.target as HTMLElement | null; + if (target?.closest('button, a, input, [role="menuitem"], [role="menu"]')) return; + handleRowSelect(event as unknown as React.MouseEvent); + }; + const handleRowMouseDown = (event: React.MouseEvent) => { if (event.button === 2 || (event.button === 0 && event.ctrlKey && !selectionModeEnabled)) { suppressNextSelectRef.current = true; @@ -974,8 +986,9 @@ function SessionNodeItemComponent(props: Props): React.ReactNode { data-session-row={session.id} data-session-scope={sessionDirectory ?? ''} data-session-archived={archivedBucket ? '1' : '0'} + onClick={handleRowBackgroundClick} className={cn( - 'group relative my-0.5 flex items-center rounded-md py-1 pr-1.5', + 'group relative my-0.5 flex cursor-pointer items-center rounded-md py-1 pr-1.5', // Pull the row box left into the container gutter so the // selection highlight covers the chevron/status markers // (which sit in that gutter), then re-pad so the title text diff --git a/packages/ui/src/components/session/sidebar/SidebarProjectsList.tsx b/packages/ui/src/components/session/sidebar/SidebarProjectsList.tsx index c6b94eef..849987b0 100644 --- a/packages/ui/src/components/session/sidebar/SidebarProjectsList.tsx +++ b/packages/ui/src/components/session/sidebar/SidebarProjectsList.tsx @@ -142,7 +142,12 @@ export function SidebarProjectsList(props: Props): React.ReactNode { } return ( - + // [overflow-anchor:none] — the browser's native scroll anchoring otherwise + // latches onto content BELOW a growing session group (e.g. the "Show more" + // button) and holds it in place, which makes newly revealed sessions look + // like they insert upward. With anchoring off, scrollTop stays put and new + // rows appear below naturally. + {props.topContent} {props.showOnlyMainWorkspace ? (
diff --git a/packages/ui/src/components/ui/JsonTreeViewer.tsx b/packages/ui/src/components/ui/JsonTreeViewer.tsx index 9110e5af..3712328d 100644 --- a/packages/ui/src/components/ui/JsonTreeViewer.tsx +++ b/packages/ui/src/components/ui/JsonTreeViewer.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Virtualizer } from 'virtua'; +import { useVirtualizer } from '@tanstack/react-virtual'; import { parseJsonToTree, @@ -203,6 +203,14 @@ const JsonTreeViewer = React.forwardRef<{ expandAll: () => void; collapseAll: () const shouldVirtualize = flatNodes.length > VIRTUALIZE_THRESHOLD; const parentRef = React.useRef(null); + const virtualizer = useVirtualizer({ + count: flatNodes.length, + enabled: shouldVirtualize, + getScrollElement: () => parentRef.current, + estimateSize: () => ROW_HEIGHT, + overscan: 20, + getItemKey: (index) => flatNodes[index]?.node.id ?? index, + }); const handleToggle = React.useCallback((id: string) => { setCollapsedPaths((prev) => { @@ -238,21 +246,26 @@ const JsonTreeViewer = React.forwardRef<{ expandAll: () => void; collapseAll: () className={className} style={{ maxHeight, overflow: 'auto' }} > - - {(flatNode) => ( - - )} - +
+ {virtualizer.getVirtualItems().map((item) => { + const flatNode = flatNodes[item.index]; + if (!flatNode) return null; + return ( +
+ +
+ ); + })} +
); } diff --git a/packages/ui/src/components/views/git/ChangesPanel.tsx b/packages/ui/src/components/views/git/ChangesPanel.tsx index 51a549d3..23a48105 100644 --- a/packages/ui/src/components/views/git/ChangesPanel.tsx +++ b/packages/ui/src/components/views/git/ChangesPanel.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Virtualizer, type VirtualizerHandle } from 'virtua'; +import { useVirtualizer } from '@tanstack/react-virtual'; import { Button } from '@/components/ui/button'; import { Dialog, @@ -195,16 +195,26 @@ export const ChangesPanel: React.FC = ({ const rowCount = rows.length; const shouldVirtualize = rowCount >= CHANGE_LIST_VIRTUALIZE_THRESHOLD; - const rowVirtualizerRef = React.useRef(null); - const [visibleStartIndex, setVisibleStartIndex] = React.useState(0); - - const updateVisibleStartIndex = React.useCallback((offset: number) => { - const virtualizer = rowVirtualizerRef.current; - const next = virtualizer - ? virtualizer.findItemIndex(offset) - : Math.floor(offset / CHANGE_ROW_ESTIMATE_PX); - setVisibleStartIndex((previous) => (previous === next ? previous : next)); - }, []); + const rowVirtualizer = useVirtualizer({ + count: rowCount, + enabled: shouldVirtualize, + getScrollElement: () => scrollRef.current, + estimateSize: () => CHANGE_ROW_ESTIMATE_PX, + overscan: 12, + getItemKey: (index) => rows[index]?.key ?? index, + }); + const virtualRows = rowVirtualizer.getVirtualItems(); + // First VISIBLE row index drives the visible-path prefetch window (the + // virtua findItemIndex/onScroll pair this replaces). virtualRows starts at + // the overscan boundary — up to `overscan` rows above the viewport — so + // skip rows that end above the current scroll offset; otherwise the + // prefetch budget leaks to offscreen files above the viewport. + const visibleStartIndex = React.useMemo(() => { + if (!shouldVirtualize) return 0; + const scrollTop = scrollRef.current?.scrollTop ?? 0; + const firstVisible = virtualRows.find((item) => item.end > scrollTop); + return firstVisible?.index ?? 0; + }, [shouldVirtualize, virtualRows, scrollRef]); React.useEffect(() => { if (!onVisiblePathsChange) { @@ -481,27 +491,34 @@ export const ChangesPanel: React.FC = ({ className="overlay-scrollbar-target overlay-scrollbar-container min-h-0 w-full flex-1 overflow-x-hidden overflow-y-auto" > {shouldVirtualize ? ( - - {(row, index) => ( -
- {renderRow(row, index === 0)} -
- )} -
+
+ {/* Absolutely positioned rows: variable-height rows can drift from + the computed total height under flow stacking until measured. */} + {virtualRows.map((item) => { + const row = rows[item.index]; + if (!row) return null; + return ( +
+ {renderRow(row, item.index === 0)} +
+ ); + })} +
) : (
{rows.map((row, index) => (