import React, { useEffect, useMemo, useRef, useState } from 'react'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { ActivityItem, isNoiseMessage } from './ActivityItem'; import { groupTimelineItems, isLeadThought, LeadThoughtsGroupRow } from './LeadThoughtsGroup'; 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; } 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, forceCollapsed, }: { 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; forceCollapsed?: boolean; }): 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, }: ActivityTimelineProps): React.JSX.Element => { const [visibleCount, setVisibleCount] = useState(MESSAGES_PAGE_SIZE); // --- New-message animation tracking --- const knownKeysRef = useRef>(new Set()); const isInitializedRef = useRef(false); const prevVisibleCountRef = useRef(visibleCount); 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: alternate shade on non-noise (full card) items only. const zebraShadeSet = useMemo(() => { const result = new Set(); let cardCount = 0; for (let i = 0; i < timelineItems.length; 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]); // Determine which items are "new" (should animate). /* eslint-disable react-hooks/refs -- intentional ref access during render for animation tracking */ const newItemKeys = useMemo(() => { const getItemKey = (item: TimelineItem): string => { if (item.type === 'lead-thoughts') { // Stable key: identify group by its first thought, not by count (which changes) return `thoughts-${item.group.thoughts[0].messageId ?? item.originalIndices[0]}`; } const msg = item.message; return `${msg.messageId ?? item.originalIndex}-${msg.timestamp}-${msg.from}`; }; const allKeys: string[] = []; for (const item of timelineItems) { allKeys.push(getItemKey(item)); } // First render: seed known keys, no animations if (!isInitializedRef.current) { isInitializedRef.current = true; for (const key of allKeys) { knownKeysRef.current.add(key); } prevVisibleCountRef.current = visibleCount; return new Set(); } // Pagination expansion ("Show more" / "Show all"): add keys silently const isPaginationExpansion = visibleCount > prevVisibleCountRef.current; prevVisibleCountRef.current = visibleCount; if (isPaginationExpansion) { for (const key of allKeys) { knownKeysRef.current.add(key); } return new Set(); } // Normal update: unknown keys are new items const newKeys = new Set(); for (const key of allKeys) { if (!knownKeysRef.current.has(key)) { newKeys.add(key); knownKeysRef.current.add(key); } } return newKeys; }, [timelineItems, visibleCount]); /* eslint-enable react-hooks/refs -- end animation tracking block */ const handleShowMore = (): void => { setVisibleCount((prev) => prev + MESSAGES_PAGE_SIZE); }; const handleShowAll = (): void => { setVisibleCount(Infinity); }; if (messages.length === 0) { return (

No messages

Send a message to a member to see activity.

); } 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; 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 = `thoughts-${firstThought.messageId ?? pinnedThoughtGroup.originalIndices[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) { sessionSeparator = (
New session
); } } if (item.type === 'lead-thoughts') { const { group } = item; const firstThought = group.thoughts[0]; const info = memberInfo.get(firstThought.from); const itemKey = `thoughts-${firstThought.messageId ?? item.originalIndices[0]}`; 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 = `${message.messageId ?? item.originalIndex}-${message.timestamp}-${message.from}`; 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 && ( <> )}
)}
); };