Files
fusion/packages/dashboard/app/components/MailboxView.tsx
Fusion 266eafaecd feat(FN-4103): add default agents selection in MailboxView
MailboxView's default agents selection logic was implemented and refactored to be more concise (14 lines removed), with corresponding test coverage for the all-agents default selection behavior added to MailboxView.test.tsx.

Fusion-Task-Id: FN-4103
2026-05-12 07:47:11 -07:00

1280 lines
50 KiB
TypeScript

import "./MailboxModal.css";
import { useState, useEffect, useCallback, useMemo, useRef, type CSSProperties } from "react";
import {
Mail,
Send,
Inbox as InboxIcon,
Bot,
Trash2,
CheckCheck,
Loader2,
RefreshCw,
MessageSquare,
User,
} from "lucide-react";
import type { Message, MessageType, ParticipantType } from "@fusion/core";
import {
fetchInbox,
fetchOutbox,
fetchUnreadCount,
fetchAgentMailbox,
fetchAllAgentMailbox,
markMessageRead,
markAllMessagesRead,
deleteMessage,
fetchConversation,
fetchAgents,
fetchApprovals,
fetchApprovalDetail,
decideApproval,
type InboxResponse,
type OutboxResponse,
type AgentMailboxResponse,
type AllAgentsMailboxResponse,
type Agent,
type ApprovalRequestSummary,
type ApprovalRequestDetail,
} from "../api";
import { MailboxMessageContent } from "./MailboxMessageContent";
import { MessageComposer } from "./MessageComposer";
import { subscribeSse } from "../sse-bus";
import { useViewportMode } from "../hooks/useViewportMode";
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
import { getScopedItem, setScopedItem } from "../utils/projectStorage";
// ── Types ─────────────────────────────────────────────────────────────────
type MailboxTab = "inbox" | "outbox" | "agents" | "approvals";
interface MailboxViewProps {
projectId?: string;
addToast?: (msg: string, type?: "success" | "error") => void;
/** Callback when unread count changes (for header badge updates) */
onUnreadCountChange?: (count: number) => void;
}
const ALL_AGENTS_MAILBOX_ID = "__all_agents__";
const MAILBOX_SIDEBAR_MIN_WIDTH = 280;
const MAILBOX_SIDEBAR_MAX_RATIO = 0.65;
const MAILBOX_SIDEBAR_KEYBOARD_STEP = 16;
const MAILBOX_SIDEBAR_DEFAULT_WIDTH = 320;
function getMailboxSidebarMaxWidth(containerWidth: number): number {
return Math.max(MAILBOX_SIDEBAR_MIN_WIDTH, containerWidth * MAILBOX_SIDEBAR_MAX_RATIO);
}
function clampMailboxSidebarWidth(width: number, containerWidth: number): number {
const maxWidth = getMailboxSidebarMaxWidth(containerWidth);
return Math.min(Math.max(width, MAILBOX_SIDEBAR_MIN_WIDTH), maxWidth);
}
function readMailboxSidebarWidth(projectId?: string): number {
try {
const saved = getScopedItem("kb-dashboard-mailbox-sidebar-width", projectId);
if (!saved) return MAILBOX_SIDEBAR_DEFAULT_WIDTH;
const parsed = Number(saved);
if (Number.isFinite(parsed) && parsed > 0) {
return parsed;
}
} catch {
// Invalid localStorage data - fall through to default
}
return MAILBOX_SIDEBAR_DEFAULT_WIDTH;
}
// ── Helpers ───────────────────────────────────────────────────────────────
function formatTimestamp(ts: string): string {
const date = new Date(ts);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return "Just now";
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
}
function participantLabel(
id: string,
type: ParticipantType,
agentNamesById?: ReadonlyMap<string, string>,
): string {
if (type === "user") return id === "dashboard" ? "You" : `User: ${id}`;
if (type === "agent") {
const name = agentNamesById?.get(id)?.trim();
if (!name) return `Agent: ${id}`;
if (name === id) return `Agent: ${id}`;
return `Agent: ${name} (${id})`;
}
return "System";
}
function messageTypeLabel(type: MessageType): string {
switch (type) {
case "agent-to-agent": return "Agent ↔ Agent";
case "agent-to-user": return "Agent → You";
case "user-to-agent": return "You → Agent";
case "system": return "System";
}
}
function messagePreview(content: string, max = 80): string {
if (content.length <= max) return content;
return `${content.slice(0, max)}`;
}
function getDeepLinkedMessageId(): string | null {
if (typeof window === "undefined") {
return null;
}
const params = new URLSearchParams(window.location.search);
const paramId = params.get("mailbox-message");
if (paramId) {
return paramId;
}
const hashMatch = /^#message-(.+)$/.exec(window.location.hash);
return hashMatch?.[1] ?? null;
}
function listMessageAnchorId(messageId: string): string {
return `mailbox-list-message-${messageId}`;
}
function detailMessageAnchorId(messageId: string): string {
return `mailbox-detail-message-${messageId}`;
}
function buildReplyThread(messages: Message[], selectedMessage: Message): Message[] {
const allMessages = [...messages];
if (!allMessages.some((message) => message.id === selectedMessage.id)) {
allMessages.push(selectedMessage);
}
const threadIds = new Set<string>([selectedMessage.id]);
let changed = true;
while (changed) {
changed = false;
for (const message of allMessages) {
const replyToId = message.metadata?.replyTo?.messageId;
if (threadIds.has(message.id) && replyToId && !threadIds.has(replyToId)) {
threadIds.add(replyToId);
changed = true;
}
if (replyToId && threadIds.has(replyToId) && !threadIds.has(message.id)) {
threadIds.add(message.id);
changed = true;
}
}
}
return allMessages
.filter((message) => threadIds.has(message.id))
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
}
// ── Component ─────────────────────────────────────────────────────────────
export function MailboxView({
projectId,
addToast,
onUnreadCountChange,
}: MailboxViewProps) {
const [activeTab, setActiveTab] = useState<MailboxTab>("inbox");
const [inbox, setInbox] = useState<InboxResponse | null>(null);
const [outbox, setOutbox] = useState<OutboxResponse | null>(null);
const [unreadCount, setUnreadCount] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [selectedMessage, setSelectedMessage] = useState<Message | null>(null);
const [conversationMessages, setConversationMessages] = useState<Message[]>([]);
const [showComposer, setShowComposer] = useState(false);
const [composeRecipient, setComposeRecipient] = useState<{ id: string; type: ParticipantType } | null>(null);
const [composeReplyContext, setComposeReplyContext] = useState<{ messageId: string; preview: string } | null>(null);
const [selectedAgentId, setSelectedAgentId] = useState<string>(ALL_AGENTS_MAILBOX_ID);
const [agentSubTab, setAgentSubTab] = useState<"inbox" | "outbox">("inbox");
const [agentMailbox, setAgentMailbox] = useState<AgentMailboxResponse | null>(null);
const [allAgentsMailbox, setAllAgentsMailbox] = useState<AllAgentsMailboxResponse | null>(null);
const [agents, setAgents] = useState<Agent[]>([]);
const [approvalSubTab, setApprovalSubTab] = useState<"pending" | "history">("pending");
const [approvals, setApprovals] = useState<ApprovalRequestSummary[]>([]);
const [approvalPendingCount, setApprovalPendingCount] = useState(0);
const [selectedApproval, setSelectedApproval] = useState<ApprovalRequestDetail | null>(null);
const [approvalComment, setApprovalComment] = useState("");
const [approvalDecisionLoading, setApprovalDecisionLoading] = useState<false | "approve" | "deny">(false);
const agentNamesById = useMemo(
() => new Map(agents.map((agent) => [agent.id, agent.name ?? ""])),
[agents],
);
const getParticipantLabel = useCallback(
(id: string, type: ParticipantType) => participantLabel(id, type, agentNamesById),
[agentNamesById],
);
const viewportMode = useViewportMode();
const isMobile = viewportMode === "mobile";
const isSplitPane = !isMobile;
const [sidebarWidth, setSidebarWidth] = useState<number>(() => readMailboxSidebarWidth(projectId));
const splitLayoutRef = useRef<HTMLDivElement>(null);
const { keyboardOverlap, viewportHeight, viewportOffsetTop, keyboardOpen } = useMobileKeyboard({ enabled: isMobile });
const containerKeyboardStyle = useMemo<CSSProperties | undefined>(() => {
if (!keyboardOpen) {
return undefined;
}
return {
"--keyboard-overlap": `${keyboardOverlap}px`,
"--vv-offset-top": `${viewportOffsetTop}px`,
...(viewportHeight != null ? { "--vv-height": `${viewportHeight}px` } : {}),
} as CSSProperties;
}, [keyboardOpen, keyboardOverlap, viewportHeight, viewportOffsetTop]);
useEffect(() => {
setSidebarWidth(readMailboxSidebarWidth(projectId));
}, [projectId]);
useEffect(() => {
if (!isSplitPane) return;
const containerWidth = splitLayoutRef.current?.clientWidth;
if (!containerWidth) return;
setSidebarWidth((current) => clampMailboxSidebarWidth(current, containerWidth));
}, [isSplitPane]);
useEffect(() => {
if (!isSplitPane) return;
try {
setScopedItem("kb-dashboard-mailbox-sidebar-width", String(sidebarWidth), projectId);
} catch {
// localStorage persistence is best-effort.
}
}, [isSplitPane, projectId, sidebarWidth]);
const handleSplitResizeStart = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
if (!isSplitPane) return;
event.preventDefault();
const container = splitLayoutRef.current;
if (!container) return;
const rect = container.getBoundingClientRect();
const onMouseMove = (moveEvent: MouseEvent) => {
const proposedWidth = moveEvent.clientX - rect.left;
setSidebarWidth(clampMailboxSidebarWidth(proposedWidth, rect.width));
};
const onMouseUp = () => {
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
};
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp);
}, [isSplitPane]);
const handleSplitResizeKeyDown = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
if (!isSplitPane) return;
const measuredWidth = splitLayoutRef.current?.clientWidth ?? 0;
const fallbackWidth = sidebarWidth / MAILBOX_SIDEBAR_MAX_RATIO + MAILBOX_SIDEBAR_KEYBOARD_STEP;
const containerWidth = Math.max(measuredWidth, fallbackWidth);
const maxWidth = getMailboxSidebarMaxWidth(containerWidth);
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
event.preventDefault();
const delta = event.key === "ArrowLeft" ? -MAILBOX_SIDEBAR_KEYBOARD_STEP : MAILBOX_SIDEBAR_KEYBOARD_STEP;
setSidebarWidth((current) => clampMailboxSidebarWidth(current + delta, containerWidth));
return;
}
if (event.key === "Home") {
event.preventDefault();
setSidebarWidth(MAILBOX_SIDEBAR_MIN_WIDTH);
return;
}
if (event.key === "End") {
event.preventDefault();
setSidebarWidth(maxWidth);
}
}, [isSplitPane, sidebarWidth]);
// ── Data fetching ─────────────────────────────────────────────────────
const loadInbox = useCallback(async () => {
setIsLoading(true);
try {
const data = await fetchInbox({ limit: 50 }, projectId);
setInbox(data);
setUnreadCount(data.unreadCount);
onUnreadCountChange?.(data.unreadCount);
} catch {
// Silently fail — empty state will show
} finally {
setIsLoading(false);
}
}, [projectId, onUnreadCountChange]);
const loadOutbox = useCallback(async () => {
setIsLoading(true);
try {
const data = await fetchOutbox({ limit: 50 }, projectId);
setOutbox(data);
} catch {
// Silently fail
} finally {
setIsLoading(false);
}
}, [projectId]);
const loadAgentMailbox = useCallback(async (agentId: string) => {
setIsLoading(true);
try {
const data = await fetchAgentMailbox(agentId, projectId);
setAgentMailbox(data);
} catch {
// Silently fail
} finally {
setIsLoading(false);
}
}, [projectId]);
const loadAllAgentsMailbox = useCallback(async () => {
setIsLoading(true);
try {
const data = await fetchAllAgentMailbox(projectId);
setAllAgentsMailbox(data);
} catch {
// Silently fail
} finally {
setIsLoading(false);
}
}, [projectId]);
const loadAgents = useCallback(async () => {
try {
const data = await fetchAgents(undefined, projectId);
setAgents(data);
} catch {
// Silently fail
}
}, [projectId]);
const refreshUnreadCount = useCallback(async () => {
try {
const data = await fetchUnreadCount(projectId);
setUnreadCount(data.unreadCount);
onUnreadCountChange?.(data.unreadCount);
} catch {
// Silently fail
}
}, [projectId, onUnreadCountChange]);
const loadApprovals = useCallback(async (status: "pending" | "history") => {
setIsLoading(true);
try {
const list = await fetchApprovals({ status: status === "pending" ? "pending" : undefined, limit: 100 }, projectId);
if (status === "pending") {
setApprovals(list.requests);
} else {
const [approved, denied, completed] = await Promise.all([
fetchApprovals({ status: "approved", limit: 100 }, projectId),
fetchApprovals({ status: "denied", limit: 100 }, projectId),
fetchApprovals({ status: "completed", limit: 100 }, projectId),
]);
setApprovals([...approved.requests, ...denied.requests, ...completed.requests].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)));
}
setApprovalPendingCount(list.pendingCount);
} catch {
// Silently fail
} finally {
setIsLoading(false);
}
}, [projectId]);
// Load data on tab change
useEffect(() => {
if (activeTab === "inbox") loadInbox();
else if (activeTab === "outbox") loadOutbox();
else if (activeTab === "agents") loadAgents();
else if (activeTab === "approvals") {
void loadApprovals(approvalSubTab);
}
}, [activeTab, loadInbox, loadOutbox, loadAgents, loadApprovals, approvalSubTab]);
// Load agent mailbox when selected
useEffect(() => {
if (selectedAgentId === ALL_AGENTS_MAILBOX_ID) {
void loadAllAgentsMailbox();
return;
}
void loadAgentMailbox(selectedAgentId);
}, [selectedAgentId, loadAgentMailbox, loadAllAgentsMailbox]);
// Load unread count on mount
useEffect(() => {
refreshUnreadCount();
}, [refreshUnreadCount]);
// Load agents on mount so they're available for compose from any tab (not just agents tab)
useEffect(() => {
loadAgents();
}, [loadAgents]);
// Subscribe to mailbox SSE events for near-real-time refresh.
useEffect(() => {
if (typeof EventSource === "undefined") {
return;
}
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
const onMailboxUpdate = () => {
void refreshUnreadCount();
if (activeTab === "inbox") {
void loadInbox();
} else if (activeTab === "outbox") {
void loadOutbox();
} else if (activeTab === "approvals") {
void loadApprovals(approvalSubTab);
}
if (selectedAgentId === ALL_AGENTS_MAILBOX_ID) {
void loadAllAgentsMailbox();
} else if (selectedAgentId) {
void loadAgentMailbox(selectedAgentId);
}
};
return subscribeSse(`/api/events${query}`, {
events: {
"message:sent": onMailboxUpdate,
"message:received": onMailboxUpdate,
"message:read": onMailboxUpdate,
"message:deleted": onMailboxUpdate,
"approval:requested": onMailboxUpdate,
"approval:updated": onMailboxUpdate,
"approval:decided": onMailboxUpdate,
},
});
}, [projectId, activeTab, selectedAgentId, refreshUnreadCount, loadInbox, loadOutbox, loadAgentMailbox, loadAllAgentsMailbox, loadApprovals, approvalSubTab]);
// ── Actions ───────────────────────────────────────────────────────────
const handleOpenMessage = useCallback(async (message: Message) => {
setSelectedMessage(message);
// Only auto-mark as read when viewing the dashboard user's own inbox.
// Browsing another agent's mailbox must not consume their unread messages
// out from under them — the agent's heartbeat is the one that reads + acks.
if (!message.read && activeTab === "inbox") {
try {
const updated = await markMessageRead(message.id, projectId);
// Update inbox state
if (updated) {
setInbox((prev) =>
prev
? {
...prev,
messages: prev.messages.map((m) => (m.id === updated.id ? updated : m)),
unreadCount: Math.max(0, prev.unreadCount - 1),
}
: prev,
);
}
const newCount = Math.max(0, unreadCount - 1);
setUnreadCount(newCount);
onUnreadCountChange?.(newCount);
} catch {
// Non-critical
}
}
// Load conversation thread
try {
const conv = await fetchConversation(message.fromId, message.fromType, projectId);
setConversationMessages(conv);
} catch {
setConversationMessages([message]);
}
}, [projectId, unreadCount, onUnreadCountChange, activeTab]);
// Deep-link: open and highlight a specific message from URL params.
useEffect(() => {
const deepLinkedMessageId = getDeepLinkedMessageId();
if (!deepLinkedMessageId) {
return;
}
const message = [
...(inbox?.messages ?? []),
...(outbox?.messages ?? []),
...(agentMailbox?.inbox ?? []),
...(agentMailbox?.outbox ?? []),
...(allAgentsMailbox?.messages ?? []),
...conversationMessages,
].find((candidate) => candidate.id === deepLinkedMessageId);
if (!message) {
return;
}
void handleOpenMessage(message);
}, [inbox, outbox, agentMailbox, allAgentsMailbox, conversationMessages, handleOpenMessage]);
useEffect(() => {
const deepLinkedMessageId = getDeepLinkedMessageId();
if (!deepLinkedMessageId) {
return;
}
const element = document.getElementById(detailMessageAnchorId(deepLinkedMessageId));
if (!element) {
return;
}
element.scrollIntoView({ behavior: "smooth", block: "center" });
element.classList.add("mailbox-message-highlight");
const timer = window.setTimeout(() => {
element.classList.remove("mailbox-message-highlight");
}, 2000);
return () => {
window.clearTimeout(timer);
};
}, [selectedMessage, conversationMessages]);
const handleCloseMessage = useCallback(() => {
setSelectedMessage(null);
setConversationMessages([]);
}, []);
const handleMarkAllRead = useCallback(async () => {
try {
const result = await markAllMessagesRead(projectId);
setUnreadCount(0);
onUnreadCountChange?.(0);
setInbox((prev) =>
prev
? {
...prev,
messages: prev.messages.map((m) => ({ ...m, read: true })),
unreadCount: 0,
}
: prev,
);
addToast?.(`Marked ${result.markedAsRead} messages as read`, "success");
} catch {
addToast?.("Failed to mark messages as read", "error");
}
}, [projectId, addToast, onUnreadCountChange]);
const handleDeleteMessage = useCallback(async (id: string) => {
try {
await deleteMessage(id, projectId);
setSelectedMessage(null);
setConversationMessages([]);
// Refresh current tab
if (activeTab === "inbox") loadInbox();
else if (activeTab === "outbox") loadOutbox();
else if (selectedAgentId === ALL_AGENTS_MAILBOX_ID) loadAllAgentsMailbox();
else if (selectedAgentId) loadAgentMailbox(selectedAgentId);
addToast?.("Message deleted", "success");
} catch {
addToast?.("Failed to delete message", "error");
}
}, [projectId, activeTab, selectedAgentId, loadInbox, loadOutbox, loadAgentMailbox, loadAllAgentsMailbox, addToast]);
const handleReply = useCallback((message: Message) => {
setComposeRecipient({ id: message.fromId, type: message.fromType });
setComposeReplyContext({
messageId: message.id,
preview: messagePreview(message.content, 120),
});
setShowComposer(true);
}, []);
const handleMessageSent = useCallback(() => {
setShowComposer(false);
setComposeRecipient(null);
setComposeReplyContext(null);
addToast?.("Message sent", "success");
// Refresh current tab
if (activeTab === "outbox") loadOutbox();
else if (activeTab === "agents" && selectedAgentId === ALL_AGENTS_MAILBOX_ID) loadAllAgentsMailbox();
else if (activeTab === "agents" && selectedAgentId) loadAgentMailbox(selectedAgentId);
refreshUnreadCount();
}, [activeTab, loadOutbox, selectedAgentId, loadAgentMailbox, loadAllAgentsMailbox, addToast, refreshUnreadCount]);
const handleOpenCompose = useCallback(() => {
// Pre-fill recipient from selected agent if available
if (activeTab === "agents" && selectedAgentId && selectedAgentId !== ALL_AGENTS_MAILBOX_ID) {
setComposeRecipient({ id: selectedAgentId, type: "agent" });
} else {
setComposeRecipient(null);
}
setComposeReplyContext(null);
setShowComposer(true);
}, [activeTab, selectedAgentId]);
const handleComposeCancel = useCallback(() => {
setShowComposer(false);
setComposeRecipient(null);
setComposeReplyContext(null);
}, []);
const handleOpenApproval = useCallback(async (request: ApprovalRequestSummary) => {
try {
const detail = await fetchApprovalDetail(request.id, projectId);
setSelectedApproval(detail);
setApprovalComment("");
} catch {
addToast?.("Failed to load approval request", "error");
}
}, [projectId, addToast]);
const handleApprovalDecision = useCallback(async (decision: "approve" | "deny") => {
if (!selectedApproval || approvalDecisionLoading) return;
setApprovalDecisionLoading(decision);
try {
await decideApproval(selectedApproval.id, { decision, comment: approvalComment || undefined }, projectId);
await loadApprovals(approvalSubTab);
const updated = await fetchApprovalDetail(selectedApproval.id, projectId);
setSelectedApproval(updated);
setApprovalComment("");
addToast?.(`Request ${decision === "approve" ? "approved" : "denied"}`, "success");
} catch {
addToast?.("Failed to submit decision", "error");
} finally {
setApprovalDecisionLoading(false);
}
}, [selectedApproval, approvalDecisionLoading, approvalComment, projectId, loadApprovals, approvalSubTab, addToast]);
// ── Render ────────────────────────────────────────────────────────────
const renderMessageDetail = () => {
if (!selectedMessage || showComposer) return null;
const threadMessages = buildReplyThread(conversationMessages, selectedMessage);
return (
<div className="mailbox-message-detail" data-testid="mailbox-message-detail" id={detailMessageAnchorId(selectedMessage.id)}>
<div className="mailbox-message-detail-header">
{isMobile && (
<button
className="btn btn-sm btn-secondary"
onClick={handleCloseMessage}
data-testid="mailbox-back-to-list"
>
Back
</button>
)}
<div className="mailbox-message-detail-meta">
<span className="mailbox-message-type">{messageTypeLabel(selectedMessage.type)}</span>
<span className="mailbox-message-time">{formatTimestamp(selectedMessage.createdAt)}</span>
</div>
<div className="mailbox-message-detail-actions">
{selectedMessage.fromType === "agent" && (
<button
className="btn btn-sm btn-secondary"
onClick={() => handleReply(selectedMessage)}
data-testid="mailbox-reply"
>
<MessageSquare size={14} />
<span>Reply</span>
</button>
)}
<button
className="btn btn-sm btn-secondary"
onClick={() => handleDeleteMessage(selectedMessage.id)}
data-testid="mailbox-delete"
>
<Trash2 size={14} />
<span>Delete</span>
</button>
</div>
</div>
<div className="mailbox-message-participants">
<div className="mailbox-participant">
<span className="mailbox-participant-label">From:</span>
<span className="mailbox-participant-value">
{selectedMessage.fromType === "agent" ? <Bot size={14} /> : <User size={14} />}
{getParticipantLabel(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} />}
{getParticipantLabel(selectedMessage.toId, selectedMessage.toType)}
</span>
</div>
</div>
{threadMessages.length > 1 && (
<div className="mailbox-conversation" data-testid="mailbox-conversation">
<div className="mailbox-conversation-label">Conversation</div>
{threadMessages.map((msg) => {
const replyToId = msg.metadata?.replyTo?.messageId;
const replyToMessage = replyToId
? threadMessages.find((candidate) => candidate.id === replyToId)
: undefined;
return (
<div
key={msg.id}
id={detailMessageAnchorId(msg.id)}
className={`mailbox-conversation-msg ${msg.id === selectedMessage.id ? "current" : ""}`}
>
<div className="mailbox-conversation-msg-header">
<span>{getParticipantLabel(msg.fromId, msg.fromType)}</span>
<span className="mailbox-message-time">{formatTimestamp(msg.createdAt)}</span>
</div>
{replyToId && (
<div className="mailbox-reply-context-static" data-testid={`mailbox-reply-context-${msg.id}`}>
Replying to {replyToMessage ? messagePreview(replyToMessage.content, 60) : `message ${replyToId}`}
</div>
)}
<MailboxMessageContent
content={msg.content}
className="mailbox-conversation-msg-body"
/>
</div>
);
})}
</div>
)}
{(threadMessages.length <= 1) && (
<>
{selectedMessage.metadata?.replyTo?.messageId && (
<div className="mailbox-reply-context-static" data-testid="mailbox-selected-reply-context">
Replying to message {selectedMessage.metadata.replyTo.messageId}
</div>
)}
<MailboxMessageContent
content={selectedMessage.content}
className="mailbox-message-body"
testId="mailbox-message-body"
/>
</>
)}
</div>
);
};
const renderListPane = () => (
<>
{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}
id={listMessageAnchorId(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">
{getParticipantLabel(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>
)}
{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}
id={listMessageAnchorId(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: {getParticipantLabel(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>
)}
{activeTab === "approvals" && (
<div className="mailbox-approvals" data-testid="mailbox-approvals">
<div className="mailbox-approval-filters" data-testid="mailbox-approval-filters">
<button
className={`btn btn-sm btn-secondary mailbox-agent-subtab ${approvalSubTab === "pending" ? "active" : ""}`}
onClick={() => { setApprovalSubTab("pending"); setSelectedApproval(null); }}
data-testid="mailbox-approval-filter-pending"
>
Pending
</button>
<button
className={`btn btn-sm btn-secondary mailbox-agent-subtab ${approvalSubTab === "history" ? "active" : ""}`}
onClick={() => { setApprovalSubTab("history"); setSelectedApproval(null); }}
data-testid="mailbox-approval-filter-history"
>
History
</button>
</div>
<div className="mailbox-list" data-testid="mailbox-approval-list">
{approvals.length === 0 && !isLoading && (
<div className="mailbox-empty" data-testid="mailbox-approval-empty">
<InboxIcon size={32} />
<p>{approvalSubTab === "pending" ? "No pending approvals" : "No historical approvals"}</p>
</div>
)}
{approvals.map((request) => (
<div
key={request.id}
className="mailbox-item mailbox-approval-item"
onClick={() => void handleOpenApproval(request)}
data-testid={`mailbox-approval-item-${request.id}`}
>
<div className={`status-dot mailbox-approval-status-dot mailbox-approval-status-dot--${request.status}`} />
<div className="mailbox-item-content">
<div className="mailbox-item-header">
<span className="mailbox-item-from">{request.agentId} · {request.actionCategory}</span>
<span className="mailbox-item-time">{formatTimestamp(request.createdAt)}</span>
</div>
<div className="mailbox-item-preview">{request.actionSummary}</div>
</div>
<span className={`mailbox-approval-status mailbox-approval-status--${request.status}`}>{request.status}</span>
</div>
))}
</div>
</div>
)}
{activeTab === "agents" && (
<div className="mailbox-agents" data-testid="mailbox-agents">
{agents.length === 0 ? (
<div className="mailbox-empty">
<Bot size={32} />
<p>No agents found</p>
</div>
) : (
<>
<div className="mailbox-agents-header">
<div className="mailbox-agents-dropdown">
<select
className="message-composer-select mailbox-agent-select"
value={selectedAgentId}
onChange={(e) => { setSelectedAgentId(e.target.value); setAgentSubTab("inbox"); }}
data-testid="mailbox-agent-select"
>
<option value={ALL_AGENTS_MAILBOX_ID}>All agents</option>
{agents.map((agent) => (
<option key={agent.id} value={agent.id}>
{agent.name || agent.id}
</option>
))}
</select>
</div>
<button
className="btn btn-sm btn-secondary mailbox-compose-btn"
onClick={handleOpenCompose}
data-testid="mailbox-compose-btn"
>
<MessageSquare size={14} />
<span>Compose</span>
</button>
</div>
{selectedAgentId && selectedAgentId !== ALL_AGENTS_MAILBOX_ID && (
<div className="mailbox-agent-subtabs" data-testid="mailbox-agent-subtabs">
<button
className={`btn btn-sm btn-secondary mailbox-agent-subtab ${agentSubTab === "inbox" ? "active" : ""}`}
onClick={() => setAgentSubTab("inbox")}
data-testid="mailbox-agent-subtab-inbox"
>
<InboxIcon size={12} />
<span>Inbox</span>
{agentMailbox && agentMailbox.unreadCount > 0 && (
<span className="mailbox-tab-badge">{agentMailbox.unreadCount}</span>
)}
</button>
<button
className={`btn btn-sm btn-secondary mailbox-agent-subtab ${agentSubTab === "outbox" ? "active" : ""}`}
onClick={() => setAgentSubTab("outbox")}
data-testid="mailbox-agent-subtab-outbox"
>
<Send size={12} />
<span>Outbox</span>
</button>
</div>
)}
<div className="mailbox-agents-content">
{selectedAgentId === ALL_AGENTS_MAILBOX_ID && isLoading && !allAgentsMailbox && <MailboxSkeleton />}
{selectedAgentId === ALL_AGENTS_MAILBOX_ID && allAgentsMailbox && allAgentsMailbox.messages.length === 0 && (
<div className="mailbox-empty">
<InboxIcon size={32} />
<p>No agent-to-agent messages</p>
</div>
)}
{selectedAgentId === ALL_AGENTS_MAILBOX_ID && allAgentsMailbox && allAgentsMailbox.messages.map((msg) => (
<div
key={msg.id}
id={listMessageAnchorId(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">{getParticipantLabel(msg.fromId, msg.fromType)}</span>
<span className="mailbox-item-time">{formatTimestamp(msg.createdAt)}</span>
</div>
<div className="mailbox-item-participants" data-testid={`mailbox-item-participants-${msg.id}`}>
<span>From: {getParticipantLabel(msg.fromId, msg.fromType)}</span>
<span>To: {getParticipantLabel(msg.toId, msg.toType)}</span>
</div>
<div className="mailbox-item-preview">{msg.content.slice(0, 80)}{msg.content.length > 80 ? "…" : ""}</div>
</div>
</div>
))}
{selectedAgentId && selectedAgentId !== ALL_AGENTS_MAILBOX_ID && isLoading && !agentMailbox && <MailboxSkeleton />}
{selectedAgentId && selectedAgentId !== ALL_AGENTS_MAILBOX_ID && agentMailbox && agentSubTab === "inbox" && agentMailbox.inbox.length === 0 && (
<div className="mailbox-empty">
<InboxIcon size={32} />
<p>No received messages for this agent</p>
</div>
)}
{selectedAgentId && selectedAgentId !== ALL_AGENTS_MAILBOX_ID && agentMailbox && agentSubTab === "outbox" && agentMailbox.outbox.length === 0 && (
<div className="mailbox-empty">
<Send size={32} />
<p>No sent messages for this agent</p>
</div>
)}
{selectedAgentId && selectedAgentId !== ALL_AGENTS_MAILBOX_ID && agentMailbox && agentSubTab === "inbox" && agentMailbox.inbox.map((msg) => (
<div
key={msg.id}
id={listMessageAnchorId(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">
{getParticipantLabel(msg.fromId, msg.fromType)}
</span>
<span className="mailbox-item-time">{formatTimestamp(msg.createdAt)}</span>
</div>
<div className="mailbox-item-preview">{msg.content.slice(0, 80)}{msg.content.length > 80 ? "…" : ""}</div>
</div>
</div>
))}
{selectedAgentId && selectedAgentId !== ALL_AGENTS_MAILBOX_ID && agentMailbox && agentSubTab === "outbox" && agentMailbox.outbox.map((msg) => (
<div
key={msg.id}
id={listMessageAnchorId(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: {getParticipantLabel(msg.toId, msg.toType)}
</span>
<span className="mailbox-item-time">{formatTimestamp(msg.createdAt)}</span>
</div>
<div className="mailbox-item-preview">{msg.content.slice(0, 80)}{msg.content.length > 80 ? "…" : ""}</div>
</div>
</div>
))}
</div>
</>
)}
</div>
)}
</>
);
const renderDetailPane = () => {
if (showComposer) {
return (
<MessageComposer
recipient={composeRecipient}
replyContext={composeReplyContext}
agents={agents}
projectId={projectId}
onSend={handleMessageSent}
onCancel={handleComposeCancel}
addToast={addToast}
/>
);
}
if (selectedMessage) {
return renderMessageDetail();
}
if (activeTab === "approvals" && selectedApproval) {
return (
<div className="mailbox-message-detail mailbox-approval-detail" data-testid="mailbox-approval-detail">
{isMobile && (
<button className="btn btn-sm btn-secondary" onClick={() => setSelectedApproval(null)} data-testid="mailbox-approval-back-to-list"> Back</button>
)}
<div className="mailbox-message-detail-header">
<div className="mailbox-message-detail-meta">
<span className="mailbox-message-type">{selectedApproval.actionCategory}</span>
<span className="mailbox-message-time">{selectedApproval.status}</span>
</div>
</div>
<div className="mailbox-message-body">
<strong>{selectedApproval.actionSummary}</strong>
<p>Requester: {selectedApproval.requester.actorName} ({selectedApproval.agentId})</p>
{selectedApproval.taskId && <p>Task: {selectedApproval.taskId}</p>}
<p>Requested: {formatTimestamp(selectedApproval.createdAt)}</p>
</div>
<div className="mailbox-conversation" data-testid="mailbox-approval-history">
{selectedApproval.history.map((event) => (
<div key={event.id} className="mailbox-conversation-msg">
<div className="mailbox-conversation-msg-header">
<span>{event.eventType}</span>
<span>{event.actor.actorName}</span>
</div>
{event.note && <div className="mailbox-item-preview">{event.note}</div>}
</div>
))}
</div>
{selectedApproval.status === "pending" && (
<div className="mailbox-approval-decision" data-testid="mailbox-approval-decision">
<textarea
className="message-composer-textarea mailbox-approval-comment"
value={approvalComment}
onChange={(event) => setApprovalComment(event.target.value)}
placeholder="Optional comment"
data-testid="mailbox-approval-comment"
/>
<div className="mailbox-header-actions">
<button className="btn btn-sm btn-secondary" onClick={() => void handleApprovalDecision("deny")} disabled={approvalDecisionLoading !== false} data-testid="mailbox-approval-deny">
Deny
</button>
<button className="btn btn-sm btn-primary" onClick={() => void handleApprovalDecision("approve")} disabled={approvalDecisionLoading !== false} data-testid="mailbox-approval-approve">
Approve
</button>
</div>
</div>
)}
</div>
);
}
return (
<div className="mailbox-split-empty" data-testid="mailbox-split-empty">
<Mail size={24} />
<p>Select a message to read</p>
</div>
);
};
return (
<div className="mailbox-view" style={containerKeyboardStyle} data-testid="mailbox-view">
{/* Header */}
<div className="mailbox-header">
<div className="mailbox-title">
<Mail size={18} />
<span>Mailbox</span>
{unreadCount > 0 && (
<span className="mailbox-unread-badge" data-testid="mailbox-unread-badge">
{unreadCount}
</span>
)}
</div>
<div className="mailbox-header-actions">
<button
className="btn btn-sm btn-primary"
onClick={handleOpenCompose}
title="Compose message"
data-testid="mailbox-header-compose"
>
<MessageSquare size={14} />
<span>Compose</span>
</button>
{activeTab === "inbox" && unreadCount > 0 && (
<button
className="btn btn-sm btn-secondary"
onClick={handleMarkAllRead}
title="Mark all as read"
data-testid="mailbox-mark-all-read"
>
<CheckCheck size={14} />
<span>Mark all read</span>
</button>
)}
<button
className="btn-icon"
onClick={() => {
if (activeTab === "inbox") loadInbox();
else if (activeTab === "outbox") loadOutbox();
else if (activeTab === "approvals") loadApprovals(approvalSubTab);
else if (selectedAgentId === ALL_AGENTS_MAILBOX_ID) loadAllAgentsMailbox();
else if (selectedAgentId) loadAgentMailbox(selectedAgentId);
}}
disabled={isLoading}
title="Refresh"
data-testid="mailbox-refresh"
>
{isLoading ? <Loader2 size={14} className="spin" /> : <RefreshCw size={14} />}
</button>
</div>
</div>
{/* Tabs */}
<div className="mailbox-tabs" data-testid="mailbox-tabs">
<button
className={`btn btn-sm btn-secondary mailbox-tab ${activeTab === "inbox" ? "active" : ""}`}
onClick={() => { setActiveTab("inbox"); setSelectedMessage(null); }}
data-testid="mailbox-tab-inbox"
>
<InboxIcon size={14} />
<span>Inbox</span>
{unreadCount > 0 && <span className="mailbox-tab-badge">{unreadCount}</span>}
</button>
<button
className={`btn btn-sm btn-secondary mailbox-tab ${activeTab === "outbox" ? "active" : ""}`}
onClick={() => { setActiveTab("outbox"); setSelectedMessage(null); }}
data-testid="mailbox-tab-outbox"
>
<Send size={14} />
<span>Outbox</span>
</button>
<button
className={`btn btn-sm btn-secondary mailbox-tab ${activeTab === "agents" ? "active" : ""}`}
onClick={() => { setActiveTab("agents"); setSelectedMessage(null); setSelectedApproval(null); }}
data-testid="mailbox-tab-agents"
>
<Bot size={14} />
<span>Agents</span>
</button>
<button
className={`btn btn-sm btn-secondary mailbox-tab ${activeTab === "approvals" ? "active" : ""}`}
onClick={() => { setActiveTab("approvals"); setSelectedMessage(null); setSelectedApproval(null); }}
data-testid="mailbox-tab-approvals"
>
<CheckCheck size={14} />
<span>Approvals</span>
{approvalPendingCount > 0 && <span className="mailbox-tab-badge" data-testid="mailbox-approvals-pending-badge">{approvalPendingCount}</span>}
</button>
</div>
<div className="mailbox-content" data-testid="mailbox-content">
{isSplitPane ? (
<div className="mailbox-split-layout" data-testid="mailbox-split-layout" ref={splitLayoutRef}>
<div
className="mailbox-split-list-pane"
data-testid="mailbox-split-list-pane"
style={{ width: `${sidebarWidth}px` }}
>
{renderListPane()}
</div>
<div
className="mailbox-split-resize-handle"
data-testid="mailbox-split-resize-handle"
role="separator"
aria-orientation="vertical"
aria-label="Resize message list pane"
tabIndex={0}
aria-valuemin={MAILBOX_SIDEBAR_MIN_WIDTH}
aria-valuemax={Math.round(getMailboxSidebarMaxWidth(splitLayoutRef.current?.clientWidth ?? sidebarWidth / MAILBOX_SIDEBAR_MAX_RATIO))}
aria-valuenow={Math.round(sidebarWidth)}
onMouseDown={handleSplitResizeStart}
onKeyDown={handleSplitResizeKeyDown}
/>
<div className="mailbox-split-detail-pane" data-testid="mailbox-split-detail-pane">
{renderDetailPane()}
</div>
</div>
) : (
<>
{renderMessageDetail()}
{activeTab === "approvals" && selectedApproval && renderDetailPane()}
{showComposer && (
<MessageComposer
recipient={composeRecipient}
replyContext={composeReplyContext}
agents={agents}
projectId={projectId}
onSend={handleMessageSent}
onCancel={handleComposeCancel}
addToast={addToast}
/>
)}
{!selectedMessage && !selectedApproval && !showComposer && renderListPane()}
</>
)}
</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>
);
}