import "./MailboxModal.css"; import { useState, useEffect, useCallback, useMemo } from "react"; import { Mail, Send, Inbox as InboxIcon, Bot, Trash2, CheckCheck, Loader2, RefreshCw, MessageSquare, User, } from "lucide-react"; import type { Message, MessageType, ParticipantType } from "@fusion/core"; import { fetchInbox, fetchOutbox, fetchUnreadCount, fetchAgentMailbox, markMessageRead, markAllMessagesRead, deleteMessage, fetchConversation, fetchAgents, type InboxResponse, type OutboxResponse, type AgentMailboxResponse, type Agent, } from "../api"; import { MessageComposer } from "./MessageComposer"; import { subscribeSse } from "../sse-bus"; import { useViewportMode } from "../hooks/useViewportMode"; // ── Types ───────────────────────────────────────────────────────────────── type MailboxTab = "inbox" | "outbox" | "agents"; interface MailboxViewProps { projectId?: string; addToast?: (msg: string, type?: "success" | "error") => void; /** Callback when unread count changes (for header badge updates) */ onUnreadCountChange?: (count: number) => void; } /** Represents a grouped conversation in the inbox */ interface ConversationGroup { /** Unique key combining fromId and fromType */ key: string; fromId: string; fromType: ParticipantType; /** Latest message in the conversation */ latestMessage: Message; /** All messages in this conversation */ messages: Message[]; /** Count of unread messages in this conversation */ unreadCount: number; } // ── 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, agentNamesById?: ReadonlyMap, ): string { if (type === "user") return id === "dashboard" ? "You" : `User: ${id}`; if (type === "agent") { const name = agentNamesById?.get(id)?.trim(); if (!name) return `Agent: ${id}`; if (name === id) return `Agent: ${id}`; return `Agent: ${name} (${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)}…`; } /** Groups messages by conversation (sender) key */ function groupMessagesByConversation(messages: Message[]): ConversationGroup[] { const groups = new Map(); for (const msg of messages) { const key = `${msg.fromType}:${msg.fromId}`; const existing = groups.get(key); if (existing) { existing.messages.push(msg); // Track latest by timestamp if (new Date(msg.createdAt) > new Date(existing.latestMessage.createdAt)) { existing.latestMessage = msg; } // Update unread count if (!msg.read) { existing.unreadCount++; } } else { groups.set(key, { key, fromId: msg.fromId, fromType: msg.fromType, latestMessage: msg, messages: [msg], unreadCount: msg.read ? 0 : 1, }); } } // Sort by latest message timestamp, newest first return Array.from(groups.values()).sort( (a, b) => new Date(b.latestMessage.createdAt).getTime() - new Date(a.latestMessage.createdAt).getTime() ); } // ── Component ───────────────────────────────────────────────────────────── export function MailboxView({ projectId, addToast, onUnreadCountChange, }: MailboxViewProps) { 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 [agents, setAgents] = useState([]); const agentNamesById = useMemo( () => new Map(agents.map((agent) => [agent.id, agent.name ?? ""])), [agents], ); const getParticipantLabel = useCallback( (id: string, type: ParticipantType) => participantLabel(id, type, agentNamesById), [agentNamesById], ); const viewportMode = useViewportMode(); const isMobile = viewportMode === "mobile"; const isSplitPane = !isMobile; // ── Data fetching ───────────────────────────────────────────────────── const loadInbox = useCallback(async () => { setIsLoading(true); try { const data = await fetchInbox({ limit: 50 }, projectId); setInbox(data); setUnreadCount(data.unreadCount); onUnreadCountChange?.(data.unreadCount); } catch { // Silently fail — empty state will show } finally { setIsLoading(false); } }, [projectId, onUnreadCountChange]); 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 loadAgents = useCallback(async () => { try { const data = await fetchAgents(undefined, projectId); setAgents(data); } catch { // Silently fail } }, [projectId]); const refreshUnreadCount = useCallback(async () => { try { const data = await fetchUnreadCount(projectId); setUnreadCount(data.unreadCount); onUnreadCountChange?.(data.unreadCount); } catch { // Silently fail } }, [projectId, onUnreadCountChange]); // Load data on tab change useEffect(() => { if (activeTab === "inbox") loadInbox(); else if (activeTab === "outbox") loadOutbox(); else if (activeTab === "agents") loadAgents(); }, [activeTab, loadInbox, loadOutbox, loadAgents]); // Load agent mailbox when selected useEffect(() => { if (!selectedAgentId) return; loadAgentMailbox(selectedAgentId); }, [selectedAgentId, loadAgentMailbox]); // Load unread count on mount useEffect(() => { refreshUnreadCount(); }, [refreshUnreadCount]); // Load agents on mount so they're available for compose from any tab (not just agents tab) useEffect(() => { loadAgents(); }, [loadAgents]); // Subscribe to mailbox SSE events for near-real-time refresh. useEffect(() => { if (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, }, }); }, [projectId, activeTab, selectedAgentId, refreshUnreadCount, loadInbox, loadOutbox, loadAgentMailbox]); // ── Actions ─────────────────────────────────────────────────────────── const handleOpenMessage = useCallback(async (message: Message) => { setSelectedMessage(message); // Mark as read if unread if (!message.read) { try { const updated = await markMessageRead(message.id, projectId); // Update inbox state if (updated) { setInbox((prev) => prev ? { ...prev, messages: prev.messages.map((m) => (m.id === updated.id ? updated : m)), unreadCount: Math.max(0, prev.unreadCount - 1), } : prev, ); } const newCount = Math.max(0, unreadCount - 1); setUnreadCount(newCount); onUnreadCountChange?.(newCount); } catch { // Non-critical } } // Load conversation thread try { const conv = await fetchConversation(message.fromId, message.fromType, projectId); setConversationMessages(conv); } catch { setConversationMessages([message]); } }, [projectId, unreadCount, onUnreadCountChange]); const handleCloseMessage = useCallback(() => { setSelectedMessage(null); setConversationMessages([]); }, []); const handleMarkAllRead = useCallback(async () => { try { const result = await markAllMessagesRead(projectId); setUnreadCount(0); onUnreadCountChange?.(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, onUnreadCountChange]); 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); refreshUnreadCount(); }, [activeTab, loadOutbox, selectedAgentId, loadAgentMailbox, addToast, refreshUnreadCount]); 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); }, []); // ── Render ──────────────────────────────────────────────────────────── const renderMessageDetail = () => { if (!selectedMessage || showComposer) return null; return (
{isMobile && ( )}
{messageTypeLabel(selectedMessage.type)} {formatTimestamp(selectedMessage.createdAt)}
{selectedMessage.fromType === "agent" && ( )}
From: {selectedMessage.fromType === "agent" ? : } {getParticipantLabel(selectedMessage.fromId, selectedMessage.fromType)}
To: {selectedMessage.toType === "agent" ? : } {getParticipantLabel(selectedMessage.toId, selectedMessage.toType)}
{conversationMessages.length > 1 && (
Conversation
{conversationMessages.map((msg) => { const replyToId = msg.metadata?.replyTo?.messageId; const replyToMessage = replyToId ? conversationMessages.find((candidate) => candidate.id === replyToId) : undefined; return (
{getParticipantLabel(msg.fromId, msg.fromType)} {formatTimestamp(msg.createdAt)}
{replyToId && (
↪ Replying to {replyToMessage ? messagePreview(replyToMessage.content, 60) : `message ${replyToId}`}
)}
{msg.content}
); })}
)} {(conversationMessages.length <= 1) && ( <> {selectedMessage.metadata?.replyTo?.messageId && (
↪ Replying to message {selectedMessage.metadata.replyTo.messageId}
)}
{selectedMessage.content}
)}
); }; const renderListPane = () => ( <> {activeTab === "inbox" && (
{isLoading && !inbox && } {inbox && inbox.messages.length === 0 && (

No messages in your inbox

)} {inbox && inbox.messages.length > 0 && (
{groupMessagesByConversation(inbox.messages).map((group) => (
0 ? "unread" : ""}`} onClick={() => handleOpenMessage(group.latestMessage)} data-testid={`mailbox-conversation-${group.key}`} >
{group.fromType === "agent" ? : }
{getParticipantLabel(group.fromId, group.fromType)} {formatTimestamp(group.latestMessage.createdAt)}
{group.latestMessage.content.slice(0, 80)} {group.latestMessage.content.length > 80 ? "…" : ""}
{group.unreadCount > 0 && (
{group.unreadCount > 9 ? "9+" : group.unreadCount}
)}
))}
)}
)} {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: {getParticipantLabel(msg.toId, msg.toType)} {formatTimestamp(msg.createdAt)}
{msg.content.slice(0, 80)}{msg.content.length > 80 ? "…" : ""}
))}
)} {activeTab === "agents" && (
{agents.length === 0 ? (

No agents found

) : ( <>
{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" ? : }
{getParticipantLabel(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: {getParticipantLabel(msg.toId, msg.toType)} {formatTimestamp(msg.createdAt)}
{msg.content.slice(0, 80)}{msg.content.length > 80 ? "…" : ""}
))}
)}
)} ); const renderDetailPane = () => { if (showComposer) { return ( ); } if (selectedMessage) { return renderMessageDetail(); } return (

Select a conversation to read messages

); }; return (
{/* Header */}
Mailbox {unreadCount > 0 && ( {unreadCount} )}
{activeTab === "inbox" && unreadCount > 0 && ( )}
{/* Tabs */}
{isSplitPane ? (
{renderListPane()}
{renderDetailPane()}
) : ( <> {renderMessageDetail()} {showComposer && ( )} {!selectedMessage && !showComposer && renderListPane()} )}
); } // ── Skeleton ────────────────────────────────────────────────────────────── function MailboxSkeleton() { return (
{Array.from({ length: 5 }).map((_, i) => (
))}
); }