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
This commit is contained in:
Bohdan Triapitsyn
2026-07-03 18:44:10 +03:00
parent 2bce38cfbb
commit 3f5151d424
8 changed files with 234 additions and 87 deletions
@@ -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;
@@ -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;
@@ -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<VirtualizedRowsProps> = React.memo(({
const parentRef = React.useRef<HTMLDivElement>(null);
const viewportHeight = `min(${lines.length * ROW_HEIGHT}px, ${maxHeight})`;
const virtualizer = useVirtualizer<HTMLDivElement, HTMLDivElement>({
count: lines.length,
getScrollElement: () => parentRef.current,
estimateSize: () => ROW_HEIGHT,
overscan: 20,
});
const virtualItems = virtualizer.getVirtualItems();
return (
<div
ref={parentRef}
className="typography-code font-mono w-full min-w-0"
style={{ ...(syntaxVars as React.CSSProperties), height: viewportHeight, maxHeight, overflow: 'auto' }}
>
<Virtualizer
data={lines}
itemSize={ROW_HEIGHT}
bufferSize={ROW_HEIGHT * 20}
scrollRef={parentRef}
>
{(line, index) => (
<Row
key={index}
line={line}
html={highlighted?.[index]}
showLineNumbers={showLineNumbers}
style={lineStyles?.(line)}
/>
)}
</Virtualizer>
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualItems.map((item) => {
const line = lines[item.index];
if (!line) return null;
return (
<div
key={item.index}
data-index={item.index}
ref={virtualizer.measureElement}
style={{ position: 'absolute', top: 0, left: 0, width: '100%', transform: `translateY(${item.start}px)` }}
>
<Row
line={line}
html={highlighted?.[item.index]}
showLineNumbers={showLineNumbers}
style={lineStyles?.(line)}
/>
</div>
);
})}
</div>
</div>
);
});
@@ -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<HTMLDivElement | null>(null);
const archivedScrollRef = React.useRef<HTMLElement | null>(null);
const [archivedScrollEl, setArchivedScrollEl] = React.useState<HTMLElement | null>(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<HTMLElement, HTMLDivElement>({
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 ? (
<div ref={archivedVirtualContainerRef}>
<Virtualizer
data={visibleSessions}
itemSize={ARCHIVED_ROW_ESTIMATE_PX}
bufferSize={hasExpandedParent ? ARCHIVED_ROW_ESTIMATE_PX * 20 : ARCHIVED_ROW_ESTIMATE_PX * 8}
scrollRef={archivedScrollRef}
startMargin={archivedScrollMargin}
>
{(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}
</Virtualizer>
}))
) : (
<div style={{ height: sessionVirtualizer.getTotalSize(), position: 'relative' }}>
{/* 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 (
<div
key={node.session.id}
data-index={item.index}
ref={sessionVirtualizer.measureElement}
// Rows carry my-0.5 (2px), which COLLAPSES to 2px between
// neighbors in normal flow but cannot collapse across
// isolated virtualized wrappers — spacing doubles to 4px the
// moment virtualization kicks in. Replace the row margin
// with 1px per side (no collapse, 1+1 = the same visual 2px
// gap). The [data-session-row] selector reaches the row
// through the dnd/context-menu wrappers at any depth and
// keeps nested child rows consistent too.
className="[&_[data-session-row]]:my-px"
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${item.start - archivedScrollMargin}px)`,
}}
>
{renderSessionNode(node, 0, group.directory, projectId, group.isArchivedBucket === true, undefined, 'project', {
subtreeContainsActive,
subtreeContainsEditing,
menuOpenSessionId,
nodeStructureKey: resolveNodeStructureKey(node),
childRenderExtrasFor,
})}
</div>
);
})}
</div>
)}
</div>
) : (
visibleSessions.map((node) => renderSessionNode(node, 0, group.directory, projectId, group.isArchivedBucket === true, undefined, 'project', {
@@ -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<HTMLDivElement>) => {
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<HTMLButtonElement>);
};
const handleRowMouseDown = (event: React.MouseEvent<HTMLButtonElement>) => {
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
@@ -142,7 +142,12 @@ export function SidebarProjectsList(props: Props): React.ReactNode {
}
return (
<ScrollableOverlay ref={scrollContainerRef} useScrollShadow scrollShadowSize={96} outerClassName="flex-1 min-h-0" className={cn('space-y-1 pb-1 pl-2.5 pr-2', props.mobileVariant ? '' : '')}>
// [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.
<ScrollableOverlay ref={scrollContainerRef} useScrollShadow scrollShadowSize={96} outerClassName="flex-1 min-h-0" className={cn('space-y-1 pb-1 pl-2.5 pr-2 [overflow-anchor:none]', props.mobileVariant ? '' : '')}>
{props.topContent}
{props.showOnlyMainWorkspace ? (
<div className="space-y-[0.6rem] py-1">
@@ -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<HTMLDivElement>(null);
const virtualizer = useVirtualizer<HTMLDivElement, HTMLDivElement>({
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' }}
>
<Virtualizer
data={flatNodes}
itemSize={ROW_HEIGHT}
bufferSize={ROW_HEIGHT * 20}
scrollRef={parentRef}
>
{(flatNode) => (
<JsonRow
key={flatNode.node.id}
flatNode={flatNode}
onToggle={handleToggle}
onCopyPath={onCopyPath}
/>
)}
</Virtualizer>
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map((item) => {
const flatNode = flatNodes[item.index];
if (!flatNode) return null;
return (
<div
key={flatNode.node.id}
data-index={item.index}
ref={virtualizer.measureElement}
style={{ position: 'absolute', top: 0, left: 0, width: '100%', transform: `translateY(${item.start}px)` }}
>
<JsonRow
flatNode={flatNode}
onToggle={handleToggle}
onCopyPath={onCopyPath}
/>
</div>
);
})}
</div>
</div>
);
}
@@ -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<ChangesPanelProps> = ({
const rowCount = rows.length;
const shouldVirtualize = rowCount >= CHANGE_LIST_VIRTUALIZE_THRESHOLD;
const rowVirtualizerRef = React.useRef<VirtualizerHandle | null>(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<HTMLDivElement, HTMLDivElement>({
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<ChangesPanelProps> = ({
className="overlay-scrollbar-target overlay-scrollbar-container min-h-0 w-full flex-1 overflow-x-hidden overflow-y-auto"
>
{shouldVirtualize ? (
<Virtualizer
ref={rowVirtualizerRef}
data={rows}
itemSize={CHANGE_ROW_ESTIMATE_PX}
bufferSize={CHANGE_ROW_ESTIMATE_PX * 12}
scrollRef={scrollRef}
onScroll={updateVisibleStartIndex}
>
{(row, index) => (
<div
key={row.key}
className={cn(
'relative',
showDivider(index) &&
'before:pointer-events-none before:absolute before:left-0 before:right-2 before:top-0 before:border-t before:border-border/60'
)}
>
{renderRow(row, index === 0)}
</div>
)}
</Virtualizer>
<div style={{ height: rowVirtualizer.getTotalSize(), position: 'relative' }}>
{/* 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 (
<div
key={row.key}
data-index={item.index}
ref={rowVirtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${item.start}px)`,
}}
className={cn(
showDivider(item.index) &&
'before:pointer-events-none before:absolute before:left-0 before:right-2 before:top-0 before:border-t before:border-border/60'
)}
>
{renderRow(row, item.index === 0)}
</div>
);
})}
</div>
) : (
<div role="list" aria-label={t('gitView.changes.changedFilesAria')}>
{rows.map((row, index) => (