Merge pull request #75 from sardorb3k/perf/virtualization-threshold-tests

perf(team): enable virtualization past threshold + tests
This commit is contained in:
Илия 2026-04-20 09:00:07 +03:00 committed by GitHub
commit 6929ab2a34
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 902 additions and 164 deletions

View file

@ -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<HTMLElement | null>;
/**
* 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<HTMLElement | null>;
/**
* 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<string, string>();
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<TimelineRow['kind'], number> = {
'session-separator': 135,
'compaction-divider': 50,
'lead-thought-group': 220,
'message-row': 140,
};
function collectScrollMarginObserverTargets(
rootElement: HTMLElement,
scrollElement: HTMLElement
): HTMLElement[] {
const targets = new Set<HTMLElement>([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<HTMLElement | null>;
}): React.JSX.Element => {
const ref = useRef<HTMLDivElement>(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 (
<AnimatedHeightReveal animate={isNew} containerRef={ref}>
@ -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<HTMLDivElement>(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<readonly TimelineRow[]>(() => {
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 (
<div
key={row.key}
className="flex items-center gap-3"
style={{ paddingTop: 45, paddingBottom: 45 }}
>
<div className="h-px flex-1 bg-blue-600/30 dark:bg-blue-400/30" />
<span className="whitespace-nowrap text-[11px] font-medium text-blue-600 dark:text-blue-400">
New session
</span>
<div className="h-px flex-1 bg-blue-600/30 dark:bg-blue-400/30" />
</div>
);
case 'compaction-divider':
return <CompactionDivider key={row.key} message={row.message} />;
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 (
<LeadThoughtsGroupRow
key={key}
group={group}
memberColor={info?.color}
canBeLive={pinnedCanBeLive}
isTeamAlive={pinnedCanBeLive ? isTeamAlive : undefined}
leadActivity={pinnedCanBeLive ? leadActivity : undefined}
leadContextUpdatedAt={pinnedCanBeLive ? leadContextUpdatedAt : undefined}
isNew={!suppressEntry && newItemKeys.has(key)}
onVisible={onMessageVisible}
observerRoot={observerRoot}
zebraShade={zebraShadeSet.has(itemIndex)}
collapseMode={collapseProps.collapseMode}
isCollapsed={collapseProps.isCollapsed}
canToggleCollapse={collapseProps.canToggleCollapse}
collapseToggleKey={collapseProps.collapseToggleKey}
onToggleCollapse={onToggleExpandOverride}
onTaskIdClick={onTaskIdClick}
memberColorMap={colorMap}
onReply={onReplyToMessage}
compactHeader={compactHeader}
teamNames={teamNames}
teamColorByName={teamColorByName}
onTeamClick={onTeamClick}
onExpand={compactHeader ? onExpandItem : undefined}
expandItemKey={compactHeader ? key : undefined}
/>
);
}
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 (
<MemoizedMessageRowWithObserver
key={key}
message={message}
teamName={teamName}
memberRole={renderProps.memberRole}
memberColor={renderProps.memberColor}
recipientColor={renderProps.recipientColor}
isUnread={isUnread}
isNew={!suppressEntry && newItemKeys.has(key)}
zebraShade={zebraShadeSet.has(itemIndex)}
memberColorMap={colorMap}
localMemberNames={localMemberNames}
onMemberNameClick={onMemberClick ? handleMemberNameClick : undefined}
onCreateTask={onCreateTaskFromMessage}
onReply={onReplyToMessage}
onVisible={onMessageVisible}
onTaskIdClick={onTaskIdClick}
onRestartTeam={onRestartTeam}
collapseMode={collapseProps.collapseMode}
isCollapsed={collapseProps.isCollapsed}
canToggleCollapse={collapseProps.canToggleCollapse}
collapseToggleKey={collapseProps.collapseToggleKey}
onToggleCollapse={onToggleExpandOverride}
compactHeader={compactHeader}
teamNames={teamNames}
teamColorByName={teamColorByName}
onTeamClick={onTeamClick}
onExpand={compactHeader ? onExpandItem : undefined}
expandItemKey={compactHeader ? key : undefined}
observerRoot={observerRoot}
onExpandContent={onExpandContent}
/>
);
}
}
};
if (messages.length === 0) {
return (
<div className="rounded-md border border-[var(--color-border)] p-3 pl-5 text-xs text-[var(--color-text-muted)]">
@ -496,165 +867,49 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({
return (
<div ref={rootRef} className="space-y-1">
{/* 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 (
<LeadThoughtsGroupRow
key={itemKey}
group={group}
memberColor={info?.color}
canBeLive={pinnedCanBeLive}
isTeamAlive={pinnedCanBeLive ? isTeamAlive : undefined}
leadActivity={pinnedCanBeLive ? leadActivity : undefined}
leadContextUpdatedAt={pinnedCanBeLive ? leadContextUpdatedAt : undefined}
isNew={newItemKeys.has(itemKey)}
onVisible={onMessageVisible}
zebraShade={zebraShadeSet.has(0)}
collapseMode={collapseProps.collapseMode}
isCollapsed={collapseProps.isCollapsed}
canToggleCollapse={collapseProps.canToggleCollapse}
collapseToggleKey={collapseProps.collapseToggleKey}
onToggleCollapse={onToggleExpandOverride}
onTaskIdClick={onTaskIdClick}
memberColorMap={colorMap}
onReply={onReplyToMessage}
compactHeader={compactHeader}
teamNames={teamNames}
teamColorByName={teamColorByName}
onTeamClick={onTeamClick}
onExpand={compactHeader ? onExpandItem : undefined}
expandItemKey={compactHeader ? itemKey : undefined}
/>
);
})()}
{/* 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 ? (
<div
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const row = renderRows[virtualRow.index];
if (!row) return null;
return (
<div
className="flex items-center gap-3"
style={{ paddingTop: 45, paddingBottom: 45 }}
key={virtualRow.key}
// `measureElement` swaps each row's estimated height for its
// real rendered height as it mounts, so the virtualizer can
// correct totalSize and downstream row positions. The wrapper
// div carries no padding/margin, so its bounding box matches
// the inner row's bounding box — this is why a merged ref
// callback between the observer and `measureElement` isn't
// needed here.
ref={rowVirtualizer.measureElement}
data-index={virtualRow.index}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
// `translateY` is offset by scrollMargin so the virtualizer
// positions rows relative to the timeline's own origin,
// not the scroll container's top — otherwise rows would
// overlap the composer / status block at the top.
transform: `translateY(${virtualRow.start - rowVirtualizer.options.scrollMargin}px)`,
}}
>
<div className="h-px flex-1 bg-blue-600/30 dark:bg-blue-400/30" />
<span className="whitespace-nowrap text-[11px] font-medium text-blue-600 dark:text-blue-400">
New session
</span>
<div className="h-px flex-1 bg-blue-600/30 dark:bg-blue-400/30" />
{renderTimelineRow(row, { suppressEntryAnimation: true })}
</div>
);
}
}
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 (
<React.Fragment key={itemKey}>
{sessionSeparator}
<LeadThoughtsGroupRow
group={group}
memberColor={info?.color}
canBeLive={false}
isNew={newItemKeys.has(itemKey)}
onVisible={onMessageVisible}
zebraShade={zebraShadeSet.has(realIndex)}
collapseMode={collapseProps.collapseMode}
isCollapsed={collapseProps.isCollapsed}
canToggleCollapse={collapseProps.canToggleCollapse}
collapseToggleKey={collapseProps.collapseToggleKey}
onToggleCollapse={onToggleExpandOverride}
onTaskIdClick={onTaskIdClick}
memberColorMap={colorMap}
onReply={onReplyToMessage}
compactHeader={compactHeader}
teamNames={teamNames}
teamColorByName={teamColorByName}
onTeamClick={onTeamClick}
onExpand={compactHeader ? onExpandItem : undefined}
expandItemKey={compactHeader ? itemKey : undefined}
/>
</React.Fragment>
);
}
const { message } = item;
// Compaction boundary — render as a divider instead of a regular message card
if (isCompactionMessage(message)) {
const messageKey = toMessageKey(message);
return (
<React.Fragment key={messageKey}>
{sessionSeparator}
<CompactionDivider message={message} />
</React.Fragment>
);
}
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 (
<React.Fragment key={messageKey}>
{sessionSeparator}
<MemoizedMessageRowWithObserver
message={message}
teamName={teamName}
memberRole={renderProps.memberRole}
memberColor={renderProps.memberColor}
recipientColor={renderProps.recipientColor}
isUnread={isUnread}
isNew={newItemKeys.has(messageKey)}
zebraShade={zebraShadeSet.has(realIndex)}
memberColorMap={colorMap}
localMemberNames={localMemberNames}
onMemberNameClick={onMemberClick ? handleMemberNameClick : undefined}
onCreateTask={onCreateTaskFromMessage}
onReply={onReplyToMessage}
onVisible={onMessageVisible}
onTaskIdClick={onTaskIdClick}
onRestartTeam={onRestartTeam}
collapseMode={collapseProps.collapseMode}
isCollapsed={collapseProps.isCollapsed}
canToggleCollapse={collapseProps.canToggleCollapse}
collapseToggleKey={collapseProps.collapseToggleKey}
onToggleCollapse={onToggleExpandOverride}
compactHeader={compactHeader}
teamNames={teamNames}
teamColorByName={teamColorByName}
onTeamClick={onTeamClick}
onExpand={compactHeader ? onExpandItem : undefined}
expandItemKey={compactHeader ? messageKey : undefined}
onExpandContent={onExpandContent}
/>
</React.Fragment>
);
})}
})}
</div>
) : (
renderRows.map((row) => renderTimelineRow(row))
)}
{hiddenCount > 0 && (
<div className="relative flex justify-center pb-3 pt-1">
{/* Bottom-up shadow gradient: darkest at bottom edge, fades upward */}

