import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { toMessageKey } from '@renderer/utils/teamMessageKey'; import { ActivityItem, isNoiseMessage } from './ActivityItem'; import { AnimatedHeightReveal } from './AnimatedHeightReveal'; import { findNewestMessageIndex, resolveTimelineCollapseState } from './collapseState'; import { getThoughtGroupKey, groupTimelineItems, isLeadThought, LeadThoughtsGroupRow, } from './LeadThoughtsGroup'; import { useNewItemKeys } from './useNewItemKeys'; import type { ActivityCollapseState } from './collapseState'; 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; /** * All session IDs belonging to this team (current + history). * Used together with currentLeadSessionId to suppress only the reconnect boundary * from the current live session back into the team's previous session history. */ teamSessionIds?: Set; /** Current lead session ID for the active team, if known. */ currentLeadSessionId?: string; } const VIEWPORT_THRESHOLD = 0.15; const MESSAGES_PAGE_SIZE = 30; const MessageRowWithObserver = ({ message, teamName, memberRole, memberColor, recipientColor, isUnread, isNew, zebraShade, memberColorMap, onMemberNameClick, onCreateTask, onReply, onVisible, onTaskIdClick, onRestartTeam, collapseState, }: { message: InboxMessage; teamName: string; memberRole?: string; memberColor?: string; recipientColor?: string; isUnread?: boolean; isNew?: boolean; zebraShade?: boolean; memberColorMap?: Map; onMemberNameClick?: (name: string) => void; onCreateTask?: (subject: string, description: string) => void; onReply?: (message: InboxMessage) => void; onVisible?: (message: InboxMessage) => void; onTaskIdClick?: (taskId: string) => void; onRestartTeam?: () => void; collapseState?: ActivityCollapseState; }): 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 ( ); }; export const ActivityTimeline = ({ messages, teamName, members, readState, onCreateTaskFromMessage, onReplyToMessage, onMemberClick, onMessageVisible, onTaskIdClick, onRestartTeam, allCollapsed, expandOverrides, onToggleExpandOverride, teamSessionIds, currentLeadSessionId, }: ActivityTimelineProps): React.JSX.Element => { const [visibleCount, setVisibleCount] = useState(MESSAGES_PAGE_SIZE); const colorMap = members ? buildMemberColorMap(members) : new Map(); const memberInfo = new Map(); if (members) { for (const m of members) { const info = { role: m.role ?? (m.agentType !== 'general-purpose' ? m.agentType : undefined), color: colorMap.get(m.name), }; memberInfo.set(m.name, info); if (m.agentType && m.agentType !== m.name) { memberInfo.set(m.agentType, info); } } // Map "user" to team-lead's resolved color and role const leadMember = members.find( (m) => m.agentType === 'team-lead' || m.role?.toLowerCase().includes('lead') ); if (leadMember) { const leadInfo = memberInfo.get(leadMember.name); if (leadInfo) { memberInfo.set('user', { role: undefined, color: colorMap.get('user') }); } } } const handleMemberNameClick = (name: string): void => { const member = members?.find((m) => m.name === name || m.agentType === name); if (member) onMemberClick?.(member); }; // 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 (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 getItemSessionId = (item: TimelineItem): string | undefined => item.type === 'lead-thoughts' ? item.group.thoughts[0].leadSessionId : item.message.leadSessionId; // 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 getItemCollapseState = useCallback( (stableKey: string, itemIndex: number): ActivityCollapseState => resolveTimelineCollapseState({ allCollapsed, itemIndex, newestMessageIndex, isPinnedThoughtGroup: itemIndex === 0 && pinnedThoughtGroup != null, isExpandedOverride: expandOverrides?.has(stableKey) ?? false, onToggleOverride: onToggleExpandOverride ? () => onToggleExpandOverride(stableKey) : undefined, }), [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 info = memberInfo.get(firstThought.from); const itemKey = getThoughtGroupKey(group); const stableKey = itemKey; const collapseState = getItemCollapseState(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 prevSessionId = getItemSessionId(timelineItems[realIndex - 1]); const currSessionId = getItemSessionId(item); if (prevSessionId && currSessionId && prevSessionId !== currSessionId) { // Suppress only the boundary between the current live session and the team's // older session history. Older historical session boundaries should still render. const isReconnectBoundary = !!currentLeadSessionId && teamSessionIds && teamSessionIds.has(prevSessionId) && teamSessionIds.has(currSessionId) && (prevSessionId === currentLeadSessionId || currSessionId === currentLeadSessionId); if (!isReconnectBoundary) { 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 collapseState = getItemCollapseState(stableKey, realIndex); return ( {sessionSeparator} ); } const { message } = item; const info = memberInfo.get(message.from); const recipientInfo = message.to ? memberInfo.get(message.to) : undefined; const recipientColor = recipientInfo?.color ?? (message.to ? colorMap.get(message.to) : undefined); const messageKey = toMessageKey(message); const stableKey = messageKey; const collapseState = getItemCollapseState(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 && ( <> )}
)}
); };