import { useCallback, useEffect, useMemo, 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 { useTeamMessagesExpanded } from '@renderer/hooks/useTeamMessagesExpanded'; import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead'; import { useStore } from '@renderer/store'; import { computePendingCrossTeamReplies } from '@renderer/utils/crossTeamPendingReplies'; import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering'; import { toMessageKey } from '@renderer/utils/teamMessageKey'; import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { Bell, CheckCheck, ChevronsDownUp, ChevronsUpDown, ChevronRight, MessageSquare, PanelLeftClose, PanelLeft, Search, X, } from 'lucide-react'; import { ActiveTasksBlock } from '../activity/ActiveTasksBlock'; import { ActivityTimeline } from '../activity/ActivityTimeline'; import { PendingRepliesBlock } from '../activity/PendingRepliesBlock'; import { CollapsibleTeamSection } from '../CollapsibleTeamSection'; import { MessageComposer } from './MessageComposer'; import { MessagesFilterPopover } from './MessagesFilterPopover'; import type { MessagesFilterState } from './MessagesFilterPopover'; import type { ActionMode } from './ActionModeSelector'; import type { InboxMessage, ResolvedTeamMember, 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; /** 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 = ({ teamName, position, onTogglePosition, members, tasks, messages, isTeamAlive, 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 [messagesSearchQuery, setMessagesSearchQuery] = useState(''); const [messagesFilter, setMessagesFilter] = useState({ from: new Set(), to: new Set(), showNoise: false, }); const [messagesFilterOpen, setMessagesFilterOpen] = useState(false); const [messagesCollapsed, setMessagesCollapsed] = useState(true); const [statusBlockCollapsed, setStatusBlockCollapsed] = useState(false); const filteredMessages = useMemo(() => { return filterTeamMessages(messages, { timeWindow, filter: messagesFilter, searchQuery: messagesSearchQuery, }); }, [messages, timeWindow, messagesFilter, messagesSearchQuery]); 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 handleMarkAllRead = useCallback(() => { const keys = filteredMessages .filter((m) => !m.read && !readSet.has(toMessageKey(m))) .map((m) => toMessageKey(m)); markAllRead(keys); }, [filteredMessages, readSet, markAllRead]); const pendingCrossTeamReplies = useMemo( () => computePendingCrossTeamReplies(messages), [messages] ); /** Whether the Status block has any visible items (pending replies or active tasks). */ const hasStatusItems = useMemo(() => { const hasPendingReplies = Object.keys(pendingRepliesByMember).some((name) => members.some((m) => m.name === name) ); if (hasPendingReplies) return true; if (pendingCrossTeamReplies.length > 0) return true; const tMap = new Map(tasks.map((t) => [t.id, t])); return members.some((m) => { if (!m.currentTaskId) return false; const task = tMap.get(m.currentTaskId); if (task && (task.reviewState === 'approved' || task.status === 'completed')) return false; return true; }); }, [members, tasks, pendingRepliesByMember, pendingCrossTeamReplies.length]); // 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 ) => { const sentAtMs = Date.now(); onPendingReplyChange((prev) => ({ ...prev, [member]: sentAtMs })); void sendTeamMessage(teamName, { member, text, summary, attachments, actionMode, }).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) => { void sendCrossTeamMessage({ fromTeam: teamName, fromMember: 'user', toTeam, text, actionMode, summary, }); }, [teamName, sendCrossTeamMessage] ); // ---- Shared content (used in both modes) ---- const searchAndFilterBar = (
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 && ( )}
{messagesCollapsed ? 'Expand all messages' : 'Collapse all messages'}
); const messagesContent = ( <> {/* Status block: button floats right (absolute, no layout impact); expanded content renders full-width in normal flow. */} {hasStatusItems && ( <>
{!statusBlockCollapsed && (
)} )} ); // ---- 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 )} Desktop notifications plugin
Move to inline
{/* Search & filter bar */}
{searchAndFilterBar}
{/* Scrollable content */}
{messagesContent}
); } // ---- 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={ <> Desktop notifications plugin Move to sidebar } defaultOpen action={
{searchAndFilterBar}
} > {messagesContent}
); };