import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; 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 { filterTeamMessages } from '@renderer/utils/teamMessageFiltering'; import { toMessageKey } from '@renderer/utils/teamMessageKey'; 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; } 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 = useStore((s) => s.sendTeamMessage); const sendCrossTeamMessage = useStore((s) => s.sendCrossTeamMessage); const sendingMessage = useStore((s) => s.sendingMessage); const sendMessageError = useStore((s) => s.sendMessageError); const lastSendMessageResult = useStore((s) => s.lastSendMessageResult); const teams = useStore((s) => s.teams); const openTeamTab = useStore((s) => s.openTeamTab); 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(() => { return filterTeamMessages(messages, { timeWindow, filter: messagesFilter, searchQuery: messagesSearchQuery, }); }, [messages, timeWindow, messagesFilter, messagesSearchQuery]); // Resolve the expanded item from filtered messages const expandedItem = useMemo(() => { if (!expandedItemKey) return null; if (!expandedItemKey.startsWith('thoughts-')) { const msg = filteredMessages.find((m) => toMessageKey(m) === expandedItemKey); return msg ? { type: 'message', message: msg } : null; } const allItems = groupTimelineItems(filteredMessages); return ( allItems.find( (item) => item.type === 'lead-thoughts' && getThoughtGroupKey(item.group) === expandedItemKey ) ?? null ); }, [expandedItemKey, filteredMessages]); // 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 = messages.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); }, [messages, pendingRepliesByMember, onPendingReplyChange]); 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 = (
); // ---- 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)} >
{' '}
); } // ---- 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}
); });