diff --git a/src/renderer/components/team/activity/ActivityTimeline.tsx b/src/renderer/components/team/activity/ActivityTimeline.tsx index abc3313d..d4c106f9 100644 --- a/src/renderer/components/team/activity/ActivityTimeline.tsx +++ b/src/renderer/components/team/activity/ActivityTimeline.tsx @@ -1,4 +1,12 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { + type RefObject, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; import { areInboxMessagesEquivalentForRender, @@ -6,6 +14,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'; @@ -21,9 +30,60 @@ 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, + * 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 +126,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; @@ -74,6 +142,59 @@ 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; +const VIRTUALIZATION_ROW_GAP_PX = 4; + +/** + * 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` + * (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 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') { @@ -141,6 +262,7 @@ const MessageRowWithObserver = ({ onExpand, expandItemKey, onExpandContent, + observerRoot, }: { message: InboxMessage; teamName: string; @@ -170,6 +292,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 +308,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 +322,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 +392,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 +419,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); @@ -444,6 +574,129 @@ 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]); + + // 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 >= VIRTUALIZATION_ROW_THRESHOLD; + + // DOM-measured distance from the scroll container's scroll origin to the + // 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); + + useLayoutEffect(() => { + 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 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); + resizeObserver.disconnect(); + 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, + gap: VIRTUALIZATION_ROW_GAP_PX, + scrollMargin: measuredScrollMargin, + }); + // Determine the index of the "newest" non-thought timeline item (for auto-expand). const newestMessageIndex = useMemo(() => { return findNewestMessageIndex(timelineItems); @@ -485,6 +738,124 @@ 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. + // + // `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 ( +
+
+ + 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 (
@@ -496,165 +867,49 @@ 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 = ( + {shouldVirtualize ? ( +
+ {rowVirtualizer.getVirtualItems().map((virtualRow) => { + const row = renderRows[virtualRow.index]; + if (!row) return null; + return (
-
- - New session - -
+ {renderTimelineRow(row, { suppressEntryAnimation: true })}
); - } - } - - 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/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 1428e581..27992279 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,31 @@ 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, + // 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(() => { // no-op: user is reading expanded content, not composing }, []); @@ -682,6 +708,7 @@ export const MessagesPanel = memo(function MessagesPanel({ onTaskIdClick={onTaskIdClick} onExpandItem={handleExpandItem} onExpandContent={handleExpandContent} + viewport={activityTimelineViewport} /> {hasMore && (
@@ -867,6 +894,7 @@ export const MessagesPanel = memo(function MessagesPanel({ onTaskIdClick={onTaskIdClick} onExpandItem={handleExpandItem} onExpandContent={handleExpandContent} + viewport={activityTimelineViewport} /> {hasMore && (
@@ -1154,6 +1182,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..11c12728 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', () => ({ @@ -284,4 +289,304 @@ 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', () => { + 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(); + }); +}); + +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(); + }); }); 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(); + }); +});