perf(team): virtualizer skeleton + measured scrollMargin (gated)
Fourth step of the virtualization plan. Adds `useVirtualizer` wiring with a DOM-measured `scrollMargin`, gated behind `viewport.virtualizationEnabled`. Dormant in this release — no caller flips the flag yet — so behavior is unchanged. - Imports `useVirtualizer` from `@tanstack/react-virtual`. Fixed per-kind estimates (`ROW_SIZE_ESTIMATES`) drive `estimateSize`. Keys come from `row.key`, so row identity matches the renderRows model. - `shouldVirtualize` requires all of: contract says enabled, a scroll element ref is present, and there is at least one row. Otherwise the render falls back to the direct `renderRows.map(...)` path from PR #72. - Measures `scrollMargin` via `ResizeObserver` on both the scroll element and the timeline root, plus `scroll` and `resize` listeners, all rAF-batched. Avoids hand-summed heights that drift when composer/status/padding change. - Virtualized path renders an absolute-positioned list inside a sized container (`height = getTotalSize()`). `translateY` subtracts `scrollMargin` so rows align to the timeline's own origin rather than the scroll container's top. This PR intentionally does *not* enable `measureElement` (PR #5) or flip `virtualizationEnabled` for any layout (PR #6) — both rely on this wiring landing first.
This commit is contained in:
parent
a43fedcaab
commit
7c4247bc73
1 changed files with 116 additions and 1 deletions
|
|
@ -6,6 +6,7 @@ import {
|
|||
areStringMapsEqual,
|
||||
} from '@renderer/utils/messageRenderEquality';
|
||||
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { Layers } from 'lucide-react';
|
||||
|
||||
import { ActivityItem, isNoiseMessage } from './ActivityItem';
|
||||
|
|
@ -133,6 +134,20 @@ const COMPACT_MESSAGES_WIDTH_PX = 400;
|
|||
const EMPTY_TEAM_NAMES: string[] = [];
|
||||
const EMPTY_TEAM_COLOR_MAP = new Map<string, string>();
|
||||
const DEFAULT_COLLAPSE_MODE = 'default' as const;
|
||||
const VIRTUALIZER_OVERSCAN = 8;
|
||||
|
||||
/**
|
||||
* Per-kind height estimates for `estimateSize`. These are rough initial guesses
|
||||
* only; the virtualizer re-measures rows as they mount via `measureElement`
|
||||
* (wired in a follow-up PR), so small inaccuracies here are self-correcting.
|
||||
* Sizes come from visually averaged steady-state heights in production layouts.
|
||||
*/
|
||||
const ROW_SIZE_ESTIMATES: Record<TimelineRow['kind'], number> = {
|
||||
'session-separator': 135,
|
||||
'compaction-divider': 50,
|
||||
'lead-thought-group': 220,
|
||||
'message-row': 140,
|
||||
};
|
||||
|
||||
function getItemSessionAnchorId(item: TimelineItem): string | undefined {
|
||||
if (item.type === 'lead-thoughts') {
|
||||
|
|
@ -572,6 +587,72 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({
|
|||
return rows;
|
||||
}, [pinnedThoughtGroup, previousSessionAnchorByIndex, startIndex, timelineItems]);
|
||||
|
||||
// Virtualizer gate — dormant unless the parent explicitly opts in via
|
||||
// `viewport.virtualizationEnabled`. The contract carries this flag so the
|
||||
// (large) virtualized render path can land before any caller flips the
|
||||
// switch, and can be toggled on per-layout once measurement is validated.
|
||||
const shouldVirtualize =
|
||||
viewport?.virtualizationEnabled === true &&
|
||||
viewport.scrollElementRef != null &&
|
||||
renderRows.length > 0;
|
||||
|
||||
// DOM-measured distance from the scroll container's scroll origin to the
|
||||
// timeline root. Hand-summing composer/status/padding heights would drift as
|
||||
// soon as any of those blocks change size; measuring the actual offset via
|
||||
// `getBoundingClientRect` keeps the virtualizer accurate without coupling
|
||||
// to layout internals.
|
||||
const [measuredScrollMargin, setMeasuredScrollMargin] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldVirtualize) return;
|
||||
const scrollEl = viewport?.scrollElementRef?.current ?? null;
|
||||
const rootEl = rootRef.current;
|
||||
if (!scrollEl || !rootEl) return;
|
||||
|
||||
let pending = false;
|
||||
let rafId: number | null = null;
|
||||
const measure = (): void => {
|
||||
if (pending) return;
|
||||
pending = true;
|
||||
rafId = requestAnimationFrame(() => {
|
||||
rafId = null;
|
||||
pending = false;
|
||||
const scrollRect = scrollEl.getBoundingClientRect();
|
||||
const rootRect = rootEl.getBoundingClientRect();
|
||||
// Distance from top of scroll content to top of timeline root. Adding
|
||||
// `scrollTop` compensates for the fact that both rects are relative
|
||||
// to the viewport at measurement time, not the scrollable content.
|
||||
const next = Math.max(0, rootRect.top - scrollRect.top + scrollEl.scrollTop);
|
||||
setMeasuredScrollMargin((prev) => (Math.abs(prev - next) < 0.5 ? prev : next));
|
||||
});
|
||||
};
|
||||
|
||||
measure();
|
||||
const scrollObserver = new ResizeObserver(measure);
|
||||
scrollObserver.observe(scrollEl);
|
||||
const rootObserver = new ResizeObserver(measure);
|
||||
rootObserver.observe(rootEl);
|
||||
scrollEl.addEventListener('scroll', measure, { passive: true });
|
||||
window.addEventListener('resize', measure);
|
||||
|
||||
return () => {
|
||||
if (rafId !== null) cancelAnimationFrame(rafId);
|
||||
scrollObserver.disconnect();
|
||||
rootObserver.disconnect();
|
||||
scrollEl.removeEventListener('scroll', measure);
|
||||
window.removeEventListener('resize', measure);
|
||||
};
|
||||
}, [shouldVirtualize, viewport?.scrollElementRef]);
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: shouldVirtualize ? renderRows.length : 0,
|
||||
getScrollElement: () => viewport?.scrollElementRef?.current ?? null,
|
||||
estimateSize: (index) => ROW_SIZE_ESTIMATES[renderRows[index]?.kind ?? 'message-row'],
|
||||
getItemKey: (index) => renderRows[index]?.key ?? `row-${index}`,
|
||||
overscan: VIRTUALIZER_OVERSCAN,
|
||||
scrollMargin: measuredScrollMargin,
|
||||
});
|
||||
|
||||
// Determine the index of the "newest" non-thought timeline item (for auto-expand).
|
||||
const newestMessageIndex = useMemo(() => {
|
||||
return findNewestMessageIndex(timelineItems);
|
||||
|
|
@ -731,7 +812,41 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({
|
|||
|
||||
return (
|
||||
<div ref={rootRef} className="space-y-1">
|
||||
{renderRows.map((row) => renderTimelineRow(row))}
|
||||
{shouldVirtualize ? (
|
||||
<div
|
||||
style={{
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const row = renderRows[virtualRow.index];
|
||||
if (!row) return null;
|
||||
return (
|
||||
<div
|
||||
key={virtualRow.key}
|
||||
data-index={virtualRow.index}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
// `translateY` is offset by scrollMargin so the virtualizer
|
||||
// positions rows relative to the timeline's own origin,
|
||||
// not the scroll container's top — otherwise rows would
|
||||
// overlap the composer / status block at the top.
|
||||
transform: `translateY(${virtualRow.start - rowVirtualizer.options.scrollMargin}px)`,
|
||||
}}
|
||||
>
|
||||
{renderTimelineRow(row)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
renderRows.map((row) => renderTimelineRow(row))
|
||||
)}
|
||||
{hiddenCount > 0 && (
|
||||
<div className="relative flex justify-center pb-3 pt-1">
|
||||
{/* Bottom-up shadow gradient: darkest at bottom edge, fades upward */}
|
||||
|
|
|
|||
Loading…
Reference in a new issue