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