diff --git a/src/renderer/components/team/activity/ActivityTimeline.tsx b/src/renderer/components/team/activity/ActivityTimeline.tsx index bc513762..3b04a1bb 100644 --- a/src/renderer/components/team/activity/ActivityTimeline.tsx +++ b/src/renderer/components/team/activity/ActivityTimeline.tsx @@ -136,6 +136,15 @@ const EMPTY_TEAM_COLOR_MAP = new Map(); const DEFAULT_COLLAPSE_MODE = 'default' as const; const VIRTUALIZER_OVERSCAN = 8; +/** + * Row count above which virtualization is worth its complexity cost. Below + * this, the direct render path is both simpler and faster (no wrapper div, + * no position: absolute, no measurement churn). Chosen so conversations under + * roughly one session of activity stay on the direct path and the virtualized + * path only activates when scrolling behavior actually starts to matter. + */ +const VIRTUALIZATION_ROW_THRESHOLD = 60; + /** * Per-kind height estimates for `estimateSize`. These are rough initial guesses * only; the virtualizer re-measures rows as they mount via `measureElement` @@ -587,14 +596,15 @@ 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. + // Virtualizer gate — activates only when the parent opts in via + // `viewport.virtualizationEnabled`, the scroll element ref is present, and + // the row count is large enough for virtualization to pay for itself. Below + // the threshold the direct render path is both simpler and faster, so we + // keep it for short lists. const shouldVirtualize = viewport?.virtualizationEnabled === true && viewport.scrollElementRef != null && - renderRows.length > 0; + renderRows.length >= VIRTUALIZATION_ROW_THRESHOLD; // DOM-measured distance from the scroll container's scroll origin to the // timeline root. Hand-summing composer/status/padding heights would drift as diff --git a/src/renderer/components/team/messages/MessagesPanel.tsx b/src/renderer/components/team/messages/MessagesPanel.tsx index c9e37131..9c9814c4 100644 --- a/src/renderer/components/team/messages/MessagesPanel.tsx +++ b/src/renderer/components/team/messages/MessagesPanel.tsx @@ -255,7 +255,10 @@ export const MessagesPanel = memo(function MessagesPanel({ scrollElementRef: activeScrollContainerRef, observerRoot: activeScrollContainerRef, scrollMargin: 0, - virtualizationEnabled: false, + // Opt into virtualization; ActivityTimeline keeps the direct render + // path for short lists and only switches to the windowed path once + // the row count crosses its internal threshold. + virtualizationEnabled: true, }; }, [activeScrollContainerRef]); const handleExpandContent = useCallback(() => { diff --git a/test/renderer/components/team/activity/ActivityTimeline.test.ts b/test/renderer/components/team/activity/ActivityTimeline.test.ts index ccea2895..11c12728 100644 --- a/test/renderer/components/team/activity/ActivityTimeline.test.ts +++ b/test/renderer/components/team/activity/ActivityTimeline.test.ts @@ -464,3 +464,129 @@ describe('ActivityTimeline viewport observerRoot', () => { scrollHost.remove(); }); }); + +describe('ActivityTimeline virtualization threshold', () => { + let container: HTMLDivElement; + + beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + document.body.innerHTML = ''; + vi.unstubAllGlobals(); + }); + + const buildMany = (count: number): InboxMessage[] => + Array.from({ length: count }, (_, i) => + makeMessage({ + messageId: `msg-${i}`, + text: `message ${i}`, + from: 'alice', + source: 'inbox', + leadSessionId: `member-session-${i}`, + }) + ); + + it('does not enter the virtualized render path when the row count is below the threshold', async () => { + const scrollHost = document.createElement('div'); + document.body.appendChild(scrollHost); + const scrollRef = { current: scrollHost }; + + const root = createRoot(container); + await act(async () => { + root.render( + React.createElement(ActivityTimeline, { + messages: buildMany(10), + teamName: 'demo-team', + viewport: { + scrollElementRef: scrollRef, + observerRoot: scrollRef, + scrollMargin: 0, + virtualizationEnabled: true, + }, + }) + ); + }); + + // Virtualized path wraps items in an absolute-position container; the + // direct path does not. Assert the wrapper is absent. + const absoluteWrapper = container.querySelector('div[style*="position: relative"]'); + expect(absoluteWrapper).toBeNull(); + // Sanity check: direct render still emits at least one activity item. + expect(container.textContent).toContain('message 0'); + + await act(async () => { + root.unmount(); + }); + scrollHost.remove(); + }); + + it('falls back to the direct render path when no viewport is provided', async () => { + const root = createRoot(container); + await act(async () => { + root.render( + React.createElement(ActivityTimeline, { + messages: buildMany(80), + teamName: 'demo-team', + }) + ); + }); + + const absoluteWrapper = container.querySelector('div[style*="position: relative"]'); + expect(absoluteWrapper).toBeNull(); + expect(container.textContent).toContain('message 0'); + + await act(async () => { + root.unmount(); + }); + }); + + it('enters the virtualized render path when row count crosses the threshold', async () => { + const scrollHost = document.createElement('div'); + document.body.appendChild(scrollHost); + const scrollRef = { current: scrollHost }; + + const root = createRoot(container); + await act(async () => { + root.render( + React.createElement(ActivityTimeline, { + messages: buildMany(80), + teamName: 'demo-team', + viewport: { + scrollElementRef: scrollRef, + observerRoot: scrollRef, + scrollMargin: 0, + virtualizationEnabled: true, + }, + }) + ); + }); + + // Default pagination caps visible rows at 30, which stays below the + // threshold, so the direct render path is in effect here. Click "show + // all" to expose every message — that pushes row count past the gate. + const showAllButton = [...container.querySelectorAll('button')].find( + (b) => b.textContent?.toLowerCase().includes('show all') + ); + expect(showAllButton).toBeDefined(); + + await act(async () => { + showAllButton?.click(); + }); + + // Virtualized path: sized container div with `position: relative` + // directly inside the timeline root. jsdom serialises style attributes + // with spaces after the colon, so match case-insensitively. + const html = container.innerHTML; + expect(html.toLowerCase()).toMatch(/position:\s*relative/); + + await act(async () => { + root.unmount(); + }); + scrollHost.remove(); + }); +});