feat(FN-989): add inter-agent messaging system with mailbox UI and CLI commands

- Add Message types (Message, MessageThread, MessageRecipient) and exports to @fusion/core
- Create MessageStore with full CRUD: send, read, delete, inbox, threads, and search
- Add messages table migration (schema v12) with SQLite full-text search support
- Add REST API routes for messaging (CRUD, search, broadcast, unread count)
- Add frontend API client functions for all messaging endpoints
- Build MailboxModal and MessageComposer dashboard components with header integration
- Add CLI message commands (inbox, send, read, delete) with rich output formatting
- Add comprehensive test coverage for MessageStore, CLI commands, and UI components
- Update documentation (CLI STANDALONE.md, dashboard README) with messaging usage
This commit is contained in:
gsxdsm
2026-04-07 11:21:59 -07:00
parent 7af1317f34
commit 43b1f6e772
20 changed files with 3972 additions and 17 deletions

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { Settings, Pause, Play, Square, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Workflow, Bot, ChevronLeft, Target, ChevronRight, FileCode, Loader2, Grid3X3 } from "lucide-react";
import { Settings, Pause, Play, Square, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Workflow, Bot, ChevronLeft, Target, ChevronRight, FileCode, Loader2, Grid3X3, Mail } from "lucide-react";
import type { ProjectInfo } from "../api";
import { fetchScripts } from "../api";
import { ProjectSelector } from "./ProjectSelector";
@@ -30,6 +30,10 @@ export interface HeaderProps {
activePlanningSessionCount?: number;
onOpenUsage?: () => void;
onOpenActivityLog?: () => void;
/** Opens the mailbox modal */
onOpenMailbox?: () => void;
/** Unread message count for badge display */
mailboxUnreadCount?: number;
onOpenSchedules?: () => void;
onOpenGitManager?: () => void;
onOpenWorkflowSteps?: () => void;
@@ -103,6 +107,8 @@ export function Header({
activePlanningSessionCount = 0,
onOpenUsage,
onOpenActivityLog,
onOpenMailbox,
mailboxUnreadCount = 0,
onOpenSchedules,
onOpenGitManager,
onOpenWorkflowSteps,
@@ -417,6 +423,23 @@ export function Header({
</button>
)}
{/* Mailbox button - desktop only */}
{!isCompact && onOpenMailbox && (
<button
className={`btn-icon${mailboxUnreadCount > 0 ? " btn-icon--has-indicator" : ""}`}
onClick={onOpenMailbox}
title={`Mailbox${mailboxUnreadCount > 0 ? ` (${mailboxUnreadCount} unread)` : ""}`}
data-testid="header-mailbox-btn"
>
<Mail size={16} />
{mailboxUnreadCount > 0 && (
<span className="btn-icon-indicator" data-testid="header-mailbox-badge">
{mailboxUnreadCount > 9 ? "9+" : mailboxUnreadCount}
</span>
)}
</button>
)}
{/* Desktop actions */}
{!isCompact && (
<button className="btn-icon" onClick={onOpenGitHubImport} title="Import from GitHub">
@@ -736,6 +759,18 @@ export function Header({
<span>View Activity Log</span>
</button>
)}
{/* Mailbox - in overflow on mobile */}
{onOpenMailbox && (
<button
className="mobile-overflow-item"
onClick={() => handleOverflowAction(onOpenMailbox)}
role="menuitem"
data-testid="overflow-mailbox-btn"
>
<Mail size={16} />
<span>Mailbox{mailboxUnreadCount > 0 ? ` (${mailboxUnreadCount})` : ""}</span>
</button>
)}
{/* Usage - in overflow on mobile */}
{onOpenUsage && (
<button

View File

@@ -0,0 +1,609 @@
import { useState, useEffect, useCallback } from "react";
import {
X,
Mail,
Send,
Inbox as InboxIcon,
Bot,
Trash2,
Check,
CheckCheck,
Loader2,
RefreshCw,
MessageSquare,
User,
AlertCircle,
} 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";
// ── 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";
}
}
// ── Component ─────────────────────────────────────────────────────────────
export function MailboxModal({
isOpen,
onClose,
projectId,
addToast,
agents = [],
}: MailboxModalProps) {
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 [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
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]);
// ── 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 });
setShowComposer(true);
}, []);
const handleMessageSent = useCallback(() => {
setShowComposer(false);
setComposeRecipient(null);
addToast?.("Message sent", "success");
// Refresh outbox
if (activeTab === "outbox") loadOutbox();
}, [activeTab, loadOutbox, addToast]);
const handleComposeCancel = useCallback(() => {
setShowComposer(false);
setComposeRecipient(null);
}, []);
if (!isOpen) return null;
// ── Render ────────────────────────────────────────────────────────────
return (
<div
className="modal-overlay open"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
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">
{activeTab === "inbox" && unreadCount > 0 && (
<button
className="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={`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={`mailbox-tab ${activeTab === "outbox" ? "active" : ""}`}
onClick={() => { setActiveTab("outbox"); setSelectedMessage(null); }}
data-testid="mailbox-tab-outbox"
>
<Send size={14} />
<span>Outbox</span>
</button>
<button
className={`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-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-sm btn-secondary"
onClick={() => handleReply(selectedMessage)}
data-testid="mailbox-reply"
>
<MessageSquare size={14} />
<span>Reply</span>
</button>
)}
<button
className="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) => (
<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>
<div className="mailbox-conversation-msg-body">{msg.content}</div>
</div>
))}
</div>
)}
{/* Full message content */}
{(conversationMessages.length <= 1) && (
<div className="mailbox-message-body" data-testid="mailbox-message-body">
{selectedMessage.content}
</div>
)}
</div>
)}
{/* Message Composer */}
{showComposer && (
<MessageComposer
recipient={composeRecipient}
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">
<div className="mailbox-agents-sidebar">
<div className="mailbox-agents-label">Select an agent</div>
{agents.length === 0 && (
<div className="mailbox-empty">
<Bot size={24} />
<p>No agents found</p>
</div>
)}
{agents.map((agent) => (
<button
key={agent.id}
className={`mailbox-agent-btn ${selectedAgentId === agent.id ? "active" : ""}`}
onClick={() => setSelectedAgentId(agent.id)}
data-testid={`mailbox-agent-btn-${agent.id}`}
>
<Bot size={14} />
<span>{agent.name || agent.id}</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 />}
{agentMailbox && agentMailbox.messages.length === 0 && (
<div className="mailbox-empty">
<InboxIcon size={32} />
<p>No messages for this agent</p>
</div>
)}
{agentMailbox?.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">
{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>
))}
</div>
</div>
)}
</>
)}
</div>
{/* Compose FAB (only when viewing inbox/outbox, not in detail view or agents tab) */}
{!selectedMessage && !showComposer && activeTab !== "agents" && (
<button
className="mailbox-compose-fab"
onClick={() => setShowComposer(true)}
title="Compose message"
data-testid="mailbox-compose-fab"
>
<MessageSquare size={20} />
</button>
)}
</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>
);
}

View File

@@ -0,0 +1,197 @@
import { useState, useCallback } from "react";
import { X, Send, Loader2, Bot, AlertCircle } from "lucide-react";
import type { ParticipantType, MessageType } from "@fusion/core";
import { sendMessage } from "../api";
import type { Agent } from "../api";
// ── Types ─────────────────────────────────────────────────────────────────
interface MessageComposerProps {
/** Pre-fill recipient (e.g. when replying) */
recipient?: { id: string; type: ParticipantType } | null;
/** List of agents for recipient selection */
agents?: Agent[];
/** Project ID for multi-project */
projectId?: string;
/** Called when message is successfully sent */
onSend: () => void;
/** Called when user cancels */
onCancel: () => void;
/** Toast notification callback */
addToast?: (msg: string, type?: "success" | "error") => void;
}
const MAX_CONTENT_LENGTH = 2000;
// ── Component ─────────────────────────────────────────────────────────────
export function MessageComposer({
recipient,
agents = [],
projectId,
onSend,
onCancel,
addToast,
}: MessageComposerProps) {
const [toId, setToId] = useState(recipient?.id ?? "");
const [toType, setToType] = useState<ParticipantType>(recipient?.type ?? "agent");
const [content, setContent] = useState("");
const [isSending, setIsSending] = useState(false);
const [error, setError] = useState<string | null>(null);
const isValid = toId.trim() !== "" && content.trim().length > 0 && content.length <= MAX_CONTENT_LENGTH;
const handleSend = useCallback(async () => {
if (!isValid || isSending) return;
setIsSending(true);
setError(null);
try {
const messageType: MessageType = toType === "agent" ? "user-to-agent" : "system";
await sendMessage(
{
toId: toId.trim(),
toType,
content: content.trim(),
type: messageType,
},
projectId,
);
onSend();
} catch (err: any) {
const msg = err?.message ?? "Failed to send message";
setError(msg);
addToast?.(msg, "error");
} finally {
setIsSending(false);
}
}, [isValid, isSending, toId, toType, content, projectId, onSend, addToast]);
const handleAgentSelect = useCallback((agentId: string) => {
setToId(agentId);
setToType("agent");
}, []);
return (
<div className="message-composer" data-testid="message-composer">
<div className="message-composer-header">
<span>New Message</span>
<button
className="btn-icon"
onClick={onCancel}
aria-label="Cancel"
data-testid="message-composer-cancel"
>
<X size={16} />
</button>
</div>
<div className="message-composer-body">
{/* Recipient selection */}
{!recipient && (
<div className="message-composer-field">
<label className="message-composer-label" htmlFor="message-recipient">
To:
</label>
{agents.length > 0 ? (
<select
id="message-recipient"
className="message-composer-select"
value={toId}
onChange={(e) => handleAgentSelect(e.target.value)}
data-testid="message-composer-recipient"
>
<option value="">Select agent</option>
{agents.map((agent) => (
<option key={agent.id} value={agent.id}>
{agent.name || agent.id}
</option>
))}
</select>
) : (
<input
id="message-recipient"
className="message-composer-input"
type="text"
placeholder="Recipient ID"
value={toId}
onChange={(e) => setToId(e.target.value)}
data-testid="message-composer-recipient"
/>
)}
</div>
)}
{/* Recipient display (when pre-filled from reply) */}
{recipient && (
<div className="message-composer-field">
<span className="message-composer-label">To:</span>
<span className="message-composer-recipient-fixed">
<Bot size={14} />
{recipient.id}
</span>
</div>
)}
{/* Content */}
<div className="message-composer-field message-composer-field--content">
<label className="message-composer-label" htmlFor="message-content">
Message:
</label>
<textarea
id="message-content"
className="message-composer-textarea"
placeholder="Type your message…"
value={content}
onChange={(e) => setContent(e.target.value)}
maxLength={MAX_CONTENT_LENGTH}
rows={4}
data-testid="message-composer-content"
/>
<div className="message-composer-charcount" data-testid="message-composer-charcount">
<span className={content.length > MAX_CONTENT_LENGTH ? "over-limit" : ""}>
{content.length}/{MAX_CONTENT_LENGTH}
</span>
</div>
</div>
{/* Error */}
{error && (
<div className="message-composer-error" data-testid="message-composer-error">
<AlertCircle size={14} />
<span>{error}</span>
</div>
)}
</div>
<div className="message-composer-footer">
<button
className="btn-sm btn-secondary"
onClick={onCancel}
data-testid="message-composer-cancel-btn"
>
Cancel
</button>
<button
className="btn-sm btn-primary"
onClick={handleSend}
disabled={!isValid || isSending}
data-testid="message-composer-send"
>
{isSending ? (
<>
<Loader2 size={14} className="spin" />
<span>Sending</span>
</>
) : (
<>
<Send size={14} />
<span>Send</span>
</>
)}
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,356 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { MailboxModal } from "../MailboxModal";
import * as apiModule from "../../api";
import type { Agent } from "../../api";
import type { Message } from "@fusion/core";
// Mock the API module
vi.mock("../../api", () => ({
fetchInbox: vi.fn(),
fetchOutbox: vi.fn(),
fetchUnreadCount: vi.fn(),
fetchAgentMailbox: vi.fn(),
markMessageRead: vi.fn(),
markAllMessagesRead: vi.fn(),
deleteMessage: vi.fn(),
fetchConversation: vi.fn(),
sendMessage: vi.fn(),
}));
// Mock lucide-react icons
vi.mock("lucide-react", () => ({
X: () => <span data-testid="icon-x">X</span>,
Mail: () => <span data-testid="icon-mail">Mail</span>,
Send: () => <span data-testid="icon-send">Send</span>,
Inbox: () => <span data-testid="icon-inbox">Inbox</span>,
Bot: () => <span data-testid="icon-bot">Bot</span>,
Trash2: () => <span data-testid="icon-trash">Trash</span>,
Check: () => <span data-testid="icon-check">Check</span>,
CheckCheck: () => <span data-testid="icon-checkcheck">CheckCheck</span>,
Loader2: ({ className }: { className?: string }) => (
<span data-testid="icon-loader" className={className}>Loader</span>
),
RefreshCw: () => <span data-testid="icon-refresh">Refresh</span>,
MessageSquare: () => <span data-testid="icon-message">Message</span>,
User: () => <span data-testid="icon-user">User</span>,
AlertCircle: () => <span data-testid="icon-alert">Alert</span>,
}));
const mockFetchInbox = vi.mocked(apiModule.fetchInbox);
const mockFetchOutbox = vi.mocked(apiModule.fetchOutbox);
const mockFetchUnreadCount = vi.mocked(apiModule.fetchUnreadCount);
const mockFetchAgentMailbox = vi.mocked(apiModule.fetchAgentMailbox);
const mockMarkMessageRead = vi.mocked(apiModule.markMessageRead);
const mockMarkAllMessagesRead = vi.mocked(apiModule.markAllMessagesRead);
const mockDeleteMessage = vi.mocked(apiModule.deleteMessage);
const mockFetchConversation = vi.mocked(apiModule.fetchConversation);
const mockAgents: Agent[] = [
{
id: "agent-001",
name: "Test Agent 1",
role: "executor",
state: "idle",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
metadata: {},
},
{
id: "agent-002",
name: "Test Agent 2",
role: "triage",
state: "active",
taskId: "FN-001",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
metadata: {},
},
];
const mockMessage: Message = {
id: "msg-001",
fromId: "agent-001",
fromType: "agent",
toId: "dashboard",
toType: "user",
content: "Hello, this is a test message from the agent.",
type: "agent-to-user",
read: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
const mockReadMessage: Message = {
...mockMessage,
id: "msg-002",
read: true,
content: "This message has been read already.",
};
const defaultProps = {
isOpen: true,
onClose: vi.fn(),
addToast: vi.fn(),
agents: mockAgents,
};
describe("MailboxModal", () => {
beforeEach(() => {
vi.clearAllMocks();
mockFetchInbox.mockResolvedValue({ messages: [mockMessage, mockReadMessage], total: 2, unreadCount: 1 });
mockFetchOutbox.mockResolvedValue({ messages: [], total: 0 });
mockFetchUnreadCount.mockResolvedValue({ unreadCount: 1 });
mockFetchConversation.mockResolvedValue([mockMessage]);
mockMarkMessageRead.mockResolvedValue({ ...mockMessage, read: true });
mockMarkAllMessagesRead.mockResolvedValue({ markedAsRead: 1 });
mockDeleteMessage.mockResolvedValue(undefined);
});
it("renders nothing when isOpen is false", () => {
render(<MailboxModal {...defaultProps} isOpen={false} />);
expect(screen.queryByTestId("mailbox-modal")).toBeNull();
});
it("renders the modal when isOpen is true", () => {
render(<MailboxModal {...defaultProps} />);
expect(screen.getByTestId("mailbox-modal")).toBeDefined();
});
it("shows the Mailbox title with unread count badge", async () => {
render(<MailboxModal {...defaultProps} />);
expect(screen.getByText("Mailbox")).toBeDefined();
// Wait for inbox to load which sets unreadCount
await waitFor(() => {
expect(screen.getByTestId("mailbox-unread-badge")).toBeDefined();
});
expect(screen.getByTestId("mailbox-unread-badge").textContent).toBe("1");
});
it("renders all three tabs", () => {
render(<MailboxModal {...defaultProps} />);
expect(screen.getByTestId("mailbox-tab-inbox")).toBeDefined();
expect(screen.getByTestId("mailbox-tab-outbox")).toBeDefined();
expect(screen.getByTestId("mailbox-tab-agents")).toBeDefined();
});
it("shows inbox tab as active by default", () => {
render(<MailboxModal {...defaultProps} />);
const inboxTab = screen.getByTestId("mailbox-tab-inbox");
expect(inboxTab.classList.contains("active")).toBe(true);
});
it("loads inbox on mount", async () => {
render(<MailboxModal {...defaultProps} />);
await waitFor(() => {
expect(mockFetchInbox).toHaveBeenCalledWith({ limit: 50 }, undefined);
});
});
it("shows inbox messages after loading", async () => {
render(<MailboxModal {...defaultProps} />);
await waitFor(() => {
expect(screen.getByTestId("mailbox-inbox-list")).toBeDefined();
});
// Should show both messages
expect(screen.getByTestId("mailbox-item-msg-001")).toBeDefined();
expect(screen.getByTestId("mailbox-item-msg-002")).toBeDefined();
});
it("shows unread dot for unread messages", async () => {
render(<MailboxModal {...defaultProps} />);
await waitFor(() => {
expect(screen.getByTestId("mailbox-unread-dot-msg-001")).toBeDefined();
});
});
it("does not show unread dot for read messages", async () => {
render(<MailboxModal {...defaultProps} />);
await waitFor(() => {
expect(screen.getByTestId("mailbox-item-msg-002")).toBeDefined();
});
expect(screen.queryByTestId("mailbox-unread-dot-msg-002")).toBeNull();
});
it("switches to outbox tab on click", async () => {
render(<MailboxModal {...defaultProps} />);
const outboxTab = screen.getByTestId("mailbox-tab-outbox");
fireEvent.click(outboxTab);
await waitFor(() => {
expect(mockFetchOutbox).toHaveBeenCalledWith({ limit: 50 }, undefined);
});
});
it("shows empty state for empty outbox", async () => {
render(<MailboxModal {...defaultProps} />);
fireEvent.click(screen.getByTestId("mailbox-tab-outbox"));
await waitFor(() => {
expect(screen.getByTestId("mailbox-outbox-empty")).toBeDefined();
});
});
it("switches to agents tab on click", async () => {
render(<MailboxModal {...defaultProps} />);
fireEvent.click(screen.getByTestId("mailbox-tab-agents"));
await waitFor(() => {
expect(screen.getByTestId("mailbox-agents")).toBeDefined();
});
});
it("shows agent buttons in agents tab", async () => {
render(<MailboxModal {...defaultProps} />);
fireEvent.click(screen.getByTestId("mailbox-tab-agents"));
await waitFor(() => {
expect(screen.getByTestId("mailbox-agent-btn-agent-001")).toBeDefined();
expect(screen.getByTestId("mailbox-agent-btn-agent-002")).toBeDefined();
});
});
it("loads agent mailbox when agent is selected", async () => {
mockFetchAgentMailbox.mockResolvedValue({
ownerId: "agent-001",
ownerType: "agent",
unreadCount: 0,
messages: [],
});
render(<MailboxModal {...defaultProps} />);
fireEvent.click(screen.getByTestId("mailbox-tab-agents"));
await waitFor(() => {
expect(screen.getByTestId("mailbox-agent-btn-agent-001")).toBeDefined();
});
fireEvent.click(screen.getByTestId("mailbox-agent-btn-agent-001"));
await waitFor(() => {
expect(mockFetchAgentMailbox).toHaveBeenCalledWith("agent-001", undefined);
});
});
it("opens message detail when clicking a message", async () => {
render(<MailboxModal {...defaultProps} />);
await waitFor(() => {
expect(screen.getByTestId("mailbox-item-msg-001")).toBeDefined();
});
fireEvent.click(screen.getByTestId("mailbox-item-msg-001"));
await waitFor(() => {
expect(screen.getByTestId("mailbox-message-detail")).toBeDefined();
});
});
it("marks message as read when opening unread message", async () => {
render(<MailboxModal {...defaultProps} />);
await waitFor(() => {
expect(screen.getByTestId("mailbox-item-msg-001")).toBeDefined();
});
fireEvent.click(screen.getByTestId("mailbox-item-msg-001"));
await waitFor(() => {
expect(mockMarkMessageRead).toHaveBeenCalledWith("msg-001", undefined);
});
});
it("shows back button in message detail", async () => {
render(<MailboxModal {...defaultProps} />);
await waitFor(() => {
expect(screen.getByTestId("mailbox-item-msg-001")).toBeDefined();
});
fireEvent.click(screen.getByTestId("mailbox-item-msg-001"));
await waitFor(() => {
expect(screen.getByTestId("mailbox-back-to-list")).toBeDefined();
});
});
it("returns to list when clicking back button", async () => {
render(<MailboxModal {...defaultProps} />);
await waitFor(() => {
expect(screen.getByTestId("mailbox-item-msg-001")).toBeDefined();
});
fireEvent.click(screen.getByTestId("mailbox-item-msg-001"));
await waitFor(() => {
expect(screen.getByTestId("mailbox-back-to-list")).toBeDefined();
});
fireEvent.click(screen.getByTestId("mailbox-back-to-list"));
await waitFor(() => {
expect(screen.queryByTestId("mailbox-message-detail")).toBeNull();
expect(screen.getByTestId("mailbox-inbox-list")).toBeDefined();
});
});
it("shows mark all read button when there are unread messages", async () => {
render(<MailboxModal {...defaultProps} />);
await waitFor(() => {
expect(screen.getByTestId("mailbox-mark-all-read")).toBeDefined();
});
});
it("calls markAllMessagesRead when clicking mark all read", async () => {
render(<MailboxModal {...defaultProps} />);
await waitFor(() => {
expect(screen.getByTestId("mailbox-mark-all-read")).toBeDefined();
});
fireEvent.click(screen.getByTestId("mailbox-mark-all-read"));
await waitFor(() => {
expect(mockMarkAllMessagesRead).toHaveBeenCalledWith(undefined);
});
});
it("deletes message when clicking delete in detail view", async () => {
render(<MailboxModal {...defaultProps} />);
await waitFor(() => {
expect(screen.getByTestId("mailbox-item-msg-001")).toBeDefined();
});
fireEvent.click(screen.getByTestId("mailbox-item-msg-001"));
await waitFor(() => {
expect(screen.getByTestId("mailbox-delete")).toBeDefined();
});
fireEvent.click(screen.getByTestId("mailbox-delete"));
await waitFor(() => {
expect(mockDeleteMessage).toHaveBeenCalledWith("msg-001", undefined);
});
});
it("shows compose FAB in inbox tab", async () => {
render(<MailboxModal {...defaultProps} />);
await waitFor(() => {
expect(screen.getByTestId("mailbox-compose-fab")).toBeDefined();
});
});
it("does not show compose FAB in agents tab", async () => {
render(<MailboxModal {...defaultProps} />);
fireEvent.click(screen.getByTestId("mailbox-tab-agents"));
await waitFor(() => {
expect(screen.queryByTestId("mailbox-compose-fab")).toBeNull();
});
});
it("shows loading skeleton while loading", async () => {
mockFetchInbox.mockImplementation(() => new Promise(() => {})); // Never resolves
render(<MailboxModal {...defaultProps} />);
await waitFor(() => {
expect(screen.getByTestId("mailbox-skeleton")).toBeDefined();
});
});
it("shows empty inbox state when no messages", async () => {
mockFetchInbox.mockResolvedValue({ messages: [], total: 0, unreadCount: 0 });
render(<MailboxModal {...defaultProps} />);
await waitFor(() => {
expect(screen.getByTestId("mailbox-inbox-empty")).toBeDefined();
});
});
it("calls onClose when clicking close button", async () => {
const onClose = vi.fn();
render(<MailboxModal {...defaultProps} onClose={onClose} />);
await waitFor(() => {
expect(screen.getByTestId("mailbox-close")).toBeDefined();
});
fireEvent.click(screen.getByTestId("mailbox-close"));
expect(onClose).toHaveBeenCalledOnce();
});
it("passes projectId to API calls", async () => {
render(<MailboxModal {...defaultProps} projectId="proj-1" />);
await waitFor(() => {
expect(mockFetchInbox).toHaveBeenCalledWith({ limit: 50 }, "proj-1");
});
});
});

View File

@@ -0,0 +1,204 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { MessageComposer } from "../MessageComposer";
import * as apiModule from "../../api";
import type { Agent } from "../../api";
// Mock the API module
vi.mock("../../api", () => ({
sendMessage: vi.fn(),
}));
// Mock lucide-react icons
vi.mock("lucide-react", () => ({
X: () => <span data-testid="icon-x">X</span>,
Send: () => <span data-testid="icon-send">Send</span>,
Loader2: ({ className }: { className?: string }) => (
<span data-testid="icon-loader" className={className}>Loader</span>
),
Bot: () => <span data-testid="icon-bot">Bot</span>,
AlertCircle: () => <span data-testid="icon-alert">Alert</span>,
}));
const mockSendMessage = vi.mocked(apiModule.sendMessage);
const mockAgents: Agent[] = [
{
id: "agent-001",
name: "Test Agent",
role: "executor",
state: "idle",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
metadata: {},
},
];
const defaultProps = {
onSend: vi.fn(),
onCancel: vi.fn(),
addToast: vi.fn(),
};
describe("MessageComposer", () => {
beforeEach(() => {
vi.clearAllMocks();
mockSendMessage.mockResolvedValue({
id: "msg-new",
fromId: "dashboard",
fromType: "user",
toId: "agent-001",
toType: "agent",
content: "Test message",
type: "user-to-agent",
read: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
});
it("renders the composer with header", () => {
render(<MessageComposer {...defaultProps} />);
expect(screen.getByText("New Message")).toBeDefined();
});
it("shows agent dropdown when agents are provided", () => {
render(<MessageComposer {...defaultProps} agents={mockAgents} />);
const select = screen.getByTestId("message-composer-recipient");
expect(select).toBeDefined();
expect(select.tagName).toBe("SELECT");
});
it("shows text input when no agents provided", () => {
render(<MessageComposer {...defaultProps} />);
const input = screen.getByTestId("message-composer-recipient");
expect(input.tagName).toBe("INPUT");
});
it("disables send button when content is empty", () => {
render(<MessageComposer {...defaultProps} agents={mockAgents} />);
const sendBtn = screen.getByTestId("message-composer-send");
expect(sendBtn.hasAttribute("disabled")).toBe(true);
});
it("enables send button when recipient and content are filled", () => {
render(<MessageComposer {...defaultProps} agents={mockAgents} />);
// Select agent
fireEvent.change(screen.getByTestId("message-composer-recipient"), {
target: { value: "agent-001" },
});
// Type content
fireEvent.change(screen.getByTestId("message-composer-content"), {
target: { value: "Hello agent!" },
});
const sendBtn = screen.getByTestId("message-composer-send");
expect(sendBtn.hasAttribute("disabled")).toBe(false);
});
it("shows character count", () => {
render(<MessageComposer {...defaultProps} />);
expect(screen.getByTestId("message-composer-charcount")).toBeDefined();
expect(screen.getByTestId("message-composer-charcount").textContent).toContain("0/2000");
});
it("updates character count when typing", () => {
render(<MessageComposer {...defaultProps} />);
const textarea = screen.getByTestId("message-composer-content");
fireEvent.change(textarea, { target: { value: "Hello" } });
expect(screen.getByTestId("message-composer-charcount").textContent).toContain("5/2000");
});
it("calls onSend when message is sent successfully", async () => {
render(<MessageComposer {...defaultProps} agents={mockAgents} />);
fireEvent.change(screen.getByTestId("message-composer-recipient"), {
target: { value: "agent-001" },
});
fireEvent.change(screen.getByTestId("message-composer-content"), {
target: { value: "Hello agent!" },
});
fireEvent.click(screen.getByTestId("message-composer-send"));
await waitFor(() => {
expect(mockSendMessage).toHaveBeenCalledWith(
{
toId: "agent-001",
toType: "agent",
content: "Hello agent!",
type: "user-to-agent",
},
undefined,
);
});
expect(defaultProps.onSend).toHaveBeenCalledOnce();
});
it("shows error when send fails", async () => {
mockSendMessage.mockRejectedValue(new Error("Network error"));
render(<MessageComposer {...defaultProps} agents={mockAgents} />);
fireEvent.change(screen.getByTestId("message-composer-recipient"), {
target: { value: "agent-001" },
});
fireEvent.change(screen.getByTestId("message-composer-content"), {
target: { value: "Hello agent!" },
});
fireEvent.click(screen.getByTestId("message-composer-send"));
await waitFor(() => {
expect(screen.getByTestId("message-composer-error")).toBeDefined();
});
expect(screen.getByTestId("message-composer-error").textContent).toContain("Network error");
});
it("calls onCancel when clicking cancel button", () => {
render(<MessageComposer {...defaultProps} />);
fireEvent.click(screen.getByTestId("message-composer-cancel"));
expect(defaultProps.onCancel).toHaveBeenCalledOnce();
});
it("calls onCancel when clicking cancel footer button", () => {
render(<MessageComposer {...defaultProps} />);
fireEvent.click(screen.getByTestId("message-composer-cancel-btn"));
expect(defaultProps.onCancel).toHaveBeenCalledOnce();
});
it("pre-fills recipient when provided", () => {
render(
<MessageComposer
{...defaultProps}
recipient={{ id: "agent-001", type: "agent" }}
/>,
);
// When recipient is pre-filled, it shows a fixed label instead of dropdown
expect(screen.getByText("agent-001")).toBeDefined();
});
it("shows loading state while sending", async () => {
mockSendMessage.mockImplementation(() => new Promise(() => {})); // Never resolves
render(<MessageComposer {...defaultProps} agents={mockAgents} />);
fireEvent.change(screen.getByTestId("message-composer-recipient"), {
target: { value: "agent-001" },
});
fireEvent.change(screen.getByTestId("message-composer-content"), {
target: { value: "Hello agent!" },
});
fireEvent.click(screen.getByTestId("message-composer-send"));
await waitFor(() => {
expect(screen.getByTestId("icon-loader")).toBeDefined();
});
});
it("passes projectId to sendMessage", async () => {
render(<MessageComposer {...defaultProps} agents={mockAgents} projectId="proj-1" />);
fireEvent.change(screen.getByTestId("message-composer-recipient"), {
target: { value: "agent-001" },
});
fireEvent.change(screen.getByTestId("message-composer-content"), {
target: { value: "Hello!" },
});
fireEvent.click(screen.getByTestId("message-composer-send"));
await waitFor(() => {
expect(mockSendMessage).toHaveBeenCalledWith(
expect.anything(),
"proj-1",
);
});
});
});