import { useCallback, useEffect, useMemo, useState } from 'react'; import { buildInlineActivityEntries } from '@features/agent-graph/renderer'; import { api } from '@renderer/api'; import { ActivityItem } from '@renderer/components/team/activity/ActivityItem'; import { buildMessageContext, resolveMessageRenderProps, } from '@renderer/components/team/activity/activityMessageContext'; import { MessageExpandDialog } from '@renderer/components/team/activity/MessageExpandDialog'; import { Button } from '@renderer/components/ui/button'; import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead'; import { mergeTeamMessages } from '@renderer/utils/mergeTeamMessages'; import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering'; import { toMessageKey } from '@renderer/utils/teamMessageKey'; import { isLeadMember } from '@shared/utils/leadDetection'; import type { MemberActivityFilter } from './memberDetailTypes'; import type { TimelineItem } from '@renderer/components/team/activity/LeadThoughtsGroup'; import type { InboxMessage, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types'; interface MemberMessagesTabProps { messages: InboxMessage[]; teamName: string; memberName: string; members: ResolvedTeamMember[]; tasks: TeamTaskWithKanban[]; initialFilter?: MemberActivityFilter; onCreateTask?: (subject: string, description: string) => void; onTaskClick?: (task: TeamTaskWithKanban) => void; } const MAX_MESSAGES = 100; const MEMBER_MESSAGES_PAGE_SIZE = 50; const FILTER_OPTIONS: readonly { value: MemberActivityFilter; label: string }[] = [ { value: 'all', label: 'All' }, { value: 'messages', label: 'Messages' }, { value: 'comments', label: 'Comments' }, ]; export const MemberMessagesTab = ({ messages, teamName, memberName, members, tasks, initialFilter = 'all', onCreateTask, onTaskClick, }: MemberMessagesTabProps): React.JSX.Element => { const [pagedMessages, setPagedMessages] = useState([]); const [nextCursor, setNextCursor] = useState(null); const [hasMore, setHasMore] = useState(false); const [initialPageLoading, setInitialPageLoading] = useState(false); const [loadingOlderMessages, setLoadingOlderMessages] = useState(false); const [activityFilter, setActivityFilter] = useState(initialFilter); const [expandedItem, setExpandedItem] = useState(null); const { readSet } = useTeamMessagesRead(teamName); const leadId = `lead:${teamName}`; const leadName = useMemo( () => members.find((candidate) => isLeadMember(candidate))?.name ?? `${teamName}-lead`, [members, teamName] ); const ownerNodeId = memberName === leadName ? leadId : `member:${teamName}:${memberName}`; const ownerNodeIds = useMemo(() => new Set([leadId, ownerNodeId]), [leadId, ownerNodeId]); const taskMap = useMemo(() => new Map(tasks.map((task) => [task.id, task])), [tasks]); const messageContext = useMemo(() => buildMessageContext(members), [members]); useEffect(() => { setActivityFilter(initialFilter); }, [initialFilter, memberName, teamName]); useEffect(() => { let cancelled = false; setPagedMessages([]); setNextCursor(null); setHasMore(false); setInitialPageLoading(true); void (async () => { try { const page = await api.teams.getMessagesPage(teamName, { limit: MEMBER_MESSAGES_PAGE_SIZE, }); if (cancelled) return; const memberPageMessages = page.messages.filter( (message) => message.from === memberName || message.to === memberName ); setPagedMessages(memberPageMessages); setNextCursor(page.nextCursor); setHasMore(page.hasMore); } catch { if (!cancelled) { setPagedMessages([]); setNextCursor(null); setHasMore(false); } } finally { if (!cancelled) { setInitialPageLoading(false); } } })(); return () => { cancelled = true; }; }, [teamName, memberName]); const loadOlderMessages = useCallback(async () => { if (!nextCursor || loadingOlderMessages) return; setLoadingOlderMessages(true); try { const page = await api.teams.getMessagesPage(teamName, { beforeTimestamp: nextCursor, limit: MEMBER_MESSAGES_PAGE_SIZE, }); const memberPageMessages = page.messages.filter( (message) => message.from === memberName || message.to === memberName ); setPagedMessages((prev) => mergeTeamMessages(prev, memberPageMessages)); setNextCursor(page.nextCursor); setHasMore(page.hasMore); } catch { // best-effort } finally { setLoadingOlderMessages(false); } }, [loadingOlderMessages, memberName, nextCursor, teamName]); const effectiveMessages = useMemo( () => mergeTeamMessages(messages, pagedMessages), [messages, pagedMessages] ); const filteredMessages = useMemo( () => filterTeamMessages(effectiveMessages, { timeWindow: null, filter: { from: new Set(), to: new Set(), showNoise: true }, searchQuery: '', }), [effectiveMessages] ); const activityEntries = useMemo(() => { const entriesByOwner = buildInlineActivityEntries({ data: { members, tasks, messages: filteredMessages, }, teamName, leadId, leadName, ownerNodeIds, }); return (entriesByOwner.get(ownerNodeId) ?? []).slice(0, MAX_MESSAGES); }, [filteredMessages, leadId, leadName, members, ownerNodeId, ownerNodeIds, tasks, teamName]); const displayEntries = useMemo(() => { switch (activityFilter) { case 'messages': return activityEntries.filter( (entry) => entry.message.messageKind !== 'task_comment_notification' ); case 'comments': return activityEntries.filter( (entry) => entry.message.messageKind === 'task_comment_notification' ); default: return activityEntries; } }, [activityEntries, activityFilter]); const expandedItemsByKey = useMemo(() => { const items = new Map(); for (const entry of displayEntries) { items.set(toMessageKey(entry.message), { type: 'message', message: entry.message }); } return items; }, [displayEntries]); const handleExpandItem = useCallback( (key: string) => { const next = expandedItemsByKey.get(key); if (next) { setExpandedItem(next); } }, [expandedItemsByKey] ); const handleTaskIdClick = useCallback( (taskId: string) => { const task = taskMap.get(taskId) ?? tasks.find((candidate) => candidate.displayId === taskId); if (task) { onTaskClick?.(task); } }, [onTaskClick, taskMap, tasks] ); const emptyStateText = initialPageLoading ? 'Loading activity...' : activityFilter === 'comments' ? 'No comments for this member' : activityFilter === 'messages' ? hasMore ? 'No loaded messages for this member yet' : 'No messages with this member' : 'No activity with this member'; const canLoadOlderMessages = hasMore && activityFilter !== 'comments' && displayEntries.length > 0; return (
{FILTER_OPTIONS.map((option) => { const isActive = activityFilter === option.value; return ( ); })}
{displayEntries.length > 0 ? ( displayEntries.map((entry, index) => { const messageKey = toMessageKey(entry.message); const renderProps = resolveMessageRenderProps(entry.message, messageContext); const timelineItem: TimelineItem = { type: 'message', message: entry.message }; const isUnread = !entry.message.read && !readSet.has(messageKey); return (
setExpandedItem(timelineItem)} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); setExpandedItem(timelineItem); } }} >
); }) ) : (
{emptyStateText}
)} {canLoadOlderMessages && (
)}
{ if (!open) { setExpandedItem(null); } }} teamName={teamName} members={members} onTaskIdClick={handleTaskIdClick} onCreateTaskFromMessage={onCreateTask} />
); };