import { useEffect, useMemo, useRef, useState } from 'react'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { ActivityItem } from './ActivityItem'; 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; } const VIEWPORT_THRESHOLD = 0.15; const MESSAGES_PAGE_SIZE = 30; const MessageRowWithObserver = ({ message, teamName, memberRole, memberColor, recipientColor, isUnread, isNew, onMemberNameClick, onCreateTask, onReply, onVisible, onTaskIdClick, }: { message: InboxMessage; teamName: string; memberRole?: string; memberColor?: string; recipientColor?: string; isUnread?: boolean; isNew?: boolean; onMemberNameClick?: (name: string) => void; onCreateTask?: (subject: string, description: string) => void; onReply?: (message: InboxMessage) => void; onVisible?: (message: InboxMessage) => void; onTaskIdClick?: (taskId: string) => 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 (
); }; export const ActivityTimeline = ({ messages, teamName, members, readState, onCreateTaskFromMessage, onReplyToMessage, onMemberClick, onMessageVisible, onTaskIdClick, }: 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); // Track whether the user was seeing ALL messages (no hidden ones). // If so, auto-expand when new messages push count past the limit, // so previously visible messages don't silently disappear. const wasShowingAllRef = useRef(messages.length <= 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: leadInfo.role, color: colorMap.get('user') }); } } } const handleMemberNameClick = (name: string): void => { const member = members?.find((m) => m.name === name || m.agentType === name); if (member) onMemberClick?.(member); }; const hiddenCount = Math.max(0, messages.length - visibleCount); // Auto-expand when user was seeing all and new messages arrive — derived state sync. // Reading/updating ref during render is intentional (React docs: derived state sync). /* eslint-disable react-hooks/refs -- intentional ref access during render for animation tracking */ const wasShowingAll = wasShowingAllRef.current; if (wasShowingAll && hiddenCount > 0) { setVisibleCount(messages.length); } wasShowingAllRef.current = hiddenCount === 0; const visibleMessages = useMemo( () => (hiddenCount > 0 ? messages.slice(0, visibleCount) : messages), [messages, visibleCount, hiddenCount] ); // Determine which messages are "new" (should animate). const newMessageKeys = useMemo(() => { const getKey = (msg: InboxMessage, idx: number): string => `${msg.messageId ?? idx}-${msg.timestamp}-${msg.from}`; // First render: seed known keys, no animations if (!isInitializedRef.current) { isInitializedRef.current = true; for (let i = 0; i < visibleMessages.length; i++) { knownKeysRef.current.add(getKey(visibleMessages[i], i)); } 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 (let i = 0; i < visibleMessages.length; i++) { knownKeysRef.current.add(getKey(visibleMessages[i], i)); } return new Set(); } // Normal update: unknown keys are new messages const newKeys = new Set(); for (let i = 0; i < visibleMessages.length; i++) { const key = getKey(visibleMessages[i], i); if (!knownKeysRef.current.has(key)) { newKeys.add(key); knownKeysRef.current.add(key); } } return newKeys; }, [visibleMessages, 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.

); } return (
{visibleMessages.map((message, index) => { 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 globalIndex = index; const messageKey = `${message.messageId ?? globalIndex}-${message.timestamp}-${message.from}`; const isUnread = readState ? !message.read && !readState.readSet.has(readState.getMessageKey(message)) : !message.read; return ( ); })} {hiddenCount > 0 && (
{/* Bottom-up shadow gradient: darkest at bottom edge, fades upward */}
+{hiddenCount} older {hiddenCount > MESSAGES_PAGE_SIZE && ( <> )}
)}
); };