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
@@ -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', {