diff --git a/src/renderer/components/team/activity/ActivityTimeline.tsx b/src/renderer/components/team/activity/ActivityTimeline.tsx index abc3313d..ded03449 100644 --- a/src/renderer/components/team/activity/ActivityTimeline.tsx +++ b/src/renderer/components/team/activity/ActivityTimeline.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { type RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { areInboxMessagesEquivalentForRender, @@ -24,6 +24,34 @@ import { useNewItemKeys } from './useNewItemKeys'; import type { TimelineItem } from './LeadThoughtsGroup'; import type { InboxMessage, ResolvedTeamMember } from '@shared/types'; +/** + * Viewport contract — describes the scroll container that hosts the timeline + * and how ActivityTimeline should report visibility against it. When omitted, + * ActivityTimeline falls back to the document viewport (current behavior). + * + * This contract is grouped intentionally so consumers pass a single coherent + * object rather than threading several refs and flags. Virtualizer wiring + * lands in a follow-up; for now only `observerRoot` has an observable effect. + */ +export interface TimelineViewport { + /** The element that actually scrolls. */ + scrollElementRef: RefObject; + /** + * Root element for IntersectionObserver-based visibility tracking. + * Typically the same node as `scrollElementRef`, but left separate so + * future code can observe a more specific inner container when needed. + */ + observerRoot?: RefObject; + /** + * Distance from the scroll container's scroll origin to the timeline root, + * measured from the DOM. Zero in this release; used by the virtualizer in a + * follow-up change. + */ + scrollMargin?: number; + /** Enable virtualization (wired in a follow-up; ignored for now). */ + virtualizationEnabled?: boolean; +} + interface ActivityTimelineProps { messages: InboxMessage[]; teamName: string; @@ -66,6 +94,14 @@ interface ActivityTimelineProps { onExpandItem?: (key: string) => void; /** Called when ExpandableContent is expanded via "Show more" in any ActivityItem. */ onExpandContent?: () => void; + /** + * Optional viewport contract. When provided, IntersectionObserver uses the + * passed `observerRoot` instead of the document viewport, which is required + * for correctness inside scrollable layouts (sidebar, bottom-sheet) where + * the row may be clipped by its scroll parent while still intersecting the + * page viewport. + */ + viewport?: TimelineViewport; } const VIEWPORT_THRESHOLD = 0.15; @@ -141,6 +177,7 @@ const MessageRowWithObserver = ({ onExpand, expandItemKey, onExpandContent, + observerRoot, }: { message: InboxMessage; teamName: string; @@ -170,6 +207,7 @@ const MessageRowWithObserver = ({ onExpand?: (key: string) => void; expandItemKey?: string; onExpandContent?: () => void; + observerRoot?: RefObject; }): React.JSX.Element => { const ref = useRef(null); const reportedRef = useRef(false); @@ -185,6 +223,10 @@ const MessageRowWithObserver = ({ if (!onVisible) return; const el = ref.current; if (!el) return; + // Resolve the observer root at effect-time. Falls back to the document + // viewport (null) when no root is provided — preserves pre-contract + // behavior for layouts without a known scroll owner. + const root = observerRoot?.current ?? null; const observer = new IntersectionObserver( ([entry]) => { if (!entry?.isIntersecting) return; @@ -195,11 +237,11 @@ const MessageRowWithObserver = ({ reportedRef.current = true; cb(msg); }, - { threshold: VIEWPORT_THRESHOLD, rootMargin: '0px' } + { root, threshold: VIEWPORT_THRESHOLD, rootMargin: '0px' } ); observer.observe(el); return () => observer.disconnect(); - }, [onVisible]); + }, [onVisible, observerRoot]); return ( @@ -265,6 +307,7 @@ const MemoizedMessageRowWithObserver = React.memo( prev.onExpand === next.onExpand && prev.expandItemKey === next.expandItemKey && prev.onExpandContent === next.onExpandContent && + prev.observerRoot === next.observerRoot && areInboxMessagesEquivalentForRender(prev.message, next.message) ); @@ -291,7 +334,9 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({ onTeamClick, onExpandItem, onExpandContent, + viewport, }: ActivityTimelineProps): React.JSX.Element { + const observerRoot = viewport?.observerRoot ?? viewport?.scrollElementRef; const [visibleCount, setVisibleCount] = useState(MESSAGES_PAGE_SIZE); const rootRef = useRef(null); const [compactHeader, setCompactHeader] = useState(false); @@ -519,6 +564,7 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({ leadContextUpdatedAt={pinnedCanBeLive ? leadContextUpdatedAt : undefined} isNew={newItemKeys.has(itemKey)} onVisible={onMessageVisible} + observerRoot={observerRoot} zebraShade={zebraShadeSet.has(0)} collapseMode={collapseProps.collapseMode} isCollapsed={collapseProps.isCollapsed} @@ -579,6 +625,7 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({ canBeLive={false} isNew={newItemKeys.has(itemKey)} onVisible={onMessageVisible} + observerRoot={observerRoot} zebraShade={zebraShadeSet.has(realIndex)} collapseMode={collapseProps.collapseMode} isCollapsed={collapseProps.isCollapsed} @@ -650,6 +697,7 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({ onTeamClick={onTeamClick} onExpand={compactHeader ? onExpandItem : undefined} expandItemKey={compactHeader ? messageKey : undefined} + observerRoot={observerRoot} onExpandContent={onExpandContent} /> diff --git a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx index 9ee1adc1..bcb1626f 100644 --- a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx +++ b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx @@ -1,6 +1,7 @@ import { type JSX, memo, + type RefObject, useCallback, useEffect, useLayoutEffect, @@ -157,6 +158,14 @@ interface LeadThoughtsGroupRowProps { memberColor?: string; isNew?: boolean; onVisible?: (message: InboxMessage) => void; + /** + * Root element for IntersectionObserver-based visibility tracking. When + * omitted, the observer falls back to the document viewport — correct for + * top-level renders, incorrect when the row is inside a scroll container + * (sidebar, bottom-sheet) that can clip the row while the document + * viewport still contains it. + */ + observerRoot?: RefObject; /** When false, the live indicator is always off (for historical thought groups). */ canBeLive?: boolean; /** Whether the owning team is currently alive. */ @@ -528,6 +537,7 @@ const LeadThoughtsGroupRowComponent = ({ memberColor, isNew, onVisible, + observerRoot, canBeLive, isTeamAlive, leadActivity, @@ -637,6 +647,9 @@ const LeadThoughtsGroupRowComponent = ({ if (!onVisible) return; const el = ref.current; if (!el) return; + // Resolve observer root at effect-time. Falls back to the document + // viewport when no root is provided — preserves pre-contract behavior. + const root = observerRoot?.current ?? null; const observer = new IntersectionObserver( ([entry]) => { if (!entry?.isIntersecting) return; @@ -647,11 +660,11 @@ const LeadThoughtsGroupRowComponent = ({ } reportedCountRef.current = thoughts.length; }, - { threshold: VIEWPORT_THRESHOLD, rootMargin: '0px' } + { root, threshold: VIEWPORT_THRESHOLD, rootMargin: '0px' } ); observer.observe(el); return () => observer.disconnect(); - }, [onVisible, thoughts]); + }, [onVisible, observerRoot, thoughts]); const clearPendingScrollSync = useCallback(() => { if (scrollSyncFrameRef.current !== null) { @@ -1134,5 +1147,6 @@ export const LeadThoughtsGroupRow = memo( prev.compactHeader === next.compactHeader && prev.onExpand === next.onExpand && prev.expandItemKey === next.expandItemKey && + prev.observerRoot === next.observerRoot && areThoughtGroupsEquivalent(prev.group, next.group) ); diff --git a/src/renderer/components/team/messages/MessagesPanel.tsx b/src/renderer/components/team/messages/MessagesPanel.tsx index 98ec9c66..c9e37131 100644 --- a/src/renderer/components/team/messages/MessagesPanel.tsx +++ b/src/renderer/components/team/messages/MessagesPanel.tsx @@ -36,7 +36,7 @@ import { } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; -import { ActivityTimeline } from '../activity/ActivityTimeline'; +import { ActivityTimeline, type TimelineViewport } from '../activity/ActivityTimeline'; import { getThoughtGroupKey, groupTimelineItems } from '../activity/LeadThoughtsGroup'; import { MessageExpandDialog } from '../activity/MessageExpandDialog'; import { CollapsibleTeamSection } from '../CollapsibleTeamSection'; @@ -183,6 +183,7 @@ export const MessagesPanel = memo(function MessagesPanel({ onReplyToMessage, onRestartTeam, onTaskIdClick, + inlineScrollContainerRef, }: MessagesPanelProps): React.JSX.Element { const { sendTeamMessage, @@ -235,6 +236,28 @@ export const MessagesPanel = memo(function MessagesPanel({ // Held here so future viewport consumers (virtualization) can observe the // true scrolling element in bottom-sheet mode. const bottomSheetScrollRef = useRef(null); + + // Resolve the active scroll owner for the current layout. This is the + // ref that ActivityTimeline's IntersectionObserver will use as its root, + // so visibility is measured against the real scroll container rather + // than the document viewport. Virtualizer consumers will hook into the + // same ref in a follow-up change. + const activeScrollContainerRef = + position === 'inline' + ? (inlineScrollContainerRef ?? null) + : position === 'sidebar' + ? sidebarScrollRef + : bottomSheetScrollRef; + + const activityTimelineViewport = useMemo(() => { + if (!activeScrollContainerRef) return undefined; + return { + scrollElementRef: activeScrollContainerRef, + observerRoot: activeScrollContainerRef, + scrollMargin: 0, + virtualizationEnabled: false, + }; + }, [activeScrollContainerRef]); const handleExpandContent = useCallback(() => { // no-op: user is reading expanded content, not composing }, []); @@ -678,6 +701,7 @@ export const MessagesPanel = memo(function MessagesPanel({ onTaskIdClick={onTaskIdClick} onExpandItem={handleExpandItem} onExpandContent={handleExpandContent} + viewport={activityTimelineViewport} /> {hasMore && (
@@ -863,6 +887,7 @@ export const MessagesPanel = memo(function MessagesPanel({ onTaskIdClick={onTaskIdClick} onExpandItem={handleExpandItem} onExpandContent={handleExpandContent} + viewport={activityTimelineViewport} /> {hasMore && (
@@ -1150,6 +1175,7 @@ export const MessagesPanel = memo(function MessagesPanel({ onTaskIdClick={onTaskIdClick} onExpandItem={handleExpandItem} onExpandContent={handleExpandContent} + viewport={activityTimelineViewport} /> {hasMore && (
diff --git a/test/renderer/components/team/activity/ActivityTimeline.test.ts b/test/renderer/components/team/activity/ActivityTimeline.test.ts index d02b5bb1..3b8bb73f 100644 --- a/test/renderer/components/team/activity/ActivityTimeline.test.ts +++ b/test/renderer/components/team/activity/ActivityTimeline.test.ts @@ -13,8 +13,13 @@ vi.mock('@renderer/components/team/activity/ActivityItem', () => ({ vi.mock('@renderer/components/team/activity/AnimatedHeightReveal', () => ({ ENTRY_REVEAL_ANIMATION_MS: 220, - AnimatedHeightReveal: ({ children }: { children: React.ReactNode }) => - React.createElement(React.Fragment, null, children), + AnimatedHeightReveal: ({ + children, + containerRef, + }: { + children: React.ReactNode; + containerRef?: React.RefObject; + }) => React.createElement('div', { ref: containerRef }, children), })); vi.mock('@renderer/components/team/activity/useNewItemKeys', () => ({ @@ -285,3 +290,123 @@ describe('ActivityTimeline session separators', () => { }); }); }); + +describe('ActivityTimeline viewport observerRoot', () => { + let container: HTMLDivElement; + let capturedRoots: Array; + let originalIntersectionObserver: + | typeof globalThis.IntersectionObserver + | undefined; + + beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + container = document.createElement('div'); + document.body.appendChild(container); + + capturedRoots = []; + originalIntersectionObserver = globalThis.IntersectionObserver; + class FakeIntersectionObserver { + public readonly root: Element | Document | null; + public readonly rootMargin: string; + public readonly thresholds: ReadonlyArray; + constructor( + _callback: IntersectionObserverCallback, + options?: IntersectionObserverInit + ) { + this.root = options?.root ?? null; + this.rootMargin = options?.rootMargin ?? '0px'; + this.thresholds = Array.isArray(options?.threshold) + ? options.threshold + : typeof options?.threshold === 'number' + ? [options.threshold] + : [0]; + capturedRoots.push(this.root); + } + observe(): void {} + unobserve(): void {} + disconnect(): void {} + takeRecords(): IntersectionObserverEntry[] { + return []; + } + } + vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver); + }); + + afterEach(() => { + if (originalIntersectionObserver) { + globalThis.IntersectionObserver = originalIntersectionObserver; + } + container.remove(); + document.body.innerHTML = ''; + vi.unstubAllGlobals(); + }); + + it('creates IntersectionObservers with root=null when no viewport is passed', async () => { + const root = createRoot(container); + const messages: InboxMessage[] = [ + makeMessage({ + messageId: 'msg-1', + text: 'hello', + from: 'alice', + source: 'inbox', + }), + ]; + + await act(async () => { + root.render( + React.createElement(ActivityTimeline, { + messages, + teamName: 'demo-team', + onMessageVisible: () => {}, + }) + ); + }); + + expect(capturedRoots.length).toBeGreaterThan(0); + expect(capturedRoots.every((r) => r === null)).toBe(true); + + await act(async () => { + root.unmount(); + }); + }); + + it('creates IntersectionObservers with the provided root when viewport.observerRoot is set', async () => { + const scrollHost = document.createElement('div'); + document.body.appendChild(scrollHost); + const scrollRef = { current: scrollHost }; + + const root = createRoot(container); + const messages: InboxMessage[] = [ + makeMessage({ + messageId: 'msg-1', + text: 'hello', + from: 'alice', + source: 'inbox', + }), + ]; + + await act(async () => { + root.render( + React.createElement(ActivityTimeline, { + messages, + teamName: 'demo-team', + onMessageVisible: () => {}, + viewport: { + scrollElementRef: scrollRef, + observerRoot: scrollRef, + scrollMargin: 0, + virtualizationEnabled: false, + }, + }) + ); + }); + + expect(capturedRoots.length).toBeGreaterThan(0); + expect(capturedRoots.every((r) => r === scrollHost)).toBe(true); + + await act(async () => { + root.unmount(); + }); + scrollHost.remove(); + }); +});