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:
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user