refactor(team): viewport contract + observer root for ActivityTimeline

Second step of the virtualization plan. No virtualization yet. This PR
makes IntersectionObserver-based visibility tracking correct inside
scroll containers (sidebar, bottom-sheet), which is a prerequisite for
virtualizing the timeline.

- Introduces `TimelineViewport` — a grouped contract passed as a single
  `viewport` prop on `ActivityTimeline`. Holds `scrollElementRef`,
  `observerRoot`, `scrollMargin`, and `virtualizationEnabled`.
- `MessageRowWithObserver` and `LeadThoughtsGroupRow` now create their
  `IntersectionObserver` with `root = observerRoot?.current ?? null`
  instead of defaulting to the document viewport. Unread marking now
  fires when rows enter their real scroll parent.
- `MessagesPanel` resolves the active scroll owner from `position`
  (inline from parent ref, sidebar from `sidebarScrollRef`, bottom-sheet
  from `bottomSheetScrollRef`) and passes it into ActivityTimeline.
- Tests: stubs `IntersectionObserver` to capture `options.root` and
  asserts null when no viewport is passed, and the provided element when
  `viewport.observerRoot` is set.

`scrollMargin` and `virtualizationEnabled` are included in the contract
but not consumed yet — they land in follow-up PRs (#4/#5).
This commit is contained in:
Mike 2026-04-20 00:17:04 +05:00
parent fd9eb21f55
commit d4f518e8c5
4 changed files with 221 additions and 8 deletions

View file

@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { type RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { import {
areInboxMessagesEquivalentForRender, areInboxMessagesEquivalentForRender,
@ -24,6 +24,34 @@ import { useNewItemKeys } from './useNewItemKeys';
import type { TimelineItem } from './LeadThoughtsGroup'; import type { TimelineItem } from './LeadThoughtsGroup';
import type { InboxMessage, ResolvedTeamMember } from '@shared/types'; import type { InboxMessage, ResolvedTeamMember } from '@shared/types';
/**
* Viewport contract describes the scroll container that hosts the timeline
* and how ActivityTimeline should report visibility against it. When omitted,
* ActivityTimeline falls back to the document viewport (current behavior).
*
* This contract is grouped intentionally so consumers pass a single coherent
* object rather than threading several refs and flags. Virtualizer wiring
* lands in a follow-up; for now only `observerRoot` has an observable effect.
*/
export interface TimelineViewport {
/** The element that actually scrolls. */
scrollElementRef: RefObject<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 { interface ActivityTimelineProps {
messages: InboxMessage[]; messages: InboxMessage[];
teamName: string; teamName: string;
@ -66,6 +94,14 @@ interface ActivityTimelineProps {
onExpandItem?: (key: string) => void; onExpandItem?: (key: string) => void;
/** Called when ExpandableContent is expanded via "Show more" in any ActivityItem. */ /** Called when ExpandableContent is expanded via "Show more" in any ActivityItem. */
onExpandContent?: () => void; 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; const VIEWPORT_THRESHOLD = 0.15;
@ -141,6 +177,7 @@ const MessageRowWithObserver = ({
onExpand, onExpand,
expandItemKey, expandItemKey,
onExpandContent, onExpandContent,
observerRoot,
}: { }: {
message: InboxMessage; message: InboxMessage;
teamName: string; teamName: string;
@ -170,6 +207,7 @@ const MessageRowWithObserver = ({
onExpand?: (key: string) => void; onExpand?: (key: string) => void;
expandItemKey?: string; expandItemKey?: string;
onExpandContent?: () => void; onExpandContent?: () => void;
observerRoot?: RefObject<HTMLElement | null>;
}): React.JSX.Element => { }): React.JSX.Element => {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const reportedRef = useRef(false); const reportedRef = useRef(false);
@ -185,6 +223,10 @@ const MessageRowWithObserver = ({
if (!onVisible) return; if (!onVisible) return;
const el = ref.current; const el = ref.current;
if (!el) return; 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( const observer = new IntersectionObserver(
([entry]) => { ([entry]) => {
if (!entry?.isIntersecting) return; if (!entry?.isIntersecting) return;
@ -195,11 +237,11 @@ const MessageRowWithObserver = ({
reportedRef.current = true; reportedRef.current = true;
cb(msg); cb(msg);
}, },
{ threshold: VIEWPORT_THRESHOLD, rootMargin: '0px' } { root, threshold: VIEWPORT_THRESHOLD, rootMargin: '0px' }
); );
observer.observe(el); observer.observe(el);
return () => observer.disconnect(); return () => observer.disconnect();
}, [onVisible]); }, [onVisible, observerRoot]);
return ( return (
<AnimatedHeightReveal animate={isNew} containerRef={ref}> <AnimatedHeightReveal animate={isNew} containerRef={ref}>
@ -265,6 +307,7 @@ const MemoizedMessageRowWithObserver = React.memo(
prev.onExpand === next.onExpand && prev.onExpand === next.onExpand &&
prev.expandItemKey === next.expandItemKey && prev.expandItemKey === next.expandItemKey &&
prev.onExpandContent === next.onExpandContent && prev.onExpandContent === next.onExpandContent &&
prev.observerRoot === next.observerRoot &&
areInboxMessagesEquivalentForRender(prev.message, next.message) areInboxMessagesEquivalentForRender(prev.message, next.message)
); );
@ -291,7 +334,9 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({
onTeamClick, onTeamClick,
onExpandItem, onExpandItem,
onExpandContent, onExpandContent,
viewport,
}: ActivityTimelineProps): React.JSX.Element { }: ActivityTimelineProps): React.JSX.Element {
const observerRoot = viewport?.observerRoot ?? viewport?.scrollElementRef;
const [visibleCount, setVisibleCount] = useState(MESSAGES_PAGE_SIZE); const [visibleCount, setVisibleCount] = useState(MESSAGES_PAGE_SIZE);
const rootRef = useRef<HTMLDivElement>(null); const rootRef = useRef<HTMLDivElement>(null);
const [compactHeader, setCompactHeader] = useState(false); const [compactHeader, setCompactHeader] = useState(false);
@ -519,6 +564,7 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({
leadContextUpdatedAt={pinnedCanBeLive ? leadContextUpdatedAt : undefined} leadContextUpdatedAt={pinnedCanBeLive ? leadContextUpdatedAt : undefined}
isNew={newItemKeys.has(itemKey)} isNew={newItemKeys.has(itemKey)}
onVisible={onMessageVisible} onVisible={onMessageVisible}
observerRoot={observerRoot}
zebraShade={zebraShadeSet.has(0)} zebraShade={zebraShadeSet.has(0)}
collapseMode={collapseProps.collapseMode} collapseMode={collapseProps.collapseMode}
isCollapsed={collapseProps.isCollapsed} isCollapsed={collapseProps.isCollapsed}
@ -579,6 +625,7 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({
canBeLive={false} canBeLive={false}
isNew={newItemKeys.has(itemKey)} isNew={newItemKeys.has(itemKey)}
onVisible={onMessageVisible} onVisible={onMessageVisible}
observerRoot={observerRoot}
zebraShade={zebraShadeSet.has(realIndex)} zebraShade={zebraShadeSet.has(realIndex)}
collapseMode={collapseProps.collapseMode} collapseMode={collapseProps.collapseMode}
isCollapsed={collapseProps.isCollapsed} isCollapsed={collapseProps.isCollapsed}
@ -650,6 +697,7 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({
onTeamClick={onTeamClick} onTeamClick={onTeamClick}
onExpand={compactHeader ? onExpandItem : undefined} onExpand={compactHeader ? onExpandItem : undefined}
expandItemKey={compactHeader ? messageKey : undefined} expandItemKey={compactHeader ? messageKey : undefined}
observerRoot={observerRoot}
onExpandContent={onExpandContent} onExpandContent={onExpandContent}
/> />
</React.Fragment> </React.Fragment>

View file

@ -1,6 +1,7 @@
import { import {
type JSX, type JSX,
memo, memo,
type RefObject,
useCallback, useCallback,
useEffect, useEffect,
useLayoutEffect, useLayoutEffect,
@ -157,6 +158,14 @@ interface LeadThoughtsGroupRowProps {
memberColor?: string; memberColor?: string;
isNew?: boolean; isNew?: boolean;
onVisible?: (message: InboxMessage) => void; 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). */ /** When false, the live indicator is always off (for historical thought groups). */
canBeLive?: boolean; canBeLive?: boolean;
/** Whether the owning team is currently alive. */ /** Whether the owning team is currently alive. */
@ -528,6 +537,7 @@ const LeadThoughtsGroupRowComponent = ({
memberColor, memberColor,
isNew, isNew,
onVisible, onVisible,
observerRoot,
canBeLive, canBeLive,
isTeamAlive, isTeamAlive,
leadActivity, leadActivity,
@ -637,6 +647,9 @@ const LeadThoughtsGroupRowComponent = ({
if (!onVisible) return; if (!onVisible) return;
const el = ref.current; const el = ref.current;
if (!el) return; 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( const observer = new IntersectionObserver(
([entry]) => { ([entry]) => {
if (!entry?.isIntersecting) return; if (!entry?.isIntersecting) return;
@ -647,11 +660,11 @@ const LeadThoughtsGroupRowComponent = ({
} }
reportedCountRef.current = thoughts.length; reportedCountRef.current = thoughts.length;
}, },
{ threshold: VIEWPORT_THRESHOLD, rootMargin: '0px' } { root, threshold: VIEWPORT_THRESHOLD, rootMargin: '0px' }
); );
observer.observe(el); observer.observe(el);
return () => observer.disconnect(); return () => observer.disconnect();
}, [onVisible, thoughts]); }, [onVisible, observerRoot, thoughts]);
const clearPendingScrollSync = useCallback(() => { const clearPendingScrollSync = useCallback(() => {
if (scrollSyncFrameRef.current !== null) { if (scrollSyncFrameRef.current !== null) {
@ -1134,5 +1147,6 @@ export const LeadThoughtsGroupRow = memo(
prev.compactHeader === next.compactHeader && prev.compactHeader === next.compactHeader &&
prev.onExpand === next.onExpand && prev.onExpand === next.onExpand &&
prev.expandItemKey === next.expandItemKey && prev.expandItemKey === next.expandItemKey &&
prev.observerRoot === next.observerRoot &&
areThoughtGroupsEquivalent(prev.group, next.group) areThoughtGroupsEquivalent(prev.group, next.group)
); );

View file

@ -36,7 +36,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow'; 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 { getThoughtGroupKey, groupTimelineItems } from '../activity/LeadThoughtsGroup';
import { MessageExpandDialog } from '../activity/MessageExpandDialog'; import { MessageExpandDialog } from '../activity/MessageExpandDialog';
import { CollapsibleTeamSection } from '../CollapsibleTeamSection'; import { CollapsibleTeamSection } from '../CollapsibleTeamSection';
@ -183,6 +183,7 @@ export const MessagesPanel = memo(function MessagesPanel({
onReplyToMessage, onReplyToMessage,
onRestartTeam, onRestartTeam,
onTaskIdClick, onTaskIdClick,
inlineScrollContainerRef,
}: MessagesPanelProps): React.JSX.Element { }: MessagesPanelProps): React.JSX.Element {
const { const {
sendTeamMessage, sendTeamMessage,
@ -235,6 +236,28 @@ export const MessagesPanel = memo(function MessagesPanel({
// Held here so future viewport consumers (virtualization) can observe the // Held here so future viewport consumers (virtualization) can observe the
// true scrolling element in bottom-sheet mode. // true scrolling element in bottom-sheet mode.
const bottomSheetScrollRef = useRef<HTMLDivElement | null>(null); 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,
virtualizationEnabled: false,
};
}, [activeScrollContainerRef]);
const handleExpandContent = useCallback(() => { const handleExpandContent = useCallback(() => {
// no-op: user is reading expanded content, not composing // no-op: user is reading expanded content, not composing
}, []); }, []);
@ -678,6 +701,7 @@ export const MessagesPanel = memo(function MessagesPanel({
onTaskIdClick={onTaskIdClick} onTaskIdClick={onTaskIdClick}
onExpandItem={handleExpandItem} onExpandItem={handleExpandItem}
onExpandContent={handleExpandContent} onExpandContent={handleExpandContent}
viewport={activityTimelineViewport}
/> />
{hasMore && ( {hasMore && (
<div className="flex justify-center py-2"> <div className="flex justify-center py-2">
@ -863,6 +887,7 @@ export const MessagesPanel = memo(function MessagesPanel({
onTaskIdClick={onTaskIdClick} onTaskIdClick={onTaskIdClick}
onExpandItem={handleExpandItem} onExpandItem={handleExpandItem}
onExpandContent={handleExpandContent} onExpandContent={handleExpandContent}
viewport={activityTimelineViewport}
/> />
{hasMore && ( {hasMore && (
<div className="flex justify-center py-2"> <div className="flex justify-center py-2">
@ -1150,6 +1175,7 @@ export const MessagesPanel = memo(function MessagesPanel({
onTaskIdClick={onTaskIdClick} onTaskIdClick={onTaskIdClick}
onExpandItem={handleExpandItem} onExpandItem={handleExpandItem}
onExpandContent={handleExpandContent} onExpandContent={handleExpandContent}
viewport={activityTimelineViewport}
/> />
{hasMore && ( {hasMore && (
<div className="flex justify-center py-2"> <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', () => ({ vi.mock('@renderer/components/team/activity/AnimatedHeightReveal', () => ({
ENTRY_REVEAL_ANIMATION_MS: 220, ENTRY_REVEAL_ANIMATION_MS: 220,
AnimatedHeightReveal: ({ children }: { children: React.ReactNode }) => AnimatedHeightReveal: ({
React.createElement(React.Fragment, null, children), children,
containerRef,
}: {
children: React.ReactNode;
containerRef?: React.RefObject<HTMLDivElement | null>;
}) => React.createElement('div', { ref: containerRef }, children),
})); }));
vi.mock('@renderer/components/team/activity/useNewItemKeys', () => ({ vi.mock('@renderer/components/team/activity/useNewItemKeys', () => ({
@ -285,3 +290,123 @@ describe('ActivityTimeline session separators', () => {
}); });
}); });
}); });
describe('ActivityTimeline viewport observerRoot', () => {
let container: HTMLDivElement;
let capturedRoots: Array<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();
});
});