From d4f518e8c5fe134672b2f2dd17c3b24c3aa8cc0e Mon Sep 17 00:00:00 2001 From: Mike Date: Mon, 20 Apr 2026 00:17:04 +0500 Subject: [PATCH 1/6] refactor(team): viewport contract + observer root for ActivityTimeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second step of the virtualization plan. No virtualization yet. This PR makes IntersectionObserver-based visibility tracking correct inside scroll containers (sidebar, bottom-sheet), which is a prerequisite for virtualizing the timeline. - Introduces `TimelineViewport` — a grouped contract passed as a single `viewport` prop on `ActivityTimeline`. Holds `scrollElementRef`, `observerRoot`, `scrollMargin`, and `virtualizationEnabled`. - `MessageRowWithObserver` and `LeadThoughtsGroupRow` now create their `IntersectionObserver` with `root = observerRoot?.current ?? null` instead of defaulting to the document viewport. Unread marking now fires when rows enter their real scroll parent. - `MessagesPanel` resolves the active scroll owner from `position` (inline from parent ref, sidebar from `sidebarScrollRef`, bottom-sheet from `bottomSheetScrollRef`) and passes it into ActivityTimeline. - Tests: stubs `IntersectionObserver` to capture `options.root` and asserts null when no viewport is passed, and the provided element when `viewport.observerRoot` is set. `scrollMargin` and `virtualizationEnabled` are included in the contract but not consumed yet — they land in follow-up PRs (#4/#5). --- .../team/activity/ActivityTimeline.tsx | 54 +++++++- .../team/activity/LeadThoughtsGroup.tsx | 18 ++- .../team/messages/MessagesPanel.tsx | 28 +++- .../team/activity/ActivityTimeline.test.ts | 129 +++++++++++++++++- 4 files changed, 221 insertions(+), 8 deletions(-) diff --git a/src/renderer/components/team/activity/ActivityTimeline.tsx b/src/renderer/components/team/activity/ActivityTimeline.tsx index abc3313d..ded03449 100644 --- a/src/renderer/components/team/activity/ActivityTimeline.tsx +++ b/src/renderer/components/team/activity/ActivityTimeline.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { type RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { areInboxMessagesEquivalentForRender, @@ -24,6 +24,34 @@ import { useNewItemKeys } from './useNewItemKeys'; import type { TimelineItem } from './LeadThoughtsGroup'; import type { InboxMessage, ResolvedTeamMember } from '@shared/types'; +/** + * Viewport contract — describes the scroll container that hosts the timeline + * and how ActivityTimeline should report visibility against it. When omitted, + * ActivityTimeline falls back to the document viewport (current behavior). + * + * This contract is grouped intentionally so consumers pass a single coherent + * object rather than threading several refs and flags. Virtualizer wiring + * lands in a follow-up; for now only `observerRoot` has an observable effect. + */ +export interface TimelineViewport { + /** The element that actually scrolls. */ + scrollElementRef: RefObject; + /** + * Root element for IntersectionObserver-based visibility tracking. + * Typically the same node as `scrollElementRef`, but left separate so + * future code can observe a more specific inner container when needed. + */ + observerRoot?: RefObject; + /** + * Distance from the scroll container's scroll origin to the timeline root, + * measured from the DOM. Zero in this release; used by the virtualizer in a + * follow-up change. + */ + scrollMargin?: number; + /** Enable virtualization (wired in a follow-up; ignored for now). */ + virtualizationEnabled?: boolean; +} + interface ActivityTimelineProps { messages: InboxMessage[]; teamName: string; @@ -66,6 +94,14 @@ interface ActivityTimelineProps { onExpandItem?: (key: string) => void; /** Called when ExpandableContent is expanded via "Show more" in any ActivityItem. */ onExpandContent?: () => void; + /** + * Optional viewport contract. When provided, IntersectionObserver uses the + * passed `observerRoot` instead of the document viewport, which is required + * for correctness inside scrollable layouts (sidebar, bottom-sheet) where + * the row may be clipped by its scroll parent while still intersecting the + * page viewport. + */ + viewport?: TimelineViewport; } const VIEWPORT_THRESHOLD = 0.15; @@ -141,6 +177,7 @@ const MessageRowWithObserver = ({ onExpand, expandItemKey, onExpandContent, + observerRoot, }: { message: InboxMessage; teamName: string; @@ -170,6 +207,7 @@ const MessageRowWithObserver = ({ onExpand?: (key: string) => void; expandItemKey?: string; onExpandContent?: () => void; + observerRoot?: RefObject; }): React.JSX.Element => { const ref = useRef(null); const reportedRef = useRef(false); @@ -185,6 +223,10 @@ const MessageRowWithObserver = ({ if (!onVisible) return; const el = ref.current; if (!el) return; + // Resolve the observer root at effect-time. Falls back to the document + // viewport (null) when no root is provided — preserves pre-contract + // behavior for layouts without a known scroll owner. + const root = observerRoot?.current ?? null; const observer = new IntersectionObserver( ([entry]) => { if (!entry?.isIntersecting) return; @@ -195,11 +237,11 @@ const MessageRowWithObserver = ({ reportedRef.current = true; cb(msg); }, - { threshold: VIEWPORT_THRESHOLD, rootMargin: '0px' } + { root, threshold: VIEWPORT_THRESHOLD, rootMargin: '0px' } ); observer.observe(el); return () => observer.disconnect(); - }, [onVisible]); + }, [onVisible, observerRoot]); return ( @@ -265,6 +307,7 @@ const MemoizedMessageRowWithObserver = React.memo( prev.onExpand === next.onExpand && prev.expandItemKey === next.expandItemKey && prev.onExpandContent === next.onExpandContent && + prev.observerRoot === next.observerRoot && areInboxMessagesEquivalentForRender(prev.message, next.message) ); @@ -291,7 +334,9 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({ onTeamClick, onExpandItem, onExpandContent, + viewport, }: ActivityTimelineProps): React.JSX.Element { + const observerRoot = viewport?.observerRoot ?? viewport?.scrollElementRef; const [visibleCount, setVisibleCount] = useState(MESSAGES_PAGE_SIZE); const rootRef = useRef(null); const [compactHeader, setCompactHeader] = useState(false); @@ -519,6 +564,7 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({ leadContextUpdatedAt={pinnedCanBeLive ? leadContextUpdatedAt : undefined} isNew={newItemKeys.has(itemKey)} onVisible={onMessageVisible} + observerRoot={observerRoot} zebraShade={zebraShadeSet.has(0)} collapseMode={collapseProps.collapseMode} isCollapsed={collapseProps.isCollapsed} @@ -579,6 +625,7 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({ canBeLive={false} isNew={newItemKeys.has(itemKey)} onVisible={onMessageVisible} + observerRoot={observerRoot} zebraShade={zebraShadeSet.has(realIndex)} collapseMode={collapseProps.collapseMode} isCollapsed={collapseProps.isCollapsed} @@ -650,6 +697,7 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({ onTeamClick={onTeamClick} onExpand={compactHeader ? onExpandItem : undefined} expandItemKey={compactHeader ? messageKey : undefined} + observerRoot={observerRoot} onExpandContent={onExpandContent} /> diff --git a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx index 9ee1adc1..bcb1626f 100644 --- a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx +++ b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx @@ -1,6 +1,7 @@ import { type JSX, memo, + type RefObject, useCallback, useEffect, useLayoutEffect, @@ -157,6 +158,14 @@ interface LeadThoughtsGroupRowProps { memberColor?: string; isNew?: boolean; onVisible?: (message: InboxMessage) => void; + /** + * Root element for IntersectionObserver-based visibility tracking. When + * omitted, the observer falls back to the document viewport — correct for + * top-level renders, incorrect when the row is inside a scroll container + * (sidebar, bottom-sheet) that can clip the row while the document + * viewport still contains it. + */ + observerRoot?: RefObject; /** When false, the live indicator is always off (for historical thought groups). */ canBeLive?: boolean; /** Whether the owning team is currently alive. */ @@ -528,6 +537,7 @@ const LeadThoughtsGroupRowComponent = ({ memberColor, isNew, onVisible, + observerRoot, canBeLive, isTeamAlive, leadActivity, @@ -637,6 +647,9 @@ const LeadThoughtsGroupRowComponent = ({ if (!onVisible) return; const el = ref.current; if (!el) return; + // Resolve observer root at effect-time. Falls back to the document + // viewport when no root is provided — preserves pre-contract behavior. + const root = observerRoot?.current ?? null; const observer = new IntersectionObserver( ([entry]) => { if (!entry?.isIntersecting) return; @@ -647,11 +660,11 @@ const LeadThoughtsGroupRowComponent = ({ } reportedCountRef.current = thoughts.length; }, - { threshold: VIEWPORT_THRESHOLD, rootMargin: '0px' } + { root, threshold: VIEWPORT_THRESHOLD, rootMargin: '0px' } ); observer.observe(el); return () => observer.disconnect(); - }, [onVisible, thoughts]); + }, [onVisible, observerRoot, thoughts]); const clearPendingScrollSync = useCallback(() => { if (scrollSyncFrameRef.current !== null) { @@ -1134,5 +1147,6 @@ export const LeadThoughtsGroupRow = memo( prev.compactHeader === next.compactHeader && prev.onExpand === next.onExpand && prev.expandItemKey === next.expandItemKey && + prev.observerRoot === next.observerRoot && areThoughtGroupsEquivalent(prev.group, next.group) ); diff --git a/src/renderer/components/team/messages/MessagesPanel.tsx b/src/renderer/components/team/messages/MessagesPanel.tsx index 98ec9c66..c9e37131 100644 --- a/src/renderer/components/team/messages/MessagesPanel.tsx +++ b/src/renderer/components/team/messages/MessagesPanel.tsx @@ -36,7 +36,7 @@ import { } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; -import { ActivityTimeline } from '../activity/ActivityTimeline'; +import { ActivityTimeline, type TimelineViewport } from '../activity/ActivityTimeline'; import { getThoughtGroupKey, groupTimelineItems } from '../activity/LeadThoughtsGroup'; import { MessageExpandDialog } from '../activity/MessageExpandDialog'; import { CollapsibleTeamSection } from '../CollapsibleTeamSection'; @@ -183,6 +183,7 @@ export const MessagesPanel = memo(function MessagesPanel({ onReplyToMessage, onRestartTeam, onTaskIdClick, + inlineScrollContainerRef, }: MessagesPanelProps): React.JSX.Element { const { sendTeamMessage, @@ -235,6 +236,28 @@ export const MessagesPanel = memo(function MessagesPanel({ // Held here so future viewport consumers (virtualization) can observe the // true scrolling element in bottom-sheet mode. const bottomSheetScrollRef = useRef(null); + + // Resolve the active scroll owner for the current layout. This is the + // ref that ActivityTimeline's IntersectionObserver will use as its root, + // so visibility is measured against the real scroll container rather + // than the document viewport. Virtualizer consumers will hook into the + // same ref in a follow-up change. + const activeScrollContainerRef = + position === 'inline' + ? (inlineScrollContainerRef ?? null) + : position === 'sidebar' + ? sidebarScrollRef + : bottomSheetScrollRef; + + const activityTimelineViewport = useMemo(() => { + if (!activeScrollContainerRef) return undefined; + return { + scrollElementRef: activeScrollContainerRef, + observerRoot: activeScrollContainerRef, + scrollMargin: 0, + virtualizationEnabled: false, + }; + }, [activeScrollContainerRef]); const handleExpandContent = useCallback(() => { // no-op: user is reading expanded content, not composing }, []); @@ -678,6 +701,7 @@ export const MessagesPanel = memo(function MessagesPanel({ onTaskIdClick={onTaskIdClick} onExpandItem={handleExpandItem} onExpandContent={handleExpandContent} + viewport={activityTimelineViewport} /> {hasMore && (
@@ -863,6 +887,7 @@ export const MessagesPanel = memo(function MessagesPanel({ onTaskIdClick={onTaskIdClick} onExpandItem={handleExpandItem} onExpandContent={handleExpandContent} + viewport={activityTimelineViewport} /> {hasMore && (
@@ -1150,6 +1175,7 @@ export const MessagesPanel = memo(function MessagesPanel({ onTaskIdClick={onTaskIdClick} onExpandItem={handleExpandItem} onExpandContent={handleExpandContent} + viewport={activityTimelineViewport} /> {hasMore && (
diff --git a/test/renderer/components/team/activity/ActivityTimeline.test.ts b/test/renderer/components/team/activity/ActivityTimeline.test.ts index d02b5bb1..3b8bb73f 100644 --- a/test/renderer/components/team/activity/ActivityTimeline.test.ts +++ b/test/renderer/components/team/activity/ActivityTimeline.test.ts @@ -13,8 +13,13 @@ vi.mock('@renderer/components/team/activity/ActivityItem', () => ({ vi.mock('@renderer/components/team/activity/AnimatedHeightReveal', () => ({ ENTRY_REVEAL_ANIMATION_MS: 220, - AnimatedHeightReveal: ({ children }: { children: React.ReactNode }) => - React.createElement(React.Fragment, null, children), + AnimatedHeightReveal: ({ + children, + containerRef, + }: { + children: React.ReactNode; + containerRef?: React.RefObject; + }) => React.createElement('div', { ref: containerRef }, children), })); vi.mock('@renderer/components/team/activity/useNewItemKeys', () => ({ @@ -285,3 +290,123 @@ describe('ActivityTimeline session separators', () => { }); }); }); + +describe('ActivityTimeline viewport observerRoot', () => { + let container: HTMLDivElement; + let capturedRoots: Array; + let originalIntersectionObserver: + | typeof globalThis.IntersectionObserver + | undefined; + + beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + container = document.createElement('div'); + document.body.appendChild(container); + + capturedRoots = []; + originalIntersectionObserver = globalThis.IntersectionObserver; + class FakeIntersectionObserver { + public readonly root: Element | Document | null; + public readonly rootMargin: string; + public readonly thresholds: ReadonlyArray; + constructor( + _callback: IntersectionObserverCallback, + options?: IntersectionObserverInit + ) { + this.root = options?.root ?? null; + this.rootMargin = options?.rootMargin ?? '0px'; + this.thresholds = Array.isArray(options?.threshold) + ? options.threshold + : typeof options?.threshold === 'number' + ? [options.threshold] + : [0]; + capturedRoots.push(this.root); + } + observe(): void {} + unobserve(): void {} + disconnect(): void {} + takeRecords(): IntersectionObserverEntry[] { + return []; + } + } + vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver); + }); + + afterEach(() => { + if (originalIntersectionObserver) { + globalThis.IntersectionObserver = originalIntersectionObserver; + } + container.remove(); + document.body.innerHTML = ''; + vi.unstubAllGlobals(); + }); + + it('creates IntersectionObservers with root=null when no viewport is passed', async () => { + const root = createRoot(container); + const messages: InboxMessage[] = [ + makeMessage({ + messageId: 'msg-1', + text: 'hello', + from: 'alice', + source: 'inbox', + }), + ]; + + await act(async () => { + root.render( + React.createElement(ActivityTimeline, { + messages, + teamName: 'demo-team', + onMessageVisible: () => {}, + }) + ); + }); + + expect(capturedRoots.length).toBeGreaterThan(0); + expect(capturedRoots.every((r) => r === null)).toBe(true); + + await act(async () => { + root.unmount(); + }); + }); + + it('creates IntersectionObservers with the provided root when viewport.observerRoot is set', async () => { + const scrollHost = document.createElement('div'); + document.body.appendChild(scrollHost); + const scrollRef = { current: scrollHost }; + + const root = createRoot(container); + const messages: InboxMessage[] = [ + makeMessage({ + messageId: 'msg-1', + text: 'hello', + from: 'alice', + source: 'inbox', + }), + ]; + + await act(async () => { + root.render( + React.createElement(ActivityTimeline, { + messages, + teamName: 'demo-team', + onMessageVisible: () => {}, + viewport: { + scrollElementRef: scrollRef, + observerRoot: scrollRef, + scrollMargin: 0, + virtualizationEnabled: false, + }, + }) + ); + }); + + expect(capturedRoots.length).toBeGreaterThan(0); + expect(capturedRoots.every((r) => r === scrollHost)).toBe(true); + + await act(async () => { + root.unmount(); + }); + scrollHost.remove(); + }); +}); From a43fedcaab8251745c732d1504fa1726d8cbc475 Mon Sep 17 00:00:00 2001 From: Mike Date: Mon, 20 Apr 2026 00:25:28 +0500 Subject: [PATCH 2/6] refactor(team): flatten ActivityTimeline render into atomic rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third step of the virtualization plan. Pure refactor — no UI change, no virtualization yet. Prepares the timeline for row-level windowing. - Introduces `TimelineRow`, a discriminated union of `session-separator`, `lead-thought-group` (pinned and non-pinned), `compaction-divider`, and `message-row`. Each row maps 1:1 to a single visual element. - Adds a `renderRows` useMemo that walks `timelineItems` once and emits atomic rows, hoisting session separators out of the Fragment bundle that used to pair them with their owning item. This is the shape a windowing layer needs: each row measurable and addressable independently. - Extracts a `renderTimelineRow(row)` helper that switches on `row.kind` and returns the same JSX the previous inline render produced. Logic per kind is identical — keys, memoization, collapse props, pinned thought "live" semantics — so there is no visual diff. - The render body collapses from two blocks (pinned + `.slice().map()`) into a single `renderRows.map(renderTimelineRow)` call. Follow-ups will virtualize `renderRows` with measured row heights and tighten observer/animation wiring; pagination, collapse state, zebra striping, and `groupTimelineItems` are untouched. --- .../team/activity/ActivityTimeline.tsx | 355 ++++++++++-------- .../team/activity/ActivityTimeline.test.ts | 54 +++ 2 files changed, 246 insertions(+), 163 deletions(-) diff --git a/src/renderer/components/team/activity/ActivityTimeline.tsx b/src/renderer/components/team/activity/ActivityTimeline.tsx index ded03449..7b34e0a3 100644 --- a/src/renderer/components/team/activity/ActivityTimeline.tsx +++ b/src/renderer/components/team/activity/ActivityTimeline.tsx @@ -21,9 +21,32 @@ import { } from './LeadThoughtsGroup'; import { useNewItemKeys } from './useNewItemKeys'; -import type { TimelineItem } from './LeadThoughtsGroup'; +import type { LeadThoughtGroup, TimelineItem } from './LeadThoughtsGroup'; import type { InboxMessage, ResolvedTeamMember } from '@shared/types'; +/** + * A single visual row in the timeline. The render phase maps 1:1 from this + * list into JSX, which is the shape a windowing library (e.g. + * `@tanstack/react-virtual`) expects. Grouping happens earlier, in + * `groupTimelineItems`; this layer flattens groups/separators/dividers into + * atomic rows so each one can be measured and rendered independently. + * + * The `itemIndex` fields point back into `timelineItems` so per-item state + * (collapse mode, zebra shading, "is new" flag, session anchor) can still be + * resolved without threading it through every row entry. + */ +type TimelineRow = + | { kind: 'session-separator'; key: string } + | { + kind: 'lead-thought-group'; + key: string; + itemIndex: number; + group: LeadThoughtGroup; + isPinned: boolean; + } + | { kind: 'compaction-divider'; key: string; message: InboxMessage } + | { kind: 'message-row'; key: string; itemIndex: number; message: InboxMessage }; + /** * Viewport contract — describes the scroll container that hosts the timeline * and how ActivityTimeline should report visibility against it. When omitted, @@ -489,6 +512,66 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({ const pinnedThoughtGroup = timelineItems[0]?.type === 'lead-thoughts' ? timelineItems[0] : null; const startIndex = pinnedThoughtGroup ? 1 : 0; + // Flatten timelineItems into atomic render rows. Each row maps to exactly + // one visual element — no Fragment bundles session separators with their + // owning item, because a windowing layer (landing in a follow-up PR) needs + // each row to be measurable and addressable independently. + const renderRows = useMemo(() => { + const rows: TimelineRow[] = []; + if (pinnedThoughtGroup) { + rows.push({ + kind: 'lead-thought-group', + key: getThoughtGroupKey(pinnedThoughtGroup.group), + itemIndex: 0, + group: pinnedThoughtGroup.group, + isPinned: true, + }); + } + for (let i = startIndex; i < timelineItems.length; i += 1) { + const item = timelineItems[i]; + if (i > 0) { + const currSessionId = getItemSessionAnchorId(item); + const prevSessionId = previousSessionAnchorByIndex[i]; + if (prevSessionId && currSessionId && prevSessionId !== currSessionId) { + // Include itemIndex in the key so a repeated transition (e.g. lead + // sessions A→B→A→B) does not collide on key `A->B` twice — React + // treats duplicate keys as the same element and reuses state + // across unrelated separators. + rows.push({ + kind: 'session-separator', + key: `session-separator-${i}-${prevSessionId}->${currSessionId}`, + }); + } + } + if (item.type === 'lead-thoughts') { + rows.push({ + kind: 'lead-thought-group', + key: getThoughtGroupKey(item.group), + itemIndex: i, + group: item.group, + isPinned: false, + }); + continue; + } + const message = item.message; + if (isCompactionMessage(message)) { + rows.push({ + kind: 'compaction-divider', + key: `compaction-${toMessageKey(message)}`, + message, + }); + continue; + } + rows.push({ + kind: 'message-row', + key: toMessageKey(message), + itemIndex: i, + message, + }); + } + return rows; + }, [pinnedThoughtGroup, previousSessionAnchorByIndex, startIndex, timelineItems]); + // Determine the index of the "newest" non-thought timeline item (for auto-expand). const newestMessageIndex = useMemo(() => { return findNewestMessageIndex(timelineItems); @@ -530,6 +613,113 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({ [allCollapsed, newestMessageIndex, pinnedThoughtGroup, expandOverrides, onToggleExpandOverride] ); + // Render a single atomic row. Logic per kind mirrors the previous inline + // render path; separators and dividers are their own rows rather than + // being bundled into Fragments, which is the contract the virtualizer will + // consume in a follow-up PR. + const renderTimelineRow = (row: TimelineRow): React.JSX.Element | null => { + switch (row.kind) { + case 'session-separator': + return ( +
+
+ + New session + +
+
+ ); + case 'compaction-divider': + return ; + case 'lead-thought-group': { + const { group, itemIndex, isPinned, key } = row; + const firstThought = group.thoughts[0]; + const info = memberInfo.get(firstThought.from); + const collapseProps = getItemCollapseProps(key, itemIndex); + const pinnedCanBeLive = isPinned + ? currentLeadSessionId + ? firstThought.leadSessionId === currentLeadSessionId + : true + : false; + return ( + + ); + } + case 'message-row': { + const { message, itemIndex, key } = row; + const renderProps = resolveMessageRenderProps(message, ctx); + const collapseProps = getItemCollapseProps(key, itemIndex); + const isUnread = readState + ? !message.read && !readState.readSet.has(readState.getMessageKey(message)) + : !message.read; + return ( + + ); + } + } + }; + if (messages.length === 0) { return (
@@ -541,168 +731,7 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({ return (
- {/* Pinned (newest) thought group — always at top */} - {pinnedThoughtGroup && - (() => { - const { group } = pinnedThoughtGroup; - const firstThought = group.thoughts[0]; - const pinnedCanBeLive = currentLeadSessionId - ? firstThought.leadSessionId === currentLeadSessionId - : true; - const info = memberInfo.get(firstThought.from); - const itemKey = getThoughtGroupKey(group); - const stableKey = itemKey; - const collapseProps = getItemCollapseProps(stableKey, 0); - return ( - - ); - })()} - - {/* Remaining items */} - {timelineItems.slice(startIndex).map((item, index) => { - const realIndex = index + startIndex; - - // Session boundary separator (messages sorted desc — new on top) - let sessionSeparator: React.JSX.Element | null = null; - if (realIndex > 0) { - const currSessionId = getItemSessionAnchorId(item); - const prevSessionId = previousSessionAnchorByIndex[realIndex]; - if (prevSessionId && currSessionId && prevSessionId !== currSessionId) { - sessionSeparator = ( -
-
- - New session - -
-
- ); - } - } - - if (item.type === 'lead-thoughts') { - const { group } = item; - const firstThought = group.thoughts[0]; - const info = memberInfo.get(firstThought.from); - const itemKey = getThoughtGroupKey(group); - const stableKey = itemKey; - const collapseProps = getItemCollapseProps(stableKey, realIndex); - return ( - - {sessionSeparator} - - - ); - } - - const { message } = item; - - // Compaction boundary — render as a divider instead of a regular message card - if (isCompactionMessage(message)) { - const messageKey = toMessageKey(message); - return ( - - {sessionSeparator} - - - ); - } - - const renderProps = resolveMessageRenderProps(message, ctx); - const messageKey = toMessageKey(message); - const stableKey = messageKey; - const collapseProps = getItemCollapseProps(stableKey, realIndex); - const isUnread = readState - ? !message.read && !readState.readSet.has(readState.getMessageKey(message)) - : !message.read; - return ( - - {sessionSeparator} - - - ); - })} + {renderRows.map((row) => renderTimelineRow(row))} {hiddenCount > 0 && (
{/* Bottom-up shadow gradient: darkest at bottom edge, fades upward */} diff --git a/test/renderer/components/team/activity/ActivityTimeline.test.ts b/test/renderer/components/team/activity/ActivityTimeline.test.ts index 3b8bb73f..ccea2895 100644 --- a/test/renderer/components/team/activity/ActivityTimeline.test.ts +++ b/test/renderer/components/team/activity/ActivityTimeline.test.ts @@ -289,6 +289,60 @@ describe('ActivityTimeline session separators', () => { root.unmount(); }); }); + + it('renders each separator distinctly when the same session transition repeats', async () => { + const warnSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const root = createRoot(container); + const messages: InboxMessage[] = [ + makeMessage({ + messageId: 'thought-b-2', + text: 'b second', + leadSessionId: 'lead-session-b', + from: 'team-lead', + source: 'lead_session', + }), + makeMessage({ + messageId: 'thought-a-2', + text: 'a second', + leadSessionId: 'lead-session-a', + from: 'team-lead', + source: 'lead_session', + }), + makeMessage({ + messageId: 'thought-b-1', + text: 'b first', + leadSessionId: 'lead-session-b', + from: 'team-lead', + source: 'lead_session', + }), + makeMessage({ + messageId: 'thought-a-1', + text: 'a first', + leadSessionId: 'lead-session-a', + from: 'team-lead', + source: 'lead_session', + }), + ]; + + await act(async () => { + root.render(React.createElement(ActivityTimeline, { messages, teamName: 'demo-team' })); + }); + + // Three transitions: b→a, a→b, b→a. All three separators must render. + const matches = container.textContent?.match(/New session/g) ?? []; + expect(matches.length).toBe(3); + + // React warns via `console.error` when duplicate keys are detected. + const duplicateKeyWarnings = warnSpy.mock.calls.filter((call) => + String(call[0]).includes('unique "key"') + ); + expect(duplicateKeyWarnings).toHaveLength(0); + + warnSpy.mockRestore(); + await act(async () => { + root.unmount(); + }); + }); }); describe('ActivityTimeline viewport observerRoot', () => { From 7c4247bc73409dbb69bbd27d35623cd19379beab Mon Sep 17 00:00:00 2001 From: Mike Date: Mon, 20 Apr 2026 00:33:37 +0500 Subject: [PATCH 3/6] 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 */} From b9c2dd54806386329aed64eef1e9590102143ef8 Mon Sep 17 00:00:00 2001 From: Mike Date: Mon, 20 Apr 2026 00:40:07 +0500 Subject: [PATCH 4/6] perf(team): measureElement + suppress remount animation on virtualized rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fifth step of the virtualization plan. Two small, coupled changes that make the virtualized path stable without a merged-ref helper. - Attach `rowVirtualizer.measureElement` to the existing virtualizer wrapper div. Because the wrapper carries no padding or margin, its bounding box matches the inner row, so the observer ref (which stays on the inner AnimatedHeightReveal node) and the measure ref (on the outer wrapper) address the same effective height. No merged ref callback is needed. - Suppress mount-based entry animation inside the virtualized path. The virtualizer mounts and unmounts rows as the user scrolls them in and out; without this, the "new item" fade would replay every time an older row re-entered the viewport. `renderTimelineRow` now takes an optional `suppressEntryAnimation` flag and forwards `isNew=false` to both `LeadThoughtsGroupRow` and `MemoizedMessageRowWithObserver` when set. The direct render path is unchanged. Still dormant in this release — `viewport.virtualizationEnabled` stays false at every call site. PR #6 adds the threshold gate, tests, and opt-in wiring. --- .../team/activity/ActivityTimeline.tsx | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/src/renderer/components/team/activity/ActivityTimeline.tsx b/src/renderer/components/team/activity/ActivityTimeline.tsx index 479158f0..bc513762 100644 --- a/src/renderer/components/team/activity/ActivityTimeline.tsx +++ b/src/renderer/components/team/activity/ActivityTimeline.tsx @@ -698,7 +698,18 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({ // render path; separators and dividers are their own rows rather than // being bundled into Fragments, which is the contract the virtualizer will // consume in a follow-up PR. - const renderTimelineRow = (row: TimelineRow): React.JSX.Element | null => { + // + // `suppressEntryAnimation` is set when the caller is the virtualized path: + // the virtualizer mounts and unmounts rows as they enter and leave the + // viewport, so relying on mount as a signal of "this item is new" would + // replay the entry animation every time the user scrolls back to an old + // row. In the direct render path the flag stays false and animation still + // runs on real data-set additions. + const renderTimelineRow = ( + row: TimelineRow, + options?: { suppressEntryAnimation?: boolean } + ): React.JSX.Element | null => { + const suppressEntry = options?.suppressEntryAnimation === true; switch (row.kind) { case 'session-separator': return ( @@ -735,7 +746,7 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({ isTeamAlive={pinnedCanBeLive ? isTeamAlive : undefined} leadActivity={pinnedCanBeLive ? leadActivity : undefined} leadContextUpdatedAt={pinnedCanBeLive ? leadContextUpdatedAt : undefined} - isNew={newItemKeys.has(key)} + isNew={!suppressEntry && newItemKeys.has(key)} onVisible={onMessageVisible} observerRoot={observerRoot} zebraShade={zebraShadeSet.has(itemIndex)} @@ -772,7 +783,7 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({ memberColor={renderProps.memberColor} recipientColor={renderProps.recipientColor} isUnread={isUnread} - isNew={newItemKeys.has(key)} + isNew={!suppressEntry && newItemKeys.has(key)} zebraShade={zebraShadeSet.has(itemIndex)} memberColorMap={colorMap} localMemberNames={localMemberNames} @@ -826,6 +837,14 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({ return (
- {renderTimelineRow(row)} + {renderTimelineRow(row, { suppressEntryAnimation: true })}
); })} From 05f68ced449ef4becd78f0d2675f514aa5369ae1 Mon Sep 17 00:00:00 2001 From: Mike Date: Mon, 20 Apr 2026 00:56:28 +0500 Subject: [PATCH 5/6] perf(team): enable virtualization past threshold + tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final step of the virtualization plan. Turns the virtualized render path on in production behind a row-count threshold, and adds regression tests covering every gate. - `VIRTUALIZATION_ROW_THRESHOLD = 60`. Short lists stay on the direct render path (no wrapper, no position: absolute, no measurement churn). Above the threshold the virtualizer takes over. Threshold is sized so conversations under ~one session of activity don't pay the virtualization cost; it activates once scrolling through a longer history. - `shouldVirtualize` now requires `renderRows.length >= threshold` in addition to the existing opt-in and scroll-ref checks. - `MessagesPanel` opts into virtualization for every layout it wires (inline / sidebar / bottom-sheet). The internal threshold then decides when to actually enable it, so callers don't need per-layout heuristics. - Tests: adds a new `ActivityTimeline virtualization threshold` block covering (a) below-threshold list stays on the direct path, (b) no viewport → direct path regardless of count, (c) above threshold + viewport with `virtualizationEnabled` flips to the virtualized render path (simulated by clicking "show all" past pagination). With this in, #70 → #74 combine to deliver: - correct IntersectionObserver roots in scroll containers - atomic render rows with stable keys - windowed rendering with DOM-measured scrollMargin and measureElement - auto-on when the cost of direct rendering actually shows up --- .../team/activity/ActivityTimeline.tsx | 20 ++- .../team/messages/MessagesPanel.tsx | 5 +- .../team/activity/ActivityTimeline.test.ts | 126 ++++++++++++++++++ 3 files changed, 145 insertions(+), 6 deletions(-) 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(); + }); +}); From 63bc5ed86672d2992dbee01261bcdf8fe97c67d2 Mon Sep 17 00:00:00 2001 From: 777genius Date: Mon, 20 Apr 2026 08:59:38 +0300 Subject: [PATCH 6/6] fix(team): stabilize activity timeline virtualization --- .../team/activity/ActivityTimeline.tsx | 62 ++++++-- ...vityTimeline.virtualization-config.test.ts | 135 ++++++++++++++++++ 2 files changed, 183 insertions(+), 14 deletions(-) create mode 100644 test/renderer/components/team/activity/ActivityTimeline.virtualization-config.test.ts diff --git a/src/renderer/components/team/activity/ActivityTimeline.tsx b/src/renderer/components/team/activity/ActivityTimeline.tsx index 3b04a1bb..d4c106f9 100644 --- a/src/renderer/components/team/activity/ActivityTimeline.tsx +++ b/src/renderer/components/team/activity/ActivityTimeline.tsx @@ -1,4 +1,12 @@ -import React, { type RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { + type RefObject, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; import { areInboxMessagesEquivalentForRender, @@ -135,6 +143,7 @@ const EMPTY_TEAM_NAMES: string[] = []; const EMPTY_TEAM_COLOR_MAP = new Map(); const DEFAULT_COLLAPSE_MODE = 'default' as const; const VIRTUALIZER_OVERSCAN = 8; +const VIRTUALIZATION_ROW_GAP_PX = 4; /** * Row count above which virtualization is worth its complexity cost. Below @@ -158,6 +167,35 @@ const ROW_SIZE_ESTIMATES: Record = { 'message-row': 140, }; +function collectScrollMarginObserverTargets( + rootElement: HTMLElement, + scrollElement: HTMLElement +): HTMLElement[] { + const targets = new Set([rootElement, scrollElement]); + + let current: HTMLElement | null = rootElement; + while (current && current !== scrollElement) { + const parentElement: HTMLElement | null = current.parentElement; + if (!parentElement) { + break; + } + + targets.add(parentElement); + + let previousSibling: Element | null = current.previousElementSibling; + while (previousSibling) { + if (previousSibling instanceof HTMLElement) { + targets.add(previousSibling); + } + previousSibling = previousSibling.previousElementSibling; + } + + current = parentElement; + } + + return [...targets]; +} + function getItemSessionAnchorId(item: TimelineItem): string | undefined { if (item.type === 'lead-thoughts') { return item.group.thoughts[0]?.leadSessionId; @@ -607,13 +645,12 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({ 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 - // soon as any of those blocks change size; measuring the actual offset via - // `getBoundingClientRect` keeps the virtualizer accurate without coupling - // to layout internals. + // timeline root. We avoid re-measuring on every scroll: the offset only + // changes when layout above the timeline changes, so observe the timeline, + // its ancestor chain, and all previous siblings that can push it down. const [measuredScrollMargin, setMeasuredScrollMargin] = useState(0); - useEffect(() => { + useLayoutEffect(() => { if (!shouldVirtualize) return; const scrollEl = viewport?.scrollElementRef?.current ?? null; const rootEl = rootRef.current; @@ -638,18 +675,14 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({ }; measure(); - const scrollObserver = new ResizeObserver(measure); - scrollObserver.observe(scrollEl); - const rootObserver = new ResizeObserver(measure); - rootObserver.observe(rootEl); - scrollEl.addEventListener('scroll', measure, { passive: true }); + const resizeObserver = new ResizeObserver(measure); + const observedTargets = collectScrollMarginObserverTargets(rootEl, scrollEl); + observedTargets.forEach((target) => resizeObserver.observe(target)); window.addEventListener('resize', measure); return () => { if (rafId !== null) cancelAnimationFrame(rafId); - scrollObserver.disconnect(); - rootObserver.disconnect(); - scrollEl.removeEventListener('scroll', measure); + resizeObserver.disconnect(); window.removeEventListener('resize', measure); }; }, [shouldVirtualize, viewport?.scrollElementRef]); @@ -660,6 +693,7 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({ estimateSize: (index) => ROW_SIZE_ESTIMATES[renderRows[index]?.kind ?? 'message-row'], getItemKey: (index) => renderRows[index]?.key ?? `row-${index}`, overscan: VIRTUALIZER_OVERSCAN, + gap: VIRTUALIZATION_ROW_GAP_PX, scrollMargin: measuredScrollMargin, }); diff --git a/test/renderer/components/team/activity/ActivityTimeline.virtualization-config.test.ts b/test/renderer/components/team/activity/ActivityTimeline.virtualization-config.test.ts new file mode 100644 index 00000000..9ce36d7b --- /dev/null +++ b/test/renderer/components/team/activity/ActivityTimeline.virtualization-config.test.ts @@ -0,0 +1,135 @@ +import React from 'react'; +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { InboxMessage } from '@shared/types'; + +const useVirtualizerMock = vi.fn( + (options: Record) => + ({ + getVirtualItems: () => [], + getTotalSize: () => 0, + measureElement: () => undefined, + options, + }) as const +); + +vi.mock('@tanstack/react-virtual', () => ({ + useVirtualizer: (options: Record) => useVirtualizerMock(options), +})); + +vi.mock('@renderer/components/team/activity/ActivityItem', () => ({ + ActivityItem: ({ message }: { message: InboxMessage }) => + React.createElement('div', { 'data-testid': 'activity-item' }, message.text), + isNoiseMessage: () => false, +})); + +vi.mock('@renderer/components/team/activity/AnimatedHeightReveal', () => ({ + ENTRY_REVEAL_ANIMATION_MS: 220, + AnimatedHeightReveal: ({ + children, + containerRef, + }: { + children: React.ReactNode; + containerRef?: React.RefObject; + }) => React.createElement('div', { ref: containerRef }, children), +})); + +vi.mock('@renderer/components/team/activity/useNewItemKeys', () => ({ + useNewItemKeys: () => new Set(), +})); + +import { ActivityTimeline } from '@renderer/components/team/activity/ActivityTimeline'; + +function makeMessage(overrides: Partial = {}): InboxMessage { + return { + from: 'alice', + text: 'message', + timestamp: '2026-04-20T10:00:00.000Z', + read: true, + source: 'inbox', + messageId: 'message-id', + leadSessionId: 'lead-session-1', + ...overrides, + }; +} + +describe('ActivityTimeline virtualization config', () => { + let container: HTMLDivElement; + let originalResizeObserver: typeof globalThis.ResizeObserver | undefined; + + beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + useVirtualizerMock.mockClear(); + container = document.createElement('div'); + document.body.appendChild(container); + originalResizeObserver = globalThis.ResizeObserver; + class FakeResizeObserver { + observe(): void {} + unobserve(): void {} + disconnect(): void {} + } + vi.stubGlobal('ResizeObserver', FakeResizeObserver); + }); + + afterEach(() => { + if (originalResizeObserver) { + globalThis.ResizeObserver = originalResizeObserver; + } + container.remove(); + document.body.innerHTML = ''; + vi.unstubAllGlobals(); + }); + + it('passes the direct-path row gap into useVirtualizer when virtualization activates', async () => { + const scrollHost = document.createElement('div'); + document.body.appendChild(scrollHost); + const scrollRef = { current: scrollHost }; + const root = createRoot(container); + const messages = Array.from({ length: 80 }, (_, i) => + makeMessage({ + messageId: `msg-${i}`, + text: `message ${i}`, + timestamp: new Date(Date.UTC(2026, 3, 20, 10, 0, i)).toISOString(), + leadSessionId: `member-session-${i}`, + }) + ); + + await act(async () => { + root.render( + React.createElement(ActivityTimeline, { + messages, + teamName: 'demo-team', + viewport: { + scrollElementRef: scrollRef, + observerRoot: scrollRef, + scrollMargin: 0, + virtualizationEnabled: true, + }, + }) + ); + }); + + const showAllButton = [...container.querySelectorAll('button')].find((button) => + button.textContent?.toLowerCase().includes('show all') + ); + expect(showAllButton).toBeDefined(); + + await act(async () => { + showAllButton?.click(); + }); + + const lastCall = useVirtualizerMock.mock.calls.at(-1)?.[0] as + | { count?: number; gap?: number } + | undefined; + + expect(lastCall?.count).toBeGreaterThanOrEqual(60); + expect(lastCall?.gap).toBe(4); + + await act(async () => { + root.unmount(); + }); + scrollHost.remove(); + }); +});