import { type ComponentProps, memo, type RefObject, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from 'react'; import { Sheet, type SheetRef } from 'react-modal-sheet'; import { useAppTranslation } from '@features/localization/renderer'; import { Badge } from '@renderer/components/ui/badge'; import { Button } from '@renderer/components/ui/button'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@renderer/components/ui/dropdown-menu'; 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 { shouldClearPendingReplyForOpenCodeRuntimeDelivery } from '@renderer/utils/openCodeRuntimeDeliveryDiagnostics'; import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering'; import { toMessageKey } from '@renderer/utils/teamMessageKey'; import { shouldExcludeInboxTextFromReplyCandidates } from '@shared/utils/idleNotificationSemantics'; import { isLeadMember } from '@shared/utils/leadDetection'; import { isMemberWorkSyncNudgeMessage, isReviewPickupEscalationMessage, isTaskStallRemediationMessage, } from '@shared/utils/teamAutomationMessages'; import { CheckCheck, ChevronsDownUp, ChevronsUpDown, Dock, MessageSquare, MoreHorizontal, PanelBottom, PanelBottomClose, PanelBottomOpen, PanelLeft, PanelLeftClose, Search, X, } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; import { ActivityTimeline, type TimelineViewport } 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, type MessageRevisionRequest } from './MessageComposer'; import { MessagesFilterPopover } from './MessagesFilterPopover'; import { buildRevisionNoticeText, findLatestRevisableUserSentMessage, getRevisableMessageText, hasVisibleReplyForSendMessageDiagnostics, isRevisableUserSentMessage, reconcilePendingRepliesByMember, REVISION_NOTICE_PREFIX, trimString, } from './messagesPanelLogic'; 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; const OPENCODE_RUNTIME_DELIVERY_STATUS_REFRESH_DELAYS_MS = [15_000, 45_000, 90_000] as const; const MESSAGES_SCROLL_TOP_PERSIST_DELAY_MS = 100; 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; /** Reports the rendered floating composer height so the parent can reserve scroll space. */ onFloatingComposerHeightChange?: (height: number) => void; /** * Scroll container owned by the parent view when `position === 'inline'`. * MessagesPanel does not own this element — the viewport lives in * TeamDetailView's content scroll area. Plumbed for future viewport * consumers (virtualization); unused in this release. */ inlineScrollContainerRef?: RefObject; } const MessagesComposerSection = memo(MessageComposer); const MessagesStatusSection = memo(StatusBlock); type MessagesTimelineSectionProps = ComponentProps & { hasMore: boolean; loadingOlderMessages: boolean; onLoadOlderMessages: () => void; expandedItem: TimelineItem | null; expandedItemKey: string | null; onExpandDialogChange: (open: boolean) => void; }; const MessagesTimelineSection = memo(function MessagesTimelineSection({ hasMore, loadingOlderMessages, onLoadOlderMessages, expandedItem, expandedItemKey, onExpandDialogChange, messages, loading, teamName, members, readState, allCollapsed, expandOverrides, onToggleExpandOverride, currentLeadSessionId, isTeamAlive, leadActivity, leadContextUpdatedAt, teamNames, teamColorByName, onTeamClick, onMemberClick, onCreateTaskFromMessage, onReplyToMessage, revisionMessageId, onReviseMessage, onMessageVisible, onRestartTeam, onTaskIdClick, onExpandItem, onExpandContent, viewport, }: MessagesTimelineSectionProps): React.JSX.Element { const { t } = useAppTranslation('team'); return ( <> {hasMore && (
)} ); }); 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, onFloatingComposerHeightChange, inlineScrollContainerRef, }: MessagesPanelProps): React.JSX.Element { const { t } = useAppTranslation('team'); const { sendTeamMessage, sendCrossTeamMessage, sendingMessage, sendMessageError, sendMessageWarning, sendMessageDebugDetails, lastSendMessageResult, clearSendMessageRuntimeDiagnostics, refreshSendMessageRuntimeDeliveryStatus, teams, openTeamTab, messages, messagesState, loadOlderTeamMessages, refreshTeamMessagesHead, } = useStore( useShallow((s) => ({ sendTeamMessage: s.sendTeamMessage, sendCrossTeamMessage: s.sendCrossTeamMessage, sendingMessage: s.sendingMessage, sendMessageError: s.sendMessageError, sendMessageWarning: s.sendMessageWarning, sendMessageDebugDetails: s.sendMessageDebugDetails, lastSendMessageResult: s.lastSendMessageResult, clearSendMessageRuntimeDiagnostics: s.clearSendMessageRuntimeDiagnostics, refreshSendMessageRuntimeDeliveryStatus: s.refreshSendMessageRuntimeDeliveryStatus, teams: s.teams, openTeamTab: s.openTeamTab, messages: selectTeamMessages(s, teamName), messagesState: teamName ? s.teamMessagesByName[teamName] : undefined, loadOlderTeamMessages: s.loadOlderTeamMessages, refreshTeamMessagesHead: s.refreshTeamMessagesHead, })) ); const bootstrapHeadRefreshAttemptedForTeamRef = useRef(null); const loadOlderMessages = useCallback(async () => { if (!messagesState?.hasMore || messagesState.loadingHead || messagesState.loadingOlder) { return; } await loadOlderTeamMessages(teamName); }, [loadOlderTeamMessages, messagesState, teamName]); const handleLoadOlderMessagesClick = useCallback(() => { void loadOlderMessages(); }, [loadOlderMessages]); const loadingOlderMessages = messagesState?.loadingOlder ?? false; const hasMore = messagesState?.hasMore ?? false; const effectiveMessages = messages; const loadingInitialMessages = effectiveMessages.length === 0 && (messagesState === undefined || messagesState.loadingHead); const composerTextareaRef = useRef(null); const floatingComposerMeasureRef = useRef(null); const sidebarScrollRef = useRef(null); const bottomSheetRef = useRef(null); const bottomSheetStickyTopRef = useRef(null); // Scroll container inside `Sheet.Content` for the bottom-sheet layout. // react-modal-sheet merges this ref with its own internal scroll ref. // Held here so future viewport consumers (virtualization) can observe the // true scrolling element in bottom-sheet mode. const bottomSheetScrollRef = useRef(null); // Resolve the active scroll owner for the current layout. This is the // ref that ActivityTimeline's IntersectionObserver will use as its root, // so visibility is measured against the real scroll container rather // than the document viewport. Virtualizer consumers will hook into the // same ref in a follow-up change. const activeScrollContainerRef = position === 'inline' ? (inlineScrollContainerRef ?? null) : position === 'sidebar' ? sidebarScrollRef : bottomSheetScrollRef; const activityTimelineViewport = useMemo(() => { if (!activeScrollContainerRef) return undefined; return { scrollElementRef: activeScrollContainerRef, observerRoot: activeScrollContainerRef, scrollMargin: 0, // Opt into virtualization; ActivityTimeline keeps the direct render // path for short lists and only switches to the windowed path once // the row count crosses its internal threshold. virtualizationEnabled: true, virtualizationRowThreshold: position === 'sidebar' ? 24 : undefined, }; }, [activeScrollContainerRef, position]); 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 messagesScrollTopRef = useRef(initialSidebarStateRef.current.messagesScrollTop); const messagesScrollPersistTimerRef = useRef | null>(null); 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); messagesScrollTopRef.current = initialSidebarStateRef.current.messagesScrollTop; setMessagesScrollTop(initialSidebarStateRef.current.messagesScrollTop); setBottomSheetSnapIndex(initialSidebarStateRef.current.bottomSheetSnapIndex); }, [teamName]); useEffect( () => () => { if (messagesScrollPersistTimerRef.current) { clearTimeout(messagesScrollPersistTimerRef.current); messagesScrollPersistTimerRef.current = null; } }, [] ); const persistMessagesScrollTop = useCallback((nextScrollTop: number): void => { messagesScrollTopRef.current = nextScrollTop; if (messagesScrollPersistTimerRef.current) { clearTimeout(messagesScrollPersistTimerRef.current); } messagesScrollPersistTimerRef.current = setTimeout(() => { messagesScrollPersistTimerRef.current = null; setMessagesScrollTop((current) => Math.abs(current - messagesScrollTopRef.current) < 1 ? current : messagesScrollTopRef.current ); }, MESSAGES_SCROLL_TOP_PERSIST_DELAY_MS); }, []); const handleSidebarScroll = useCallback( (event: React.UIEvent): void => { persistMessagesScrollTop(event.currentTarget.scrollTop); }, [persistMessagesScrollTop] ); useEffect(() => { setTeamMessagesSidebarUiState(teamName, { messagesSearchQuery, messagesFilter, messagesFilterOpen, messagesCollapsed, messagesSearchBarVisible, expandedItemKey, messagesScrollTop, bottomSheetSnapIndex, }); }, [ teamName, messagesSearchQuery, messagesFilter, messagesFilterOpen, messagesCollapsed, messagesSearchBarVisible, expandedItemKey, messagesScrollTop, bottomSheetSnapIndex, ]); useEffect(() => { const hasActiveParticipantFilter = messagesFilter.from.size > 0 || messagesFilter.to.size > 0; if ( messagesSearchBarVisible || (messagesSearchQuery.trim().length === 0 && !hasActiveParticipantFilter) ) { return; } setMessagesSearchBarVisible(true); }, [messagesFilter.from, messagesFilter.to, messagesSearchBarVisible, messagesSearchQuery]); useEffect(() => { if (!teamName) { return; } if (effectiveMessages.length > 0) { bootstrapHeadRefreshAttemptedForTeamRef.current = null; return; } if (messagesState?.loadingHead || messagesState?.loadingOlder) { return; } if (bootstrapHeadRefreshAttemptedForTeamRef.current === teamName) { return; } bootstrapHeadRefreshAttemptedForTeamRef.current = teamName; void refreshTeamMessagesHead(teamName).catch(() => undefined); }, [ effectiveMessages.length, messagesState?.loadingHead, messagesState?.loadingOlder, refreshTeamMessagesHead, teamName, ]); 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 leadNames = useMemo( () => members.filter((member) => isLeadMember(member)).map((member) => member.name), [members] ); const memberNames = useMemo(() => new Set(members.map((member) => member.name)), [members]); const [revisionRequest, setRevisionRequest] = useState(null); const filteredMessages = useMemo(() => { return filterTeamMessages(effectiveMessages, { leadNames, timeWindow, filter: messagesFilter, searchQuery: messagesSearchQuery, }); }, [effectiveMessages, leadNames, messagesFilter, messagesSearchQuery, timeWindow]); const activityTimelineMessages = useMemo(() => { return filterTeamMessages(effectiveMessages, { includeAutomationEvents: true, leadNames, timeWindow, filter: messagesFilter, searchQuery: messagesSearchQuery, }); }, [effectiveMessages, leadNames, messagesFilter, messagesSearchQuery, timeWindow]); const replyCandidateMessages = useMemo( () => effectiveMessages.filter( (m) => m.messageKind !== 'task_comment_notification' && !isTaskStallRemediationMessage(m) && !isMemberWorkSyncNudgeMessage(m) && !isReviewPickupEscalationMessage(m) && !shouldExcludeInboxTextFromReplyCandidates(typeof m.text === 'string' ? m.text : '') ), [effectiveMessages] ); const sendMessageRuntimeReplyVisible = useMemo( () => hasVisibleReplyForSendMessageDiagnostics(sendMessageDebugDetails, effectiveMessages), [effectiveMessages, sendMessageDebugDetails] ); const effectiveSendMessageWarning = sendMessageRuntimeReplyVisible ? null : sendMessageWarning; const effectiveSendMessageDebugDetails = sendMessageRuntimeReplyVisible ? null : sendMessageDebugDetails; const latestRevisableMessage = useMemo( () => findLatestRevisableUserSentMessage(effectiveMessages, memberNames), [effectiveMessages, memberNames] ); const revisionMessageId = trimString(latestRevisableMessage?.messageId) || null; useEffect(() => { setRevisionRequest(null); }, [teamName]); const handleRevisionCancel = useCallback(() => { setRevisionRequest(null); }, []); const handleRevisionComplete = useCallback((requestId: string) => { setRevisionRequest((current) => (current?.requestId === requestId ? null : current)); }, []); const handleReviseMessage = useCallback( async (message: InboxMessage) => { if (!isRevisableUserSentMessage(message, memberNames)) return; const originalMessageId = trimString(message.messageId); if (originalMessageId !== revisionMessageId) return; const recipient = trimString(message.to); const originalText = getRevisableMessageText(message); try { await sendTeamMessage(teamName, { member: recipient, text: buildRevisionNoticeText(originalMessageId, originalText), summary: `${REVISION_NOTICE_PREFIX} ${originalMessageId}`, }); } catch { return; } setRevisionRequest({ requestId: `${originalMessageId}:${Date.now()}`, originalMessageId, originalText, recipient, actionMode: message.actionMode, }); composerTextareaRef.current?.focus(); }, [memberNames, revisionMessageId, sendTeamMessage, teamName] ); // 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 = reconcilePendingRepliesByMember(pendingRepliesByMember, replyCandidateMessages); if (next !== pendingRepliesByMember) onPendingReplyChange(() => next); }, [onPendingReplyChange, pendingRepliesByMember, replyCandidateMessages]); useEffect(() => { if (!sendMessageRuntimeReplyVisible || !sendMessageDebugDetails?.messageId) return; clearSendMessageRuntimeDiagnostics(sendMessageDebugDetails.messageId); }, [ clearSendMessageRuntimeDiagnostics, sendMessageDebugDetails?.messageId, sendMessageRuntimeReplyVisible, ]); useEffect(() => { const debugDetails = sendMessageDebugDetails; const messageId = debugDetails?.messageId; const shouldPoll = debugDetails?.userVisibleState === 'checking' || (!debugDetails?.userVisibleState && debugDetails?.responsePending === true); if (!messageId || sendMessageRuntimeReplyVisible || !shouldPoll) { return; } const statusMessageId = debugDetails.statusMessageId || messageId; const timers = OPENCODE_RUNTIME_DELIVERY_STATUS_REFRESH_DELAYS_MS.map((delayMs) => window.setTimeout(() => { void refreshSendMessageRuntimeDeliveryStatus(teamName, { messageId, statusMessageId, }); }, delayMs) ); return () => { timers.forEach((timer) => window.clearTimeout(timer)); }; }, [ refreshSendMessageRuntimeDeliveryStatus, sendMessageDebugDetails?.messageId, sendMessageDebugDetails?.statusMessageId, sendMessageDebugDetails?.responsePending, sendMessageDebugDetails?.userVisibleState, sendMessageRuntimeReplyVisible, teamName, ]); 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, }) .then((result) => { if (shouldClearPendingReplyForOpenCodeRuntimeDelivery(result?.runtimeDelivery)) { onPendingReplyChange((prev) => { if (prev[member] !== sentAtMs) return prev; const next = { ...prev }; delete next[member]; return next; }); } }) .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 moveToFloatingComposer = useCallback(() => { onPositionChange('floating-composer'); }, [onPositionChange]); useLayoutEffect(() => { if (position !== 'floating-composer' || !onFloatingComposerHeightChange) return undefined; const node = floatingComposerMeasureRef.current; if (!node) { onFloatingComposerHeightChange(0); return undefined; } const updateHeight = (): void => { onFloatingComposerHeightChange(Math.ceil(node.getBoundingClientRect().height)); }; updateHeight(); const observer = new ResizeObserver(updateHeight); observer.observe(node); return () => { observer.disconnect(); onFloatingComposerHeightChange(0); }; }, [onFloatingComposerHeightChange, position]); 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]); const defaultComposerSection = ( ); const floatingComposerModeControls = (
{t('messages.panelMode')} {t('messages.actions.moveToInline')} {t('messages.actions.moveToBottomSheet')} {t('messages.actions.moveToSidebar')}
); const compactComposerSection = ( ); const floatingComposerSection = ( ); const inlineStatusSection = ( ); const sidebarStatusSection = ( ); const timelineSection = ( ); // ---- 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 = (
{defaultComposerSection} {inlineStatusSection} {timelineSection}
); // ---- Sidebar mode ---- if (position === 'sidebar') { return (
{/* Header */}
{t('messages.title')} {filteredMessages.length > 0 && ( {filteredMessages.length} )} {messagesUnreadCount > 0 && ( {t('messages.unread.new', { count: messagesUnreadCount })} {t('messages.unread.unread', { count: messagesUnreadCount })} )} {messagesUnreadCount > 0 && ( {t('messages.actions.markAllRead')} )}
{t('messages.actions.messageActions')} setMessagesCollapsed((v) => !v)}> {messagesCollapsed ? ( ) : ( )} {messagesCollapsed ? t('messages.actions.expandAll') : t('messages.actions.collapseAll')} setMessagesSearchBarVisible((v) => !v)}> {messagesSearchBarVisible ? ( ) : ( )} {messagesSearchBarVisible ? t('messages.actions.hideSearch') : t('messages.actions.searchMessages')} {t('messages.actions.moveToInline')} {t('messages.actions.moveToBottomSheet')} {t('messages.actions.floatComposer')}
{/* Search & filter bar (toggleable) */} {messagesSearchBarVisible && (
{searchAndFilterControls}
)} {/* Scrollable content */}
{defaultComposerSection} {sidebarStatusSection}
{timelineSection}
); } if (position === 'floating-composer') { return (
{floatingComposerSection}
); } if (position === 'bottom-sheet') { if (!mountPoint) { return