import React, { type RefObject, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from 'react'; import { areInboxMessagesEquivalentForRender, areStringArraysEqual, areStringMapsEqual, } from '@renderer/utils/messageRenderEquality'; import { toMessageKey } from '@renderer/utils/teamMessageKey'; import { useVirtualizer } from '@tanstack/react-virtual'; import { Layers, Loader2 } from 'lucide-react'; import { ActivityItem, isNoiseMessage } from './ActivityItem'; import { buildMessageContext, resolveMessageRenderProps } from './activityMessageContext'; import { AnimatedHeightReveal } from './AnimatedHeightReveal'; import { findNewestMessageIndex, resolveTimelineCollapseState } from './collapseState'; import { getThoughtGroupKey, groupTimelineItems, isCompactionMessage, isLeadThought, LeadThoughtsGroupRow, } from './LeadThoughtsGroup'; import { useNewItemKeys } from './useNewItemKeys'; import type { LeadThoughtGroup, TimelineItem } from './LeadThoughtsGroup'; import type { InboxMessage, ResolvedTeamMember } from '@shared/types'; /** * A single visual row in the timeline. The render phase maps 1:1 from this * list into JSX, which is the shape a windowing library (e.g. * `@tanstack/react-virtual`) expects. Grouping happens earlier, in * `groupTimelineItems`; this layer flattens groups/separators/dividers into * atomic rows so each one can be measured and rendered independently. * * The `itemIndex` fields point back into `timelineItems` so per-item state * (collapse mode, zebra shading, "is new" flag, session anchor) can still be * resolved without threading it through every row entry. */ type TimelineRow = | { kind: 'session-separator'; key: string } | { kind: 'lead-thought-group'; key: string; itemIndex: number; group: LeadThoughtGroup; isPinned: boolean; } | { kind: 'compaction-divider'; key: string; message: InboxMessage } | { kind: 'message-row'; key: string; itemIndex: number; message: InboxMessage }; /** * Viewport contract — describes the scroll container that hosts the timeline * and how ActivityTimeline should report visibility against it. When omitted, * ActivityTimeline falls back to the document viewport (current behavior). * * This contract is grouped intentionally so consumers pass a single coherent * object rather than threading several refs and flags. Virtualizer wiring * lands in a follow-up; for now only `observerRoot` has an observable effect. */ export interface TimelineViewport { /** The element that actually scrolls. */ scrollElementRef: RefObject; /** * Root element for IntersectionObserver-based visibility tracking. * Typically the same node as `scrollElementRef`, but left separate so * future code can observe a more specific inner container when needed. */ observerRoot?: RefObject; /** * Distance from the scroll container's scroll origin to the timeline root, * measured from the DOM. Zero in this release; used by the virtualizer in a * follow-up change. */ scrollMargin?: number; /** Enable virtualization (wired in a follow-up; ignored for now). */ virtualizationEnabled?: boolean; } interface ActivityTimelineProps { messages: InboxMessage[]; teamName: string; members?: ResolvedTeamMember[]; /** * When provided, unread is derived from this set and getMessageKey. * When omitted, unread is derived from message.read. */ readState?: { readSet: Set; getMessageKey: (message: InboxMessage) => string }; onCreateTaskFromMessage?: (subject: string, description: string) => void; onReplyToMessage?: (message: InboxMessage) => void; onMemberClick?: (member: ResolvedTeamMember) => void; /** Called when a message enters the viewport (for marking as read). */ onMessageVisible?: (message: InboxMessage) => void; /** Called when a task ID link (e.g. #10) is clicked in message text. */ onTaskIdClick?: (taskId: string) => void; /** Called when the user clicks "Restart team" on an auth error message. */ onRestartTeam?: () => void; /** When true, collapse all message bodies — show only headers with expand chevrons. */ allCollapsed?: boolean; /** Set of stable message keys that the user has manually expanded in collapsed mode. */ expandOverrides?: Set; /** Called when user toggles expand/collapse override on a specific message. */ onToggleExpandOverride?: (key: string) => void; /** Current lead session ID for the active team, if known. */ currentLeadSessionId?: string; /** Whether the current team is alive. */ isTeamAlive?: boolean; /** Current lead activity status for the active team. */ leadActivity?: string; /** Latest lead context timestamp for the active team. */ leadContextUpdatedAt?: string; /** Team names used for mention/team-link rendering. */ teamNames?: string[]; /** Team color mapping used by markdown viewers. */ teamColorByName?: ReadonlyMap; /** Opens a team tab from cross-team badges or team:// links. */ onTeamClick?: (teamName: string) => void; /** Callback to expand a message/thought item into a fullscreen dialog. */ onExpandItem?: (key: string) => void; /** Called when ExpandableContent is expanded via "Show more" in any ActivityItem. */ onExpandContent?: () => void; /** True while the initial message page is loading and no cached rows are available yet. */ loading?: boolean; /** * 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 MESSAGES_PAGE_SIZE = 30; const COMPACT_MESSAGES_WIDTH_PX = 400; const EMPTY_TEAM_NAMES: string[] = []; const EMPTY_TEAM_COLOR_MAP = new Map(); const DEFAULT_COLLAPSE_MODE = 'default' as const; const VIRTUALIZER_OVERSCAN = 8; const VIRTUALIZATION_ROW_GAP_PX = 4; /** * Row count above which virtualization is worth its complexity cost. Below * this, the direct render path is both simpler and faster (no wrapper div, * no position: absolute, no measurement churn). Chosen so conversations under * roughly one session of activity stay on the direct path and the virtualized * path only activates when scrolling behavior actually starts to matter. */ const VIRTUALIZATION_ROW_THRESHOLD = 60; /** * Per-kind height estimates for `estimateSize`. These are rough initial guesses * only; the virtualizer re-measures rows as they mount via `measureElement` * (wired in a follow-up PR), so small inaccuracies here are self-correcting. * Sizes come from visually averaged steady-state heights in production layouts. */ const ROW_SIZE_ESTIMATES: Record = { 'session-separator': 135, 'compaction-divider': 50, 'lead-thought-group': 220, 'message-row': 140, }; const TimelineLoadingState = (): React.JSX.Element => (
Loading messages...