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>
);
});