Merge pull request #75 from sardorb3k/perf/virtualization-threshold-tests
perf(team): enable virtualization past threshold + tests
This commit is contained in:
commit
6929ab2a34
5 changed files with 902 additions and 164 deletions
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue