import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { Sheet, type SheetRef } from 'react-modal-sheet'; 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 { selectTeamMessages } from '@renderer/store/slices/teamSlice'; import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering'; import { toMessageKey } from '@renderer/utils/teamMessageKey'; import { shouldExcludeInboxTextFromReplyCandidates } from '@shared/utils/idleNotificationSemantics'; import { CheckCheck, ChevronsDownUp, ChevronsUpDown, MessageSquare, PanelBottom, PanelBottomClose, PanelBottomOpen, PanelLeft, PanelLeftClose, Search, X, } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; 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 { TeamMessagesPanelMode } from '@renderer/types/teamMessagesPanelMode'; import type { InboxMessage, ResolvedTeamMember, TaskRef, TeamTaskWithKanban } from '@shared/types'; interface TimeWindow { start: number; end: number; } const BOTTOM_SHEET_HEADER_HEIGHT = 40; const BOTTOM_SHEET_COLLAPSED_SNAP_INDEX = 1; const BOTTOM_SHEET_COMPOSER_SNAP_INDEX = 2; const BOTTOM_SHEET_FULL_SNAP_INDEX = 4; interface MessagesPanelProps { teamName: string; position: TeamMessagesPanelMode; onPositionChange: (position: TeamMessagesPanelMode) => void; mountPoint?: Element | null; /** Active (non-removed) members. */ members: ResolvedTeamMember[]; /** All team tasks. */ tasks: TeamTaskWithKanban[]; /** 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; /** 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, onPositionChange, mountPoint, members, tasks, isTeamAlive, leadActivity, leadContextUpdatedAt, timeWindow, currentLeadSessionId, pendingRepliesByMember, onPendingReplyChange, onMemberClick, onTaskClick, onCreateTaskFromMessage, onReplyToMessage, onRestartTeam, onTaskIdClick, }: MessagesPanelProps): React.JSX.Element { const { sendTeamMessage, sendCrossTeamMessage, sendingMessage, sendMessageError, lastSendMessageResult, teams, openTeamTab, messages, messagesState, loadOlderTeamMessages, } = useStore( useShallow((s) => ({ sendTeamMessage: s.sendTeamMessage, sendCrossTeamMessage: s.sendCrossTeamMessage, sendingMessage: s.sendingMessage, sendMessageError: s.sendMessageError, lastSendMessageResult: s.lastSendMessageResult, teams: s.teams, openTeamTab: s.openTeamTab, messages: selectTeamMessages(s, teamName), messagesState: teamName ? s.teamMessagesByName[teamName] : undefined, loadOlderTeamMessages: s.loadOlderTeamMessages, })) ); const loadOlderMessages = useCallback(async () => { if (!messagesState?.hasMore || messagesState.loadingHead || messagesState.loadingOlder) { return; } await loadOlderTeamMessages(teamName); }, [loadOlderTeamMessages, messagesState, teamName]); const messagesLoading = (messagesState?.loadingHead ?? false) || (messagesState?.loadingOlder ?? false); const loadingOlderMessages = messagesState?.loadingOlder ?? false; const hasMore = messagesState?.hasMore ?? false; const effectiveMessages = messages; const composerTextareaRef = useRef(null); const sidebarScrollRef = useRef(null); const bottomSheetRef = useRef(null); const bottomSheetStickyTopRef = 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 [messagesSearchBarVisible, setMessagesSearchBarVisible] = useState( initialSidebarStateRef.current.messagesSearchBarVisible ); const [expandedItemKey, setExpandedItemKey] = useState( initialSidebarStateRef.current.expandedItemKey ); const [messagesScrollTop, setMessagesScrollTop] = useState( initialSidebarStateRef.current.messagesScrollTop ); const [bottomSheetSnapIndex, setBottomSheetSnapIndex] = useState( initialSidebarStateRef.current.bottomSheetSnapIndex ); const [bottomSheetStickyTopHeight, setBottomSheetStickyTopHeight] = useState(196); const [bottomSheetMountHeight, setBottomSheetMountHeight] = useState(0); useEffect(() => { initialSidebarStateRef.current = getTeamMessagesSidebarUiState(teamName); setMessagesSearchQuery(initialSidebarStateRef.current.messagesSearchQuery); setMessagesFilter(initialSidebarStateRef.current.messagesFilter); setMessagesFilterOpen(initialSidebarStateRef.current.messagesFilterOpen); setMessagesCollapsed(initialSidebarStateRef.current.messagesCollapsed); setMessagesSearchBarVisible(initialSidebarStateRef.current.messagesSearchBarVisible); setExpandedItemKey(initialSidebarStateRef.current.expandedItemKey); setMessagesScrollTop(initialSidebarStateRef.current.messagesScrollTop); setBottomSheetSnapIndex(initialSidebarStateRef.current.bottomSheetSnapIndex); }, [teamName]); useEffect(() => { setTeamMessagesSidebarUiState(teamName, { messagesSearchQuery, messagesFilter, messagesFilterOpen, messagesCollapsed, messagesSearchBarVisible, expandedItemKey, messagesScrollTop, bottomSheetSnapIndex, }); }, [ teamName, messagesSearchQuery, messagesFilter, messagesFilterOpen, messagesCollapsed, messagesSearchBarVisible, expandedItemKey, messagesScrollTop, bottomSheetSnapIndex, ]); useLayoutEffect(() => { if (position !== 'sidebar') return; const el = sidebarScrollRef.current; if (!el) return; el.scrollTop = messagesScrollTop; }, [position, messagesScrollTop]); useLayoutEffect(() => { if (position !== 'bottom-sheet' || typeof ResizeObserver === 'undefined') return; const mountPointElement = mountPoint instanceof HTMLElement ? mountPoint : null; const observedEntries: [Element | null, (height: number) => void][] = [ [bottomSheetStickyTopRef.current, setBottomSheetStickyTopHeight], [mountPointElement, setBottomSheetMountHeight], ]; const observers: ResizeObserver[] = []; for (const [element, setHeight] of observedEntries) { if (!element) continue; const updateHeight = (): void => { const nextHeight = Math.ceil(element.getBoundingClientRect().height); if (nextHeight > 0) { setHeight(nextHeight); } }; updateHeight(); const observer = new ResizeObserver(() => { updateHeight(); }); observer.observe(element); observers.push(observer); } return () => { observers.forEach((observer) => observer.disconnect()); }; }, [position, mountPoint]); const filteredMessages = useMemo(() => { return filterTeamMessages(effectiveMessages, { timeWindow, filter: messagesFilter, searchQuery: messagesSearchQuery, }); }, [effectiveMessages, messagesFilter, messagesSearchQuery, timeWindow]); const activityTimelineMessages = useMemo(() => { return filterTeamMessages(effectiveMessages, { includePassiveIdlePeerSummariesWhenNoiseHidden: true, timeWindow, filter: messagesFilter, searchQuery: messagesSearchQuery, }); }, [effectiveMessages, messagesFilter, messagesSearchQuery, 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(() => { if (!expandedItemKey) { return null; } if (!expandedItemKey.startsWith('thoughts-')) { const msg = activityTimelineMessages.find((m) => toMessageKey(m) === expandedItemKey); return msg ? { type: 'message', message: msg } : null; } const allItems = groupTimelineItems(activityTimelineMessages); return ( allItems.find( (item) => item.type === 'lead-thoughts' && getThoughtGroupKey(item.group) === expandedItemKey ) ?? null ); }, [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] ); const moveToInline = useCallback(() => { onPositionChange('inline'); }, [onPositionChange]); const moveToSidebar = useCallback(() => { onPositionChange('sidebar'); }, [onPositionChange]); const moveToBottomSheet = useCallback(() => { setBottomSheetSnapIndex(BOTTOM_SHEET_COMPOSER_SNAP_INDEX); onPositionChange('bottom-sheet'); }, [onPositionChange]); const snapBottomSheetTo = useCallback((snapIndex: number) => { setBottomSheetSnapIndex(snapIndex); bottomSheetRef.current?.snapTo(snapIndex); }, []); const toggleBottomSheetExpansion = useCallback(() => { if (bottomSheetSnapIndex === BOTTOM_SHEET_COLLAPSED_SNAP_INDEX) { snapBottomSheetTo(BOTTOM_SHEET_COMPOSER_SNAP_INDEX); return; } snapBottomSheetTo(BOTTOM_SHEET_COLLAPSED_SNAP_INDEX); }, [bottomSheetSnapIndex, snapBottomSheetTo]); const bottomSheetSnapPoints = useMemo(() => { const maxOpenHeight = bottomSheetMountHeight > 0 ? Math.max(bottomSheetMountHeight - 1, 96) : Number.POSITIVE_INFINITY; const collapsedHeight = Math.min(BOTTOM_SHEET_HEADER_HEIGHT, maxOpenHeight); const composerHeight = Math.min( Math.max(collapsedHeight + bottomSheetStickyTopHeight, collapsedHeight + 120), maxOpenHeight ); const centeredHeight = Math.min( Math.max( bottomSheetMountHeight > 0 ? Math.round(bottomSheetMountHeight * 0.58) : 520, composerHeight + 140 ), maxOpenHeight ); return [0, collapsedHeight, composerHeight, centeredHeight, 1]; }, [bottomSheetMountHeight, bottomSheetStickyTopHeight]); const normalizedBottomSheetSnapIndex = useMemo(() => { return Math.min( Math.max(bottomSheetSnapIndex, BOTTOM_SHEET_COLLAPSED_SNAP_INDEX), BOTTOM_SHEET_FULL_SNAP_INDEX ); }, [bottomSheetSnapIndex]); // ---- 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'} {messagesSearchBarVisible ? 'Hide search' : 'Search messages'} Move to inline
{/* Search & filter bar (toggleable) */} {messagesSearchBarVisible && (
{searchAndFilterControls}
)} {/* Scrollable content */}
setMessagesScrollTop(e.currentTarget.scrollTop)} >
{' '}
{hasMore && (
)}
); } if (position === 'bottom-sheet') { if (!mountPoint) { return