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, onMemberNameClick, onCreateTask, onReply, onVisible, onTaskIdClick, }: { message: InboxMessage; teamName: string; memberRole?: string; memberColor?: string; recipientColor?: string; isUnread?: 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); // 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); useEffect(() => { if (wasShowingAllRef.current && hiddenCount > 0) { queueMicrotask(() => setVisibleCount(messages.length)); } wasShowingAllRef.current = hiddenCount === 0; }, [hiddenCount, messages.length]); const visibleMessages = useMemo( () => (hiddenCount > 0 ? messages.slice(0, visibleCount) : messages), [messages, visibleCount, hiddenCount] ); 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 && (
+{hiddenCount} older {hiddenCount > MESSAGES_PAGE_SIZE && ( )}
)}
); };