View file

@ -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<HTMLElement | null>;
/** 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)
);

View file

@ -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<HTMLDivElement | null>(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<TimelineViewport | undefined>(() => {
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 && (
<div className="flex justify-center py-2">
@ -867,6 +894,7 @@ export const MessagesPanel = memo(function MessagesPanel({
onTaskIdClick={onTaskIdClick}
onExpandItem={handleExpandItem}
onExpandContent={handleExpandContent}
viewport={activityTimelineViewport}
/>
{hasMore && (
<div className="flex justify-center py-2">
@ -1154,6 +1182,7 @@ export const MessagesPanel = memo(function MessagesPanel({
onTaskIdClick={onTaskIdClick}
onExpandItem={handleExpandItem}
onExpandContent={handleExpandContent}
viewport={activityTimelineViewport}
/>
{hasMore && (
<div className="flex justify-center py-2">

View file

@ -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<HTMLDivElement | null>;
}) => 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<Element | Document | null>;
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<number>;
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<HTMLDivElement>('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<HTMLDivElement>('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();
});
});

View file

@ -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<string, unknown>) =>
({
getVirtualItems: () => [],
getTotalSize: () => 0,
measureElement: () => undefined,
options,
}) as const
);
vi.mock('@tanstack/react-virtual', () => ({
useVirtualizer: (options: Record<string, unknown>) => 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<HTMLDivElement | null>;
}) => React.createElement('div', { ref: containerRef }, children),
}));
vi.mock('@renderer/components/team/activity/useNewItemKeys', () => ({
useNewItemKeys: () => new Set<string>(),
}));
import { ActivityTimeline } from '@renderer/components/team/activity/ActivityTimeline';
function makeMessage(overrides: Partial<InboxMessage> = {}): 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();
});
});