Files
fusion/packages/dashboard/app/components/MailboxModal.tsx
gsxdsm 08aa64e314 fix(dashboard): mobile keyboard scroll lock, auto-reload hash, ChatView snap
- Add useMobileScrollLock hook using position:fixed body lock (Bootstrap/
  Headless UI/Stripe pattern) to prevent iOS Safari from shifting the
  document and visualViewport when an input inside a fixed-position modal
  is focused. Wire into 15 input-bearing modals plus ChatView, replacing
  ChatView's inline body-overflow effect.
- Widen computeBuildVersion in vite.config.ts to hash the entire app/ tree
  so the version-check poll actually notices rebuilds (FN-3333 follow-up;
  previously only main.tsx and package.json were hashed).
- ChatView: on input blur, suppress keyboard-aware sizing for 450ms while
  reserving mobile-nav-bar space, so the composer snaps to its final
  height in one move instead of crawling down with iOS's keyboard slide
  and then jumping again when the nav bar reappears.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 19:01:10 -07:00

754 lines
30 KiB
TypeScript

import "./MailboxModal.css";
import { useState, useEffect, useCallback } from "react";
import {
X,
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,
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)}`;
}
// ── Component ─────────────────────────────────────────────────────────────
export function MailboxModal({
isOpen,
onClose,
projectId,
addToast,
agents = [],
}: MailboxModalProps) {
useMobileScrollLock(isOpen);
const [activeTab, setActiveTab] = useState<MailboxTab>("inbox");
const [inbox, setInbox] = useState<InboxResponse | null>(null);
const [outbox, setOutbox] = useState<OutboxResponse | null>(null);
const [unreadCount, setUnreadCount] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [selectedMessage, setSelectedMessage] = useState<Message | null>(null);
const [conversationMessages, setConversationMessages] = useState<Message[]>([]);
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<string | null>(null);
const [agentSubTab, setAgentSubTab] = useState<"inbox" | "outbox">("inbox");
const [agentMailbox, setAgentMailbox] = useState<AgentMailboxResponse | null>(null);
// ── 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);
// Mark as read if unread
if (!message.read) {
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]);
const handleCloseMessage = useCallback(() => {
setSelectedMessage(null);
setConversationMessages([]);
}, []);
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);
}, []);
if (!isOpen) return null;
// ── Render ────────────────────────────────────────────────────────────
return (
<div
className="modal-overlay open"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
role="dialog"
aria-modal="true"
data-testid="mailbox-modal-overlay"
>
<div className="modal modal-lg mailbox-modal" data-testid="mailbox-modal">
{/* Header */}
<div className="modal-header mailbox-header">
<div className="mailbox-title">
<Mail size={18} />
<span>Mailbox</span>
{unreadCount > 0 && (
<span className="mailbox-unread-badge" data-testid="mailbox-unread-badge">
{unreadCount}
</span>
)}
</div>
<div className="mailbox-header-actions">
<button
className="btn btn-sm btn-primary"
onClick={handleOpenCompose}
title="Compose message"
data-testid="mailbox-header-compose"
>
<MessageSquare size={14} />
<span>Compose</span>
</button>
{activeTab === "inbox" && unreadCount > 0 && (
<button
className="btn btn-sm btn-secondary"
onClick={handleMarkAllRead}
title="Mark all as read"
data-testid="mailbox-mark-all-read"
>
<CheckCheck size={14} />
<span>Mark all read</span>
</button>
)}
<button
className="btn-icon"
onClick={() => {
if (activeTab === "inbox") loadInbox();
else if (activeTab === "outbox") loadOutbox();
else if (selectedAgentId) loadAgentMailbox(selectedAgentId);
}}
disabled={isLoading}
title="Refresh"
data-testid="mailbox-refresh"
>
{isLoading ? <Loader2 size={14} className="spin" /> : <RefreshCw size={14} />}
</button>
<button
className="modal-close"
onClick={onClose}
aria-label="Close"
title="Close"
data-testid="mailbox-close"
>
<X size={16} />
</button>
</div>
</div>
{/* Tabs */}
<div className="mailbox-tabs" data-testid="mailbox-tabs">
<button
className={`btn btn-sm btn-secondary mailbox-tab ${activeTab === "inbox" ? "active" : ""}`}
onClick={() => { setActiveTab("inbox"); setSelectedMessage(null); }}
data-testid="mailbox-tab-inbox"
>
<InboxIcon size={14} />
<span>Inbox</span>
{unreadCount > 0 && <span className="mailbox-tab-badge">{unreadCount}</span>}
</button>
<button
className={`btn btn-sm btn-secondary mailbox-tab ${activeTab === "outbox" ? "active" : ""}`}
onClick={() => { setActiveTab("outbox"); setSelectedMessage(null); }}
data-testid="mailbox-tab-outbox"
>
<Send size={14} />
<span>Outbox</span>
</button>
<button
className={`btn btn-sm btn-secondary mailbox-tab ${activeTab === "agents" ? "active" : ""}`}
onClick={() => { setActiveTab("agents"); setSelectedMessage(null); }}
data-testid="mailbox-tab-agents"
>
<Bot size={14} />
<span>Agents</span>
</button>
</div>
{/* Content */}
<div className="mailbox-content" data-testid="mailbox-content">
{/* Message Detail View */}
{selectedMessage && !showComposer && (
<div className="mailbox-message-detail" data-testid="mailbox-message-detail">
<div className="mailbox-message-detail-header">
<button
className="btn btn-sm btn-secondary"
onClick={handleCloseMessage}
data-testid="mailbox-back-to-list"
>
Back
</button>
<div className="mailbox-message-detail-meta">
<span className="mailbox-message-type">{messageTypeLabel(selectedMessage.type)}</span>
<span className="mailbox-message-time">{formatTimestamp(selectedMessage.createdAt)}</span>
</div>
<div className="mailbox-message-detail-actions">
{selectedMessage.fromType === "agent" && (
<button
className="btn btn-sm btn-secondary"
onClick={() => handleReply(selectedMessage)}
data-testid="mailbox-reply"
>
<MessageSquare size={14} />
<span>Reply</span>
</button>
)}
<button
className="btn btn-sm btn-secondary"
onClick={() => handleDeleteMessage(selectedMessage.id)}
data-testid="mailbox-delete"
>
<Trash2 size={14} />
<span>Delete</span>
</button>
</div>
</div>
<div className="mailbox-message-participants">
<div className="mailbox-participant">
<span className="mailbox-participant-label">From:</span>
<span className="mailbox-participant-value">
{selectedMessage.fromType === "agent" ? <Bot size={14} /> : <User size={14} />}
{participantLabel(selectedMessage.fromId, selectedMessage.fromType)}
</span>
</div>
<div className="mailbox-participant">
<span className="mailbox-participant-label">To:</span>
<span className="mailbox-participant-value">
{selectedMessage.toType === "agent" ? <Bot size={14} /> : <User size={14} />}
{participantLabel(selectedMessage.toId, selectedMessage.toType)}
</span>
</div>
</div>
{/* Conversation thread */}
{conversationMessages.length > 1 && (
<div className="mailbox-conversation" data-testid="mailbox-conversation">
<div className="mailbox-conversation-label">Conversation</div>
{conversationMessages.map((msg) => {
const replyToId = msg.metadata?.replyTo?.messageId;
const replyToMessage = replyToId
? conversationMessages.find((candidate) => candidate.id === replyToId)
: undefined;
return (
<div
key={msg.id}
className={`mailbox-conversation-msg ${msg.id === selectedMessage.id ? "current" : ""}`}
>
<div className="mailbox-conversation-msg-header">
<span>{participantLabel(msg.fromId, msg.fromType)}</span>
<span className="mailbox-message-time">{formatTimestamp(msg.createdAt)}</span>
</div>
{replyToId && (
<div className="mailbox-reply-context" data-testid={`mailbox-reply-context-${msg.id}`}>
Replying to {replyToMessage ? messagePreview(replyToMessage.content, 60) : `message ${replyToId}`}
</div>
)}
<div className="mailbox-conversation-msg-body">{msg.content}</div>
</div>
);
})}
</div>
)}
{/* Full message content */}
{(conversationMessages.length <= 1) && (
<>
{selectedMessage.metadata?.replyTo?.messageId && (
<div className="mailbox-reply-context" data-testid="mailbox-selected-reply-context">
Replying to message {selectedMessage.metadata.replyTo.messageId}
</div>
)}
<div className="mailbox-message-body" data-testid="mailbox-message-body">
{selectedMessage.content}
</div>
</>
)}
</div>
)}
{/* Message Composer */}
{showComposer && (
<MessageComposer
recipient={composeRecipient}
replyContext={composeReplyContext}
agents={agents}
projectId={projectId}
onSend={handleMessageSent}
onCancel={handleComposeCancel}
addToast={addToast}
/>
)}
{/* Tab Content — message lists */}
{!selectedMessage && !showComposer && (
<>
{/* Inbox Tab */}
{activeTab === "inbox" && (
<div className="mailbox-list" data-testid="mailbox-inbox-list">
{isLoading && !inbox && <MailboxSkeleton />}
{inbox && inbox.messages.length === 0 && (
<div className="mailbox-empty" data-testid="mailbox-inbox-empty">
<InboxIcon size={32} />
<p>No messages in your inbox</p>
</div>
)}
{inbox?.messages.map((msg) => (
<div
key={msg.id}
className={`mailbox-item ${!msg.read ? "unread" : ""}`}
onClick={() => handleOpenMessage(msg)}
data-testid={`mailbox-item-${msg.id}`}
>
<div className="mailbox-item-avatar">
{msg.fromType === "agent" ? <Bot size={16} /> : <User size={16} />}
</div>
<div className="mailbox-item-content">
<div className="mailbox-item-header">
<span className="mailbox-item-from">
{participantLabel(msg.fromId, msg.fromType)}
</span>
<span className="mailbox-item-time">{formatTimestamp(msg.createdAt)}</span>
</div>
<div className="mailbox-item-preview">{msg.content.slice(0, 80)}{msg.content.length > 80 ? "…" : ""}</div>
</div>
{!msg.read && <div className="mailbox-item-unread-dot" data-testid={`mailbox-unread-dot-${msg.id}`} />}
</div>
))}
</div>
)}
{/* Outbox Tab */}
{activeTab === "outbox" && (
<div className="mailbox-list" data-testid="mailbox-outbox-list">
{isLoading && !outbox && <MailboxSkeleton />}
{outbox && outbox.messages.length === 0 && (
<div className="mailbox-empty" data-testid="mailbox-outbox-empty">
<Send size={32} />
<p>No sent messages</p>
</div>
)}
{outbox?.messages.map((msg) => (
<div
key={msg.id}
className="mailbox-item"
onClick={() => handleOpenMessage(msg)}
data-testid={`mailbox-item-${msg.id}`}
>
<div className="mailbox-item-avatar">
{msg.toType === "agent" ? <Bot size={16} /> : <User size={16} />}
</div>
<div className="mailbox-item-content">
<div className="mailbox-item-header">
<span className="mailbox-item-to">
To: {participantLabel(msg.toId, msg.toType)}
</span>
<span className="mailbox-item-time">{formatTimestamp(msg.createdAt)}</span>
</div>
<div className="mailbox-item-preview">{msg.content.slice(0, 80)}{msg.content.length > 80 ? "…" : ""}</div>
</div>
</div>
))}
</div>
)}
{/* Agent Mailboxes Tab */}
{activeTab === "agents" && (
<div className="mailbox-agents" data-testid="mailbox-agents">
{agents.length === 0 ? (
<div className="mailbox-empty">
<Bot size={32} />
<p>No agents found</p>
</div>
) : (
<>
<div className="mailbox-agents-header">
<div className="mailbox-agents-dropdown">
<select
className="message-composer-select mailbox-agent-select"
value={selectedAgentId ?? ""}
onChange={(e) => { setSelectedAgentId(e.target.value || null); setAgentSubTab("inbox"); }}
data-testid="mailbox-agent-select"
>
<option value="">Select an agent</option>
{agents.map((agent) => (
<option key={agent.id} value={agent.id}>
{agent.name || agent.id}
</option>
))}
</select>
</div>
<button
className="btn btn-sm btn-secondary mailbox-compose-btn"
onClick={handleOpenCompose}
data-testid="mailbox-compose-btn"
>
<MessageSquare size={14} />
<span>Compose</span>
</button>
</div>
{/* Agent Sub-Tabs (Inbox/Outbox) */}
{selectedAgentId && (
<div className="mailbox-agent-subtabs" data-testid="mailbox-agent-subtabs">
<button
className={`btn btn-sm btn-secondary mailbox-agent-subtab ${agentSubTab === "inbox" ? "active" : ""}`}
onClick={() => setAgentSubTab("inbox")}
data-testid="mailbox-agent-subtab-inbox"
>
<InboxIcon size={12} />
<span>Inbox</span>
{agentMailbox && agentMailbox.unreadCount > 0 && (
<span className="mailbox-tab-badge">{agentMailbox.unreadCount}</span>
)}
</button>
<button
className={`btn btn-sm btn-secondary mailbox-agent-subtab ${agentSubTab === "outbox" ? "active" : ""}`}
onClick={() => setAgentSubTab("outbox")}
data-testid="mailbox-agent-subtab-outbox"
>
<Send size={12} />
<span>Outbox</span>
</button>
</div>
)}
<div className="mailbox-agents-content">
{!selectedAgentId && (
<div className="mailbox-empty">
<Bot size={32} />
<p>Select an agent to view their mailbox</p>
</div>
)}
{selectedAgentId && isLoading && !agentMailbox && <MailboxSkeleton />}
{selectedAgentId && agentMailbox && agentSubTab === "inbox" && agentMailbox.inbox.length === 0 && (
<div className="mailbox-empty">
<InboxIcon size={32} />
<p>No received messages for this agent</p>
</div>
)}
{selectedAgentId && agentMailbox && agentSubTab === "outbox" && agentMailbox.outbox.length === 0 && (
<div className="mailbox-empty">
<Send size={32} />
<p>No sent messages for this agent</p>
</div>
)}
{selectedAgentId && agentMailbox && agentSubTab === "inbox" && agentMailbox.inbox.map((msg) => (
<div
key={msg.id}
className={`mailbox-item ${!msg.read ? "unread" : ""}`}
onClick={() => handleOpenMessage(msg)}
data-testid={`mailbox-item-${msg.id}`}
>
<div className="mailbox-item-avatar">
{msg.fromType === "agent" ? <Bot size={16} /> : <User size={16} />}
</div>
<div className="mailbox-item-content">
<div className="mailbox-item-header">
<span className="mailbox-item-from">
{msg.fromType === "agent"
? participantLabel(msg.toId, msg.toType)
: participantLabel(msg.fromId, msg.fromType)}
</span>
<span className="mailbox-item-time">{formatTimestamp(msg.createdAt)}</span>
</div>
<div className="mailbox-item-preview">{msg.content.slice(0, 80)}{msg.content.length > 80 ? "…" : ""}</div>
</div>
</div>
))}
{selectedAgentId && agentMailbox && agentSubTab === "outbox" && agentMailbox.outbox.map((msg) => (
<div
key={msg.id}
className="mailbox-item"
onClick={() => handleOpenMessage(msg)}
data-testid={`mailbox-item-${msg.id}`}
>
<div className="mailbox-item-avatar">
{msg.toType === "agent" ? <Bot size={16} /> : <User size={16} />}
</div>
<div className="mailbox-item-content">
<div className="mailbox-item-header">
<span className="mailbox-item-to">
To: {participantLabel(msg.toId, msg.toType)}
</span>
<span className="mailbox-item-time">{formatTimestamp(msg.createdAt)}</span>
</div>
<div className="mailbox-item-preview">{msg.content.slice(0, 80)}{msg.content.length > 80 ? "…" : ""}</div>
</div>
</div>
))}
</div>
</>
)}
</div>
)}
</>
)}
</div>
</div>
</div>
);
}
// ── Skeleton ──────────────────────────────────────────────────────────────
function MailboxSkeleton() {
return (
<div className="mailbox-skeleton" data-testid="mailbox-skeleton">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="mailbox-skeleton-item">
<div className="mailbox-skeleton-avatar" />
<div className="mailbox-skeleton-content">
<div className="mailbox-skeleton-line mailbox-skeleton-line--short" />
<div className="mailbox-skeleton-line mailbox-skeleton-line--long" />
</div>
</div>
))}
</div>
);
}