import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { areInboxMessagesEquivalentForRender, areStringArraysEqual, areStringMapsEqual, } from '@renderer/utils/messageRenderEquality'; import { toMessageKey } from '@renderer/utils/teamMessageKey'; import { Layers } 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 { TimelineItem } from './LeadThoughtsGroup'; import type { InboxMessage, ResolvedTeamMember } from '@shared/types'; 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; } 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; interface ItemCollapseProps { collapseMode: 'default' | 'managed'; isCollapsed: boolean; canToggleCollapse: boolean; collapseToggleKey?: string; } /** Inline compaction boundary divider — styled like session separators but with amber accent. */ const CompactionDivider = ({ message }: { message: InboxMessage }): React.JSX.Element => (
{message.text}
); const MessageRowWithObserver = ({ message, teamName, memberRole, memberColor, recipientColor, isUnread, isNew, zebraShade, memberColorMap, localMemberNames, onMemberNameClick, onCreateTask, onReply, onVisible, onTaskIdClick, onRestartTeam, collapseMode, isCollapsed, canToggleCollapse, collapseToggleKey, onToggleCollapse, compactHeader, teamNames, teamColorByName, onTeamClick, onExpand, expandItemKey, onExpandContent, }: { message: InboxMessage; teamName: string; memberRole?: string; memberColor?: string; recipientColor?: string; isUnread?: boolean; isNew?: boolean; zebraShade?: boolean; memberColorMap?: Map; localMemberNames?: Set; onMemberNameClick?: (name: string) => void; onCreateTask?: (subject: string, description: string) => void; onReply?: (message: InboxMessage) => void; onVisible?: (message: InboxMessage) => void; onTaskIdClick?: (taskId: string) => void; onRestartTeam?: () => void; collapseMode: 'default' | 'managed'; isCollapsed: boolean; canToggleCollapse: boolean; collapseToggleKey?: string; onToggleCollapse?: (key: string) => void; compactHeader?: boolean; teamNames?: string[]; teamColorByName?: ReadonlyMap; onTeamClick?: (teamName: string) => void; onExpand?: (key: string) => void; expandItemKey?: string; onExpandContent?: () => void; }): React.JSX.Element => { const ref = useRef(null); const reportedRef = useRef(false); const messageRef = useRef(message); const onVisibleRef = useRef(onVisible); useEffect(() => { messageRef.current = message; onVisibleRef.current = onVisible; }, [message, onVisible]); useEffect(() => { if (!onVisible) return; const el = ref.current; if (!el) return; const observer = new IntersectionObserver( ([entry]) => { if (!entry?.isIntersecting) return; if (reportedRef.current) return; const cb = onVisibleRef.current; const msg = messageRef.current; if (!cb) return; reportedRef.current = true; cb(msg); }, { threshold: VIEWPORT_THRESHOLD, rootMargin: '0px' } ); observer.observe(el); return () => observer.disconnect(); }, [onVisible]); return ( ); }; const MemoizedMessageRowWithObserver = React.memo( MessageRowWithObserver, (prev, next) => prev.teamName === next.teamName && prev.memberRole === next.memberRole && prev.memberColor === next.memberColor && prev.recipientColor === next.recipientColor && prev.isUnread === next.isUnread && prev.isNew === next.isNew && prev.zebraShade === next.zebraShade && prev.memberColorMap === next.memberColorMap && prev.localMemberNames === next.localMemberNames && prev.onMemberNameClick === next.onMemberNameClick && prev.onCreateTask === next.onCreateTask && prev.onReply === next.onReply && prev.onVisible === next.onVisible && prev.onTaskIdClick === next.onTaskIdClick && prev.onRestartTeam === next.onRestartTeam && prev.collapseMode === next.collapseMode && prev.isCollapsed === next.isCollapsed && prev.canToggleCollapse === next.canToggleCollapse && prev.collapseToggleKey === next.collapseToggleKey && prev.onToggleCollapse === next.onToggleCollapse && prev.compactHeader === next.compactHeader && areStringArraysEqual(prev.teamNames, next.teamNames) && areStringMapsEqual(prev.teamColorByName, next.teamColorByName) && prev.onTeamClick === next.onTeamClick && prev.onExpand === next.onExpand && prev.expandItemKey === next.expandItemKey && prev.onExpandContent === next.onExpandContent && areInboxMessagesEquivalentForRender(prev.message, next.message) ); export const ActivityTimeline = React.memo(function ActivityTimeline({ messages, teamName, members, readState, onCreateTaskFromMessage, onReplyToMessage, onMemberClick, onMessageVisible, onTaskIdClick, onRestartTeam, allCollapsed, expandOverrides, onToggleExpandOverride, currentLeadSessionId, isTeamAlive, leadActivity, leadContextUpdatedAt, teamNames = EMPTY_TEAM_NAMES, teamColorByName = EMPTY_TEAM_COLOR_MAP, onTeamClick, onExpandItem, onExpandContent, }: ActivityTimelineProps): React.JSX.Element { const [visibleCount, setVisibleCount] = useState(MESSAGES_PAGE_SIZE); const rootRef = useRef(null); const [compactHeader, setCompactHeader] = useState(false); useEffect(() => { const el = rootRef.current; if (!el) return; const updateCompactMode = (width: number): void => { setCompactHeader((prev) => { const next = width < COMPACT_MESSAGES_WIDTH_PX; return prev === next ? prev : next; }); }; updateCompactMode(el.getBoundingClientRect().width); const observer = new ResizeObserver((entries) => { const entry = entries[0]; if (!entry) return; updateCompactMode(entry.contentRect.width); }); observer.observe(el); return () => observer.disconnect(); }, []); const ctx = useMemo(() => buildMessageContext(members), [members]); const { colorMap, localMemberNames, memberInfo } = ctx; const handleMemberNameClick = useCallback( (name: string) => { const member = members?.find( (candidate) => candidate.name === name || candidate.agentType === name ); if (member) onMemberClick?.(member); }, [members, onMemberClick] ); // Pagination counts only significant (non-thought) messages so that lead thoughts // don't consume the page limit — they collapse into a single visual group anyway. const { visibleMessages, hiddenCount } = useMemo(() => { const total = messages.length; if (total === 0) return { visibleMessages: messages, hiddenCount: 0 }; let significantSeen = 0; let cutoff = total; for (let i = 0; i < total; i++) { if (!isLeadThought(messages[i])) { significantSeen++; if (significantSeen > visibleCount) { cutoff = i; break; } } } const significantTotal = significantSeen + (cutoff < total ? messages.slice(cutoff).filter((m) => !isLeadThought(m)).length : 0); const hidden = Math.max(0, significantTotal - visibleCount); return { visibleMessages: cutoff < total ? messages.slice(0, cutoff) : messages, hiddenCount: hidden, }; }, [messages, visibleCount]); // Group consecutive lead thoughts into collapsible blocks. const timelineItems = useMemo(() => groupTimelineItems(visibleMessages), [visibleMessages]); // Zebra striping is anchored from the bottom of the visible list so prepending // new live messages at the top does not recolor every existing card. const zebraShadeSet = useMemo(() => { const result = new Set(); let cardCount = 0; for (let i = timelineItems.length - 1; i >= 0; i--) { const item = timelineItems[i]; if (item.type === 'lead-thoughts') { // Thought groups count as one card for striping if (cardCount % 2 === 1) result.add(i); cardCount++; } else { if (isNoiseMessage(item.message.text)) continue; if (isCompactionMessage(item.message)) continue; if (cardCount % 2 === 1) result.add(i); cardCount++; } } return result; }, [timelineItems]); const timelineItemKeys = useMemo(() => { const getItemKey = (item: TimelineItem): string => { if (item.type === 'lead-thoughts') { return getThoughtGroupKey(item.group); } return toMessageKey(item.message); }; return timelineItems.map(getItemKey); }, [timelineItems]); const newItemKeys = useNewItemKeys({ itemKeys: timelineItemKeys, paginationKey: visibleCount, resetKey: teamName, }); useEffect(() => { if (process.env.NODE_ENV === 'production') return; const seen = new Set(); const duplicates = new Set(); for (const key of timelineItemKeys) { if (seen.has(key)) duplicates.add(key); seen.add(key); } if (duplicates.size > 0) { console.warn('[ActivityTimeline] Duplicate timeline item keys detected', { teamName, duplicates: [...duplicates], }); } }, [teamName, timelineItemKeys]); const handleShowMore = (): void => { setVisibleCount((prev) => prev + MESSAGES_PAGE_SIZE); }; const handleShowAll = (): void => { setVisibleCount(Infinity); }; const getItemSessionAnchorId = (item: TimelineItem): string | undefined => { if (item.type === 'lead-thoughts') { return item.group.thoughts[0]?.leadSessionId; } return undefined; }; // Pin the newest thought group (if first) so it stays at the top and doesn't jump. const pinnedThoughtGroup = timelineItems[0]?.type === 'lead-thoughts' ? timelineItems[0] : null; const startIndex = pinnedThoughtGroup ? 1 : 0; // Determine the index of the "newest" non-thought timeline item (for auto-expand). const newestMessageIndex = useMemo(() => { return findNewestMessageIndex(timelineItems); }, [timelineItems]); /** * Compute the externally managed collapse state for an item in the timeline. * In collapsed mode we always keep the newest real message open, keep the pinned * thought group open, and let localStorage overrides reopen older items. */ const getItemCollapseProps = useCallback( (stableKey: string, itemIndex: number): ItemCollapseProps => { const collapseState = resolveTimelineCollapseState({ allCollapsed, itemIndex, newestMessageIndex, isPinnedThoughtGroup: itemIndex === 0 && pinnedThoughtGroup != null, isExpandedOverride: expandOverrides?.has(stableKey) ?? false, onToggleOverride: onToggleExpandOverride ? () => onToggleExpandOverride(stableKey) : undefined, }); if (collapseState.mode !== DEFAULT_COLLAPSE_MODE) { return { collapseMode: collapseState.mode, isCollapsed: collapseState.isCollapsed, canToggleCollapse: collapseState.canToggle, collapseToggleKey: collapseState.canToggle ? stableKey : undefined, }; } return { collapseMode: DEFAULT_COLLAPSE_MODE, isCollapsed: false, canToggleCollapse: false, }; }, [allCollapsed, newestMessageIndex, pinnedThoughtGroup, expandOverrides, onToggleExpandOverride] ); if (messages.length === 0) { return (

No messages

Send a message to a member to see activity.

); } return (
{/* 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 ( ); })()} {/* 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); let prevSessionId: string | undefined; for (let searchIndex = realIndex - 1; searchIndex >= 0; searchIndex -= 1) { const candidateSessionId = getItemSessionAnchorId(timelineItems[searchIndex]); if (candidateSessionId) { prevSessionId = candidateSessionId; break; } } if (prevSessionId && currSessionId && prevSessionId !== currSessionId) { sessionSeparator = (
New session
); } } 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 ( {sessionSeparator} ); } const { message } = item; // Compaction boundary — render as a divider instead of a regular message card if (isCompactionMessage(message)) { const messageKey = toMessageKey(message); return ( {sessionSeparator} ); } 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 ( {sessionSeparator} ); })} {hiddenCount > 0 && (
{/* Bottom-up shadow gradient: darkest at bottom edge, fades upward */}
+{hiddenCount} older {hiddenCount > MESSAGES_PAGE_SIZE && ( <> )}
)}
); });