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