import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { api } from '@renderer/api'; import { Badge } from '@renderer/components/ui/badge'; import { Button } from '@renderer/components/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { useStableTeamMentionMeta } from '@renderer/hooks/useStableTeamMentionMeta'; import { useTeamMessagesExpanded } from '@renderer/hooks/useTeamMessagesExpanded'; import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead'; import { useStore } from '@renderer/store'; import { mergeTeamMessages } from '@renderer/utils/mergeTeamMessages'; import { useShallow } from 'zustand/react/shallow'; import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering'; import { toMessageKey } from '@renderer/utils/teamMessageKey'; import { createLogger } from '@shared/utils/logger'; import { shouldExcludeInboxTextFromReplyCandidates } from '@shared/utils/idleNotificationSemantics'; import { CheckCheck, ChevronsDownUp, ChevronsUpDown, MessageSquare, PanelLeft, PanelLeftClose, Search, X, } from 'lucide-react'; import { ActivityTimeline } from '../activity/ActivityTimeline'; import { getThoughtGroupKey, groupTimelineItems } from '../activity/LeadThoughtsGroup'; import { MessageExpandDialog } from '../activity/MessageExpandDialog'; import { CollapsibleTeamSection } from '../CollapsibleTeamSection'; import { getTeamMessagesSidebarUiState, setTeamMessagesSidebarUiState, } from '../sidebar/teamSidebarUiState'; import { MessageComposer } from './MessageComposer'; import { MessagesFilterPopover } from './MessagesFilterPopover'; import { StatusBlock } from './StatusBlock'; import type { TimelineItem } from '../activity/LeadThoughtsGroup'; import type { ActionMode } from './ActionModeSelector'; import type { MessagesFilterState } from './MessagesFilterPopover'; import type { InboxMessage, ResolvedTeamMember, TaskRef, TeamTaskWithKanban } from '@shared/types'; interface TimeWindow { start: number; end: number; } const logger = createLogger('Component:MessagesPanel'); const MESSAGES_PANEL_FILTER_WARN_MS = 8; const MESSAGES_PANEL_EXPANDED_ITEM_WARN_MS = 6; interface MessagesPanelProps { teamName: string; position: 'sidebar' | 'inline'; onTogglePosition: () => void; /** Active (non-removed) members. */ members: ResolvedTeamMember[]; /** All team tasks. */ tasks: TeamTaskWithKanban[]; /** All raw messages from team data. */ messages: InboxMessage[]; /** Whether the team is alive. */ isTeamAlive?: boolean; /** Live lead activity status for the current team. */ leadActivity?: string; /** Latest lead context timestamp for the current team. */ leadContextUpdatedAt?: string; /** Time window for filtering. */ timeWindow: TimeWindow | null; /** Team session IDs for timeline. */ teamSessionIds: Set; /** Current lead session ID. */ currentLeadSessionId?: string; /** Pending replies tracker (shared with parent for MemberList). */ pendingRepliesByMember: Record; /** Update pending replies tracker. */ onPendingReplyChange: (updater: (prev: Record) => Record) => void; /** Callback when a member is clicked in the timeline. */ onMemberClick?: (member: ResolvedTeamMember) => void; /** Callback when a task is clicked from timeline or status block. */ onTaskClick?: (task: TeamTaskWithKanban) => void; /** Callback to open create task dialog from a message. */ onCreateTaskFromMessage?: (subject: string, description: string) => void; /** Callback to open reply dialog for a message. */ onReplyToMessage?: (message: InboxMessage) => void; /** Callback when "Restart team" is clicked. */ onRestartTeam?: () => void; /** Callback when a task ID link is clicked. */ onTaskIdClick?: (taskId: string) => void; } export const MessagesPanel = memo(function MessagesPanel({ teamName, position, onTogglePosition, members, tasks, messages, isTeamAlive, leadActivity, leadContextUpdatedAt, timeWindow, teamSessionIds, currentLeadSessionId, pendingRepliesByMember, onPendingReplyChange, onMemberClick, onTaskClick, onCreateTaskFromMessage, onReplyToMessage, onRestartTeam, onTaskIdClick, }: MessagesPanelProps): React.JSX.Element { const { sendTeamMessage, sendCrossTeamMessage, sendingMessage, sendMessageError, lastSendMessageResult, teams, openTeamTab, } = useStore( useShallow((s) => ({ sendTeamMessage: s.sendTeamMessage, sendCrossTeamMessage: s.sendCrossTeamMessage, sendingMessage: s.sendingMessage, sendMessageError: s.sendMessageError, lastSendMessageResult: s.lastSendMessageResult, teams: s.teams, openTeamTab: s.openTeamTab, })) ); // ── Paginated message fetching ── // Messages are now fetched via getMessagesPage API instead of coming // from getTeamData. The `messages` prop is used as initial seed if non-empty. const PAGE_SIZE = 50; const [fetchedMessages, setFetchedMessages] = useState([]); const [nextCursor, setNextCursor] = useState(null); const [hasMore, setHasMore] = useState(false); const [messagesLoading, setMessagesLoading] = useState(false); const fetchIdRef = useRef(0); // Initial fetch on mount or team change useEffect(() => { const id = ++fetchIdRef.current; setMessagesLoading(true); void (async () => { try { const page = await api.teams.getMessagesPage(teamName, { limit: PAGE_SIZE }); if (fetchIdRef.current !== id) return; setFetchedMessages(page.messages); setNextCursor(page.nextCursor); setHasMore(page.hasMore); } catch { // Fallback: use prop messages if API fails if (fetchIdRef.current === id && messages.length > 0) { setFetchedMessages(messages); } } finally { if (fetchIdRef.current === id) setMessagesLoading(false); } })(); }, [teamName]); // eslint-disable-line react-hooks/exhaustive-deps -- intentionally only on teamName change // Auto-refresh: poll for NEW messages only (prepend to head). // Does NOT touch nextCursor/hasMore — those belong to the "Load older" flow. useEffect(() => { if (!isTeamAlive && leadActivity !== 'active') return; const interval = setInterval(async () => { try { const page = await api.teams.getMessagesPage(teamName, { limit: PAGE_SIZE }); setFetchedMessages((prev) => mergeTeamMessages(prev, page.messages)); } catch { // best-effort } }, 5000); return () => clearInterval(interval); }, [teamName, isTeamAlive, leadActivity]); const loadOlderMessages = useCallback(async () => { if (!nextCursor || messagesLoading) return; setMessagesLoading(true); try { const page = await api.teams.getMessagesPage(teamName, { beforeTimestamp: nextCursor, limit: PAGE_SIZE, }); setFetchedMessages((prev) => mergeTeamMessages(prev, page.messages)); setNextCursor(page.nextCursor); setHasMore(page.hasMore); } catch { // best-effort } finally { setMessagesLoading(false); } }, [teamName, nextCursor, messagesLoading]); // Use fetched messages, fall back to prop messages during initial load const effectiveMessages = useMemo(() => { if (fetchedMessages.length === 0) return messages; return mergeTeamMessages(fetchedMessages, messages); }, [fetchedMessages, messages]); const composerTextareaRef = useRef(null); const sidebarScrollRef = useRef(null); const handleExpandContent = useCallback(() => { // no-op: user is reading expanded content, not composing }, []); const initialSidebarStateRef = useRef(getTeamMessagesSidebarUiState(teamName)); const [messagesSearchQuery, setMessagesSearchQuery] = useState( initialSidebarStateRef.current.messagesSearchQuery ); const [messagesFilter, setMessagesFilter] = useState( initialSidebarStateRef.current.messagesFilter ); const [messagesFilterOpen, setMessagesFilterOpen] = useState( initialSidebarStateRef.current.messagesFilterOpen ); const [messagesCollapsed, setMessagesCollapsed] = useState( initialSidebarStateRef.current.messagesCollapsed ); const [sidebarSearchVisible, setSidebarSearchVisible] = useState( initialSidebarStateRef.current.sidebarSearchVisible ); const [expandedItemKey, setExpandedItemKey] = useState( initialSidebarStateRef.current.expandedItemKey ); const [sidebarScrollTop, setSidebarScrollTop] = useState( initialSidebarStateRef.current.sidebarScrollTop ); useEffect(() => { initialSidebarStateRef.current = getTeamMessagesSidebarUiState(teamName); setMessagesSearchQuery(initialSidebarStateRef.current.messagesSearchQuery); setMessagesFilter(initialSidebarStateRef.current.messagesFilter); setMessagesFilterOpen(initialSidebarStateRef.current.messagesFilterOpen); setMessagesCollapsed(initialSidebarStateRef.current.messagesCollapsed); setSidebarSearchVisible(initialSidebarStateRef.current.sidebarSearchVisible); setExpandedItemKey(initialSidebarStateRef.current.expandedItemKey); setSidebarScrollTop(initialSidebarStateRef.current.sidebarScrollTop); }, [teamName]); useEffect(() => { setTeamMessagesSidebarUiState(teamName, { messagesSearchQuery, messagesFilter, messagesFilterOpen, messagesCollapsed, sidebarSearchVisible, expandedItemKey, sidebarScrollTop, }); }, [ teamName, messagesSearchQuery, messagesFilter, messagesFilterOpen, messagesCollapsed, sidebarSearchVisible, expandedItemKey, sidebarScrollTop, ]); useLayoutEffect(() => { if (position !== 'sidebar') return; const el = sidebarScrollRef.current; if (!el) return; el.scrollTop = sidebarScrollTop; }, [position, sidebarScrollTop]); const filteredMessages = useMemo(() => { const startedAt = performance.now(); const result = filterTeamMessages(effectiveMessages, { timeWindow, filter: messagesFilter, searchQuery: messagesSearchQuery, }); const ms = performance.now() - startedAt; if (ms >= MESSAGES_PANEL_FILTER_WARN_MS) { logger.warn( `[perf] filter team=${teamName} stage=messages ms=${ms.toFixed(1)} input=${effectiveMessages.length} output=${result.length} searchLen=${messagesSearchQuery.trim().length} noise=${ messagesFilter.showNoise ? 'on' : 'off' }` ); } return result; }, [effectiveMessages, messagesFilter, messagesSearchQuery, teamName, timeWindow]); const activityTimelineMessages = useMemo(() => { const startedAt = performance.now(); const result = filterTeamMessages(effectiveMessages, { includePassiveIdlePeerSummariesWhenNoiseHidden: true, timeWindow, filter: messagesFilter, searchQuery: messagesSearchQuery, }); const ms = performance.now() - startedAt; if (ms >= MESSAGES_PANEL_FILTER_WARN_MS) { logger.warn( `[perf] filter team=${teamName} stage=timeline ms=${ms.toFixed(1)} input=${effectiveMessages.length} output=${result.length} searchLen=${messagesSearchQuery.trim().length} noise=${ messagesFilter.showNoise ? 'on' : 'off' }` ); } return result; }, [effectiveMessages, messagesFilter, messagesSearchQuery, teamName, timeWindow]); const replyCandidateMessages = useMemo( () => effectiveMessages.filter( (m) => m.messageKind !== 'task_comment_notification' && !shouldExcludeInboxTextFromReplyCandidates(typeof m.text === 'string' ? m.text : '') ), [effectiveMessages] ); // Resolve the expanded item from filtered messages const expandedItem = useMemo(() => { const startedAt = performance.now(); if (!expandedItemKey) return null; if (!expandedItemKey.startsWith('thoughts-')) { const msg = activityTimelineMessages.find((m) => toMessageKey(m) === expandedItemKey); const result: TimelineItem | null = msg ? { type: 'message', message: msg } : null; const ms = performance.now() - startedAt; if (ms >= MESSAGES_PANEL_EXPANDED_ITEM_WARN_MS) { logger.warn( `[perf] expandedItem team=${teamName} ms=${ms.toFixed(1)} mode=message timelineMessages=${activityTimelineMessages.length}` ); } return result; } const allItems = groupTimelineItems(activityTimelineMessages); const result = allItems.find( (item) => item.type === 'lead-thoughts' && getThoughtGroupKey(item.group) === expandedItemKey ) ?? null; const ms = performance.now() - startedAt; if (ms >= MESSAGES_PANEL_EXPANDED_ITEM_WARN_MS) { logger.warn( `[perf] expandedItem team=${teamName} ms=${ms.toFixed(1)} mode=thoughts timelineMessages=${activityTimelineMessages.length} groups=${allItems.length}` ); } return result; }, [expandedItemKey, activityTimelineMessages]); // Auto-clear stale expanded key useEffect(() => { if (expandedItemKey && expandedItem === null) { setExpandedItemKey(null); } }, [expandedItemKey, expandedItem]); const handleExpandItem = useCallback((key: string) => { setExpandedItemKey(key); }, []); const handleExpandDialogChange = useCallback((open: boolean) => { if (!open) setExpandedItemKey(null); }, []); const { readSet, markRead, markAllRead } = useTeamMessagesRead(teamName); const { expandedSet, toggle: toggleExpandOverride } = useTeamMessagesExpanded(teamName); const messagesUnreadCount = useMemo( () => filteredMessages.filter((m) => !m.read && !readSet.has(toMessageKey(m))).length, [filteredMessages, readSet] ); const handleMessageVisible = useCallback( (message: InboxMessage) => markRead(toMessageKey(message)), [markRead] ); const readState = useMemo(() => ({ readSet, getMessageKey: toMessageKey }), [readSet]); const { teamNames, teamColorByName } = useStableTeamMentionMeta(teams); const handleMarkAllRead = useCallback(() => { const keys = filteredMessages .filter((m) => !m.read && !readSet.has(toMessageKey(m))) .map((m) => toMessageKey(m)); markAllRead(keys); }, [filteredMessages, readSet, markAllRead]); // Auto-clear pending replies when a member actually responds useEffect(() => { if (Object.keys(pendingRepliesByMember).length === 0) return; const next = { ...pendingRepliesByMember }; let changed = false; for (const [memberName, sentAtMs] of Object.entries(pendingRepliesByMember)) { const hasReply = replyCandidateMessages.some((m) => { if (m.from !== memberName) return false; const ts = Date.parse(m.timestamp); return Number.isFinite(ts) && ts > sentAtMs; }); if (hasReply) { delete next[memberName]; changed = true; } } if (changed) onPendingReplyChange(() => next); }, [onPendingReplyChange, pendingRepliesByMember, replyCandidateMessages]); const handleSend = useCallback( ( member: string, text: string, summary?: string, attachments?: Parameters[1] extends { attachments?: infer A } ? A : never, actionMode?: ActionMode, taskRefs?: TaskRef[] ) => { const sentAtMs = Date.now(); onPendingReplyChange((prev) => ({ ...prev, [member]: sentAtMs })); void sendTeamMessage(teamName, { member, text, summary, attachments, actionMode, taskRefs, }).catch(() => { onPendingReplyChange((prev) => { if (prev[member] !== sentAtMs) return prev; const next = { ...prev }; delete next[member]; return next; }); }); }, [teamName, sendTeamMessage, onPendingReplyChange] ); const handleCrossTeamSend = useCallback( ( toTeam: string, text: string, summary?: string, actionMode?: ActionMode, taskRefs?: TaskRef[] ) => { void sendCrossTeamMessage({ fromTeam: teamName, fromMember: 'user', toTeam, text, taskRefs, actionMode, summary, }); }, [teamName, sendCrossTeamMessage] ); // ---- Shared content (used in both modes) ---- const searchAndFilterControls = (
setMessagesSearchQuery(e.target.value)} onPointerDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} className="min-w-0 flex-1 bg-transparent text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:outline-none" /> {messagesSearchQuery && ( )}
); const searchAndFilterBar = (
{searchAndFilterControls} {messagesCollapsed ? 'Expand all messages' : 'Collapse all messages'}
); const messagesContent = (
{hasMore && (
)}
); // ---- Sidebar mode ---- if (position === 'sidebar') { return (
{/* Header */}
Messages {filteredMessages.length > 0 && ( {filteredMessages.length} )} {messagesUnreadCount > 0 && ( {messagesUnreadCount} new {messagesUnreadCount} unread )} {messagesUnreadCount > 0 && ( Mark all as read )}
{messagesCollapsed ? 'Expand all messages' : 'Collapse all messages'} {sidebarSearchVisible ? 'Hide search' : 'Search messages'} Move to inline
{/* Search & filter bar (toggleable) */} {sidebarSearchVisible && (
{searchAndFilterControls}
)} {/* Scrollable content */}
setSidebarScrollTop(e.currentTarget.scrollTop)} >
{' '}
{hasMore && (
)}
); } // ---- Inline mode (wrapped in CollapsibleTeamSection) ---- return ( } badge={filteredMessages.length} secondaryBadge={ filteredMessages.length > 0 && messagesUnreadCount > 0 ? messagesUnreadCount : undefined } afterBadge={ messagesUnreadCount > 0 ? ( Mark all as read ) : undefined } headerExtra={ Move to sidebar } defaultOpen action={
{searchAndFilterBar}
} > {messagesContent}
); });