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:
@@ -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
|
||||
|
||||
609
packages/dashboard/app/components/MailboxModal.tsx
Normal file
609
packages/dashboard/app/components/MailboxModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
197
packages/dashboard/app/components/MessageComposer.tsx
Normal file
197
packages/dashboard/app/components/MessageComposer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user