import "./MailboxModal.css"; import { useState, useEffect, useCallback } from "react"; import { X, Mail, Send, Inbox as InboxIcon, Bot, Trash2, CheckCheck, Loader2, RefreshCw, MessageSquare, User, ChevronRight, ChevronDown, } from "lucide-react"; import type { Message, MessageType, ParticipantType } from "@fusion/core"; import { fetchInbox, fetchOutbox, fetchUnreadCount, fetchAgentMailbox, markMessageRead, markAllMessagesRead, deleteMessage, fetchConversation, fetchMessage, type InboxResponse, type OutboxResponse, type AgentMailboxResponse, } from "../api"; import { MessageComposer } from "./MessageComposer"; import type { Agent } from "../api"; import { useMobileScrollLock } from "../hooks/useMobileScrollLock"; import { subscribeSse } from "../sse-bus"; // ── Types ───────────────────────────────────────────────────────────────── type MailboxTab = "inbox" | "outbox" | "agents"; interface MailboxModalProps { isOpen: boolean; onClose: () => void; projectId?: string; addToast?: (msg: string, type?: "success" | "error") => void; agents?: Agent[]; } // ── Helpers ─────────────────────────────────────────────────────────────── function formatTimestamp(ts: string): string { const date = new Date(ts); const now = new Date(); const diffMs = now.getTime() - date.getTime(); const diffMins = Math.floor(diffMs / 60000); const diffHours = Math.floor(diffMs / 3600000); const diffDays = Math.floor(diffMs / 86400000); if (diffMins < 1) return "Just now"; if (diffMins < 60) return `${diffMins}m ago`; if (diffHours < 24) return `${diffHours}h ago`; if (diffDays < 7) return `${diffDays}d ago`; return date.toLocaleDateString(undefined, { month: "short", day: "numeric" }); } function participantLabel(id: string, type: ParticipantType): string { if (type === "user") return id === "dashboard" ? "You" : `User: ${id}`; if (type === "agent") return `Agent: ${id}`; return "System"; } function messageTypeLabel(type: MessageType): string { switch (type) { case "agent-to-agent": return "Agent ↔ Agent"; case "agent-to-user": return "Agent → You"; case "user-to-agent": return "You → Agent"; case "system": return "System"; } } function messagePreview(content: string, max = 80): string { if (content.length <= max) return content; return `${content.slice(0, max)}…`; } function buildReplyThread(messages: Message[], selectedMessage: Message): Message[] { const allMessages = [...messages]; if (!allMessages.some((message) => message.id === selectedMessage.id)) { allMessages.push(selectedMessage); } const threadIds = new Set([selectedMessage.id]); let changed = true; while (changed) { changed = false; for (const message of allMessages) { const replyToId = message.metadata?.replyTo?.messageId; if (threadIds.has(message.id) && replyToId && !threadIds.has(replyToId)) { threadIds.add(replyToId); changed = true; } if (replyToId && threadIds.has(replyToId) && !threadIds.has(message.id)) { threadIds.add(message.id); changed = true; } } } return allMessages .filter((message) => threadIds.has(message.id)) .sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); } // ── Component ───────────────────────────────────────────────────────────── export function MailboxModal({ isOpen, onClose, projectId, addToast, agents = [], }: MailboxModalProps) { useMobileScrollLock(isOpen); const [activeTab, setActiveTab] = useState("inbox"); const [inbox, setInbox] = useState(null); const [outbox, setOutbox] = useState(null); const [unreadCount, setUnreadCount] = useState(0); const [isLoading, setIsLoading] = useState(false); const [selectedMessage, setSelectedMessage] = useState(null); const [conversationMessages, setConversationMessages] = useState([]); const [showComposer, setShowComposer] = useState(false); const [composeRecipient, setComposeRecipient] = useState<{ id: string; type: ParticipantType } | null>(null); const [composeReplyContext, setComposeReplyContext] = useState<{ messageId: string; preview: string } | null>(null); const [selectedAgentId, setSelectedAgentId] = useState(null); const [agentSubTab, setAgentSubTab] = useState<"inbox" | "outbox">("inbox"); const [agentMailbox, setAgentMailbox] = useState(null); const [replyContextExpanded, setReplyContextExpanded] = useState>({}); const [replyContextLoading, setReplyContextLoading] = useState>({}); const [replyContextErrors, setReplyContextErrors] = useState>({}); const [replyContextCache, setReplyContextCache] = useState>(new Map()); // ── Data fetching ───────────────────────────────────────────────────── const loadInbox = useCallback(async () => { setIsLoading(true); try { const data = await fetchInbox({ limit: 50 }, projectId); setInbox(data); setUnreadCount(data.unreadCount); } catch { // Silently fail — empty state will show } finally { setIsLoading(false); } }, [projectId]); const loadOutbox = useCallback(async () => { setIsLoading(true); try { const data = await fetchOutbox({ limit: 50 }, projectId); setOutbox(data); } catch { // Silently fail } finally { setIsLoading(false); } }, [projectId]); const loadAgentMailbox = useCallback(async (agentId: string) => { setIsLoading(true); try { const data = await fetchAgentMailbox(agentId, projectId); setAgentMailbox(data); } catch { // Silently fail } finally { setIsLoading(false); } }, [projectId]); const refreshUnreadCount = useCallback(async () => { try { const data = await fetchUnreadCount(projectId); setUnreadCount(data.unreadCount); } catch { // Silently fail } }, [projectId]); // Load data on tab change useEffect(() => { if (!isOpen) return; if (activeTab === "inbox") loadInbox(); else if (activeTab === "outbox") loadOutbox(); }, [isOpen, activeTab, loadInbox, loadOutbox]); // Load agent mailbox when selected useEffect(() => { if (!isOpen || !selectedAgentId) return; loadAgentMailbox(selectedAgentId); }, [isOpen, selectedAgentId, loadAgentMailbox]); // Refresh unread count on open useEffect(() => { if (isOpen) refreshUnreadCount(); }, [isOpen, refreshUnreadCount]); // Subscribe to mailbox SSE events while the modal is open. useEffect(() => { if (!isOpen || typeof EventSource === "undefined") { return; } const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : ""; const onMailboxUpdate = () => { void refreshUnreadCount(); if (activeTab === "inbox") { void loadInbox(); } else if (activeTab === "outbox") { void loadOutbox(); } if (selectedAgentId) { void loadAgentMailbox(selectedAgentId); } }; return subscribeSse(`/api/events${query}`, { events: { "message:sent": onMailboxUpdate, "message:received": onMailboxUpdate, "message:read": onMailboxUpdate, "message:deleted": onMailboxUpdate, }, }); }, [isOpen, projectId, activeTab, selectedAgentId, refreshUnreadCount, loadInbox, loadOutbox, loadAgentMailbox]); // ── Actions ─────────────────────────────────────────────────────────── const handleOpenMessage = useCallback(async (message: Message) => { setSelectedMessage(message); setReplyContextExpanded({}); setReplyContextLoading({}); setReplyContextErrors({}); // Only auto-mark as read when viewing the dashboard user's own inbox. // Browsing another agent's mailbox must not consume their unread messages // out from under them — the agent's heartbeat is the one that reads + acks. if (!message.read && activeTab === "inbox") { try { const updated = await markMessageRead(message.id, projectId); // Update inbox state setInbox((prev) => prev ? { ...prev, messages: prev.messages.map((m) => (m.id === updated.id ? updated : m)), unreadCount: Math.max(0, prev.unreadCount - 1), } : prev, ); setUnreadCount((c) => Math.max(0, c - 1)); } catch { // Non-critical } } // Load conversation thread try { const conv = await fetchConversation(message.fromId, message.fromType, projectId); setConversationMessages(conv); } catch { setConversationMessages([message]); } }, [projectId, activeTab]); const handleCloseMessage = useCallback(() => { setSelectedMessage(null); setConversationMessages([]); setReplyContextExpanded({}); setReplyContextLoading({}); setReplyContextErrors({}); }, []); const handleMarkAllRead = useCallback(async () => { try { const result = await markAllMessagesRead(projectId); setUnreadCount(0); setInbox((prev) => prev ? { ...prev, messages: prev.messages.map((m) => ({ ...m, read: true })), unreadCount: 0, } : prev, ); addToast?.(`Marked ${result.markedAsRead} messages as read`, "success"); } catch { addToast?.("Failed to mark messages as read", "error"); } }, [projectId, addToast]); const handleDeleteMessage = useCallback(async (id: string) => { try { await deleteMessage(id, projectId); setSelectedMessage(null); setConversationMessages([]); // Refresh current tab if (activeTab === "inbox") loadInbox(); else if (activeTab === "outbox") loadOutbox(); else if (selectedAgentId) loadAgentMailbox(selectedAgentId); addToast?.("Message deleted", "success"); } catch { addToast?.("Failed to delete message", "error"); } }, [projectId, activeTab, selectedAgentId, loadInbox, loadOutbox, loadAgentMailbox, addToast]); const handleReply = useCallback((message: Message) => { setComposeRecipient({ id: message.fromId, type: message.fromType }); setComposeReplyContext({ messageId: message.id, preview: messagePreview(message.content, 120), }); setShowComposer(true); }, []); const handleMessageSent = useCallback(() => { setShowComposer(false); setComposeRecipient(null); setComposeReplyContext(null); addToast?.("Message sent", "success"); // Refresh current tab if (activeTab === "outbox") loadOutbox(); else if (activeTab === "agents" && selectedAgentId) loadAgentMailbox(selectedAgentId); }, [activeTab, loadOutbox, selectedAgentId, loadAgentMailbox, addToast]); const handleOpenCompose = useCallback(() => { // Pre-fill recipient from selected agent if available if (activeTab === "agents" && selectedAgentId) { setComposeRecipient({ id: selectedAgentId, type: "agent" }); } else { setComposeRecipient(null); } setComposeReplyContext(null); setShowComposer(true); }, [activeTab, selectedAgentId]); const handleComposeCancel = useCallback(() => { setShowComposer(false); setComposeRecipient(null); setComposeReplyContext(null); }, []); const threadMessages = selectedMessage ? buildReplyThread(conversationMessages, selectedMessage) : []; const setReplyExpanded = (key: string, isExpanded: boolean) => { setReplyContextExpanded((prev) => ({ ...prev, [key]: isExpanded })); }; const loadReplyMessage = async (messageId: string) => { const cachedMessage = replyContextCache.get(messageId); if (cachedMessage) { return cachedMessage; } setReplyContextLoading((prev) => ({ ...prev, [messageId]: true })); setReplyContextErrors((prev) => ({ ...prev, [messageId]: "" })); try { const message = await fetchMessage(messageId, projectId); setReplyContextCache((prev) => { const next = new Map(prev); next.set(messageId, message); return next; }); return message; } catch { setReplyContextErrors((prev) => ({ ...prev, [messageId]: "Failed to load replied message. Click to retry." })); return null; } finally { setReplyContextLoading((prev) => ({ ...prev, [messageId]: false })); } }; if (!isOpen) return null; const ReplyContextExpandable = ({ ownerMessageId, replyToId, initialMessage, ancestorIds, testId, }: { ownerMessageId: string; replyToId: string; initialMessage?: Message; ancestorIds: Set; testId?: string; }) => { const cacheMessage = replyContextCache.get(replyToId) ?? initialMessage; const rowKey = `${ownerMessageId}-${replyToId}`; const isExpanded = Boolean(replyContextExpanded[rowKey]); const isLoadingReply = Boolean(replyContextLoading[replyToId]); const errorMessage = replyContextErrors[replyToId]; const hasCycle = ancestorIds.has(replyToId); const handleToggle = async () => { if (isExpanded) { setReplyExpanded(rowKey, false); return; } setReplyExpanded(rowKey, true); if (!cacheMessage && !hasCycle) { await loadReplyMessage(replyToId); } }; const nextAncestorIds = new Set(ancestorIds); nextAncestorIds.add(replyToId); return (
{isExpanded && (
{errorMessage &&
{errorMessage}
} {cacheMessage && ( <>
{participantLabel(cacheMessage.fromId, cacheMessage.fromType)} {formatTimestamp(cacheMessage.createdAt)}
{cacheMessage.content}
{cacheMessage.metadata?.replyTo?.messageId && !nextAncestorIds.has(cacheMessage.metadata.replyTo.messageId) && ( )} )}
)}
); }; // ── Render ──────────────────────────────────────────────────────────── return (
{ if (e.target === e.currentTarget) onClose(); }} role="dialog" aria-modal="true" data-testid="mailbox-modal-overlay" >
{/* Header */}
Mailbox {unreadCount > 0 && ( {unreadCount} )}
{activeTab === "inbox" && unreadCount > 0 && ( )}
{/* Tabs */}
{/* Content */}
{/* Message Detail View */} {selectedMessage && !showComposer && (
{messageTypeLabel(selectedMessage.type)} {formatTimestamp(selectedMessage.createdAt)}
{selectedMessage.fromType === "agent" && ( )}
From: {selectedMessage.fromType === "agent" ? : } {participantLabel(selectedMessage.fromId, selectedMessage.fromType)}
To: {selectedMessage.toType === "agent" ? : } {participantLabel(selectedMessage.toId, selectedMessage.toType)}
{/* Conversation thread */} {threadMessages.length > 1 && (
Conversation
{threadMessages.map((msg) => { const replyToId = msg.metadata?.replyTo?.messageId; const replyToMessage = replyToId ? threadMessages.find((candidate) => candidate.id === replyToId) : undefined; return (
{participantLabel(msg.fromId, msg.fromType)} {formatTimestamp(msg.createdAt)}
{replyToId && ( )}
{msg.content}
); })}
)} {/* Full message content */} {(threadMessages.length <= 1) && ( <> {selectedMessage.metadata?.replyTo?.messageId && ( candidate.id === selectedMessage.metadata?.replyTo?.messageId)} ancestorIds={new Set([selectedMessage.id])} testId="mailbox-selected-reply-context" /> )}
{selectedMessage.content}
)}
)} {/* Message Composer */} {showComposer && ( )} {/* Tab Content — message lists */} {!selectedMessage && !showComposer && ( <> {/* Inbox Tab */} {activeTab === "inbox" && (
{isLoading && !inbox && } {inbox && inbox.messages.length === 0 && (

No messages in your inbox

)} {inbox?.messages.map((msg) => (
handleOpenMessage(msg)} data-testid={`mailbox-item-${msg.id}`} >
{msg.fromType === "agent" ? : }
{participantLabel(msg.fromId, msg.fromType)} {formatTimestamp(msg.createdAt)}
{msg.content.slice(0, 80)}{msg.content.length > 80 ? "…" : ""}
{!msg.read &&
}
))}
)} {/* Outbox Tab */} {activeTab === "outbox" && (
{isLoading && !outbox && } {outbox && outbox.messages.length === 0 && (

No sent messages

)} {outbox?.messages.map((msg) => (
handleOpenMessage(msg)} data-testid={`mailbox-item-${msg.id}`} >
{msg.toType === "agent" ? : }
To: {participantLabel(msg.toId, msg.toType)} {formatTimestamp(msg.createdAt)}
{msg.content.slice(0, 80)}{msg.content.length > 80 ? "…" : ""}
))}
)} {/* Agent Mailboxes Tab */} {activeTab === "agents" && (
{agents.length === 0 ? (

No agents found

) : ( <>
{/* Agent Sub-Tabs (Inbox/Outbox) */} {selectedAgentId && (
)}
{!selectedAgentId && (

Select an agent to view their mailbox

)} {selectedAgentId && isLoading && !agentMailbox && } {selectedAgentId && agentMailbox && agentSubTab === "inbox" && agentMailbox.inbox.length === 0 && (

No received messages for this agent

)} {selectedAgentId && agentMailbox && agentSubTab === "outbox" && agentMailbox.outbox.length === 0 && (

No sent messages for this agent

)} {selectedAgentId && agentMailbox && agentSubTab === "inbox" && agentMailbox.inbox.map((msg) => (
handleOpenMessage(msg)} data-testid={`mailbox-item-${msg.id}`} >
{msg.fromType === "agent" ? : }
{msg.fromType === "agent" ? participantLabel(msg.toId, msg.toType) : participantLabel(msg.fromId, msg.fromType)} {formatTimestamp(msg.createdAt)}
{msg.content.slice(0, 80)}{msg.content.length > 80 ? "…" : ""}
))} {selectedAgentId && agentMailbox && agentSubTab === "outbox" && agentMailbox.outbox.map((msg) => (
handleOpenMessage(msg)} data-testid={`mailbox-item-${msg.id}`} >
{msg.toType === "agent" ? : }
To: {participantLabel(msg.toId, msg.toType)} {formatTimestamp(msg.createdAt)}
{msg.content.slice(0, 80)}{msg.content.length > 80 ? "…" : ""}
))}
)}
)} )}
); } // ── Skeleton ────────────────────────────────────────────────────────────── function MailboxSkeleton() { return (
{Array.from({ length: 5 }).map((_, i) => (
))}
); }