From 7c4247bc73409dbb69bbd27d35623cd19379beab Mon Sep 17 00:00:00 2001 From: Mike Date: Mon, 20 Apr 2026 00:33:37 +0500 Subject: [PATCH] perf(team): virtualizer skeleton + measured scrollMargin (gated) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../team/activity/ActivityTimeline.tsx | 117 +++++++++++++++++- 1 file changed, 116 insertions(+), 1 deletion(-) diff --git a/src/renderer/components/team/activity/ActivityTimeline.tsx b/src/renderer/components/team/activity/ActivityTimeline.tsx index 7b34e0a3..479158f0 100644 --- a/src/renderer/components/team/activity/ActivityTimeline.tsx +++ b/src/renderer/components/team/activity/ActivityTimeline.tsx @@ -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(); 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 = { + '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 (
- {renderRows.map((row) => renderTimelineRow(row))} + {shouldVirtualize ? ( +
+ {rowVirtualizer.getVirtualItems().map((virtualRow) => { + const row = renderRows[virtualRow.index]; + if (!row) return null; + return ( +
+ {renderTimelineRow(row)} +
+ ); + })} +
+ ) : ( + renderRows.map((row) => renderTimelineRow(row)) + )} {hiddenCount > 0 && (
{/* Bottom-up shadow gradient: darkest at bottom edge, fades upward */}