feat(FN-2494): add responsive split-pane mailbox view

- Render mailbox in split-pane mode with message list and detail panes on desktop layouts
- Update mailbox modal styling for desktop pane sizing, separators, and responsive behavior
- Preserve single-pane mobile mailbox flow while adapting interactions for split view states
- Expand MailboxView tests to cover split-pane rendering and responsive mailbox behavior
This commit is contained in:
Fusion
2026-04-25 11:10:10 -07:00
committed by gsxdsm
parent 54bc60bb8a
commit b19b0005ef
3 changed files with 534 additions and 326 deletions

View File

@@ -492,18 +492,62 @@
}
.mailbox-view .mailbox-header {
padding: var(--space-lg) 20px;
padding: var(--space-lg) var(--space-xl);
border-bottom: 1px solid var(--border);
background: var(--surface);
}
.mailbox-view .mailbox-content {
flex: 1;
overflow-y: auto;
padding: 20px;
overflow: hidden;
padding: var(--space-xl);
max-height: none;
}
.mailbox-view .mailbox-split-layout {
display: grid;
grid-template-columns: minmax(280px, 38%) minmax(0, 1fr);
gap: var(--space-lg);
height: 100%;
min-height: 0;
}
.mailbox-view .mailbox-split-list-pane,
.mailbox-view .mailbox-split-detail-pane {
min-height: 0;
overflow-y: auto;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--surface);
padding: var(--space-md);
}
.mailbox-view .mailbox-split-detail-pane {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.mailbox-view .mailbox-split-empty {
display: flex;
flex: 1;
min-height: 0;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-md);
padding: var(--space-xl);
border: 1px dashed color-mix(in srgb, var(--text-muted) 45%, transparent);
border-radius: var(--radius-md);
color: var(--text-muted);
background: color-mix(in srgb, var(--surface) 80%, transparent);
text-align: center;
}
.mailbox-view .mailbox-split-empty p {
margin: 0;
}
/* ── Mailbox — Mobile (≤ 768px) ───────────────────────────────────── */
@media (max-width: 768px) {
@@ -616,11 +660,26 @@
.mailbox-view .mailbox-content {
max-height: none;
overflow-y: auto;
padding: var(--space-md);
/* Account for mobile nav bar at bottom */
padding-bottom: calc(var(--mobile-nav-height) + env(safe-area-inset-bottom, 0px) + var(--standalone-bottom-gap) + var(--space-lg));
}
.mailbox-view .mailbox-split-layout {
display: block;
height: auto;
}
.mailbox-view .mailbox-split-list-pane,
.mailbox-view .mailbox-split-detail-pane {
border: none;
border-radius: 0;
background: transparent;
padding: 0;
overflow: visible;
}
.mailbox-view .mailbox-message-detail-header {
flex-direction: column;
align-items: flex-start;

View File

@@ -30,6 +30,7 @@ import {
} from "../api";
import { MessageComposer } from "./MessageComposer";
import { subscribeSse } from "../sse-bus";
import { useViewportMode } from "../hooks/useViewportMode";
// ── Types ─────────────────────────────────────────────────────────────────
@@ -169,6 +170,9 @@ export function MailboxView({
(id: string, type: ParticipantType) => participantLabel(id, type, agentNamesById),
[agentNamesById],
);
const viewportMode = useViewportMode();
const isMobile = viewportMode === "mobile";
const isSplitPane = !isMobile;
// ── Data fetching ─────────────────────────────────────────────────────
@@ -398,6 +402,344 @@ export function MailboxView({
// ── Render ────────────────────────────────────────────────────────────
const renderMessageDetail = () => {
if (!selectedMessage || showComposer) return null;
return (
<div className="mailbox-message-detail" data-testid="mailbox-message-detail">
<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>
{conversationMessages.length > 1 && (
<div className="mailbox-conversation" data-testid="mailbox-conversation">
<div className="mailbox-conversation-label">Conversation</div>
{conversationMessages.map((msg) => {
const replyToId = msg.metadata?.replyTo?.messageId;
const replyToMessage = replyToId
? conversationMessages.find((candidate) => candidate.id === replyToId)
: undefined;
return (
<div
key={msg.id}
className={`mailbox-conversation-msg ${msg.id === selectedMessage.id ? "current" : ""}`}
>
<div className="mailbox-conversation-msg-header">
<span>{getParticipantLabel(msg.fromId, msg.fromType)}</span>
<span className="mailbox-message-time">{formatTimestamp(msg.createdAt)}</span>
</div>
{replyToId && (
<div className="mailbox-reply-context" data-testid={`mailbox-reply-context-${msg.id}`}>
Replying to {replyToMessage ? messagePreview(replyToMessage.content, 60) : `message ${replyToId}`}
</div>
)}
<div className="mailbox-conversation-msg-body">{msg.content}</div>
</div>
);
})}
</div>
)}
{(conversationMessages.length <= 1) && (
<>
{selectedMessage.metadata?.replyTo?.messageId && (
<div className="mailbox-reply-context" data-testid="mailbox-selected-reply-context">
Replying to message {selectedMessage.metadata.replyTo.messageId}
</div>
)}
<div className="mailbox-message-body" data-testid="mailbox-message-body">
{selectedMessage.content}
</div>
</>
)}
</div>
);
};
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 && inbox.messages.length > 0 && (
<div className="mailbox-conversations" data-testid="mailbox-conversations">
{groupMessagesByConversation(inbox.messages).map((group) => (
<div
key={group.key}
className={`mailbox-conversation-group ${group.unreadCount > 0 ? "unread" : ""}`}
onClick={() => handleOpenMessage(group.latestMessage)}
data-testid={`mailbox-conversation-${group.key}`}
>
<div className="mailbox-item-avatar">
{group.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(group.fromId, group.fromType)}
</span>
<span className="mailbox-item-time">
{formatTimestamp(group.latestMessage.createdAt)}
</span>
</div>
<div className="mailbox-item-preview">
{group.latestMessage.content.slice(0, 80)}
{group.latestMessage.content.length > 80 ? "…" : ""}
</div>
</div>
{group.unreadCount > 0 && (
<div className="mailbox-group-unread-badge" data-testid={`mailbox-unread-badge-${group.key}`}>
{group.unreadCount > 9 ? "9+" : group.unreadCount}
</div>
)}
</div>
))}
</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}
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 === "agents" && (
<div className="mailbox-agents" data-testid="mailbox-agents">
{agents.length === 0 ? (
<div className="mailbox-empty">
<Bot size={32} />
<p>No agents found</p>
</div>
) : (
<>
<div className="mailbox-agents-header">
<div className="mailbox-agents-dropdown">
<select
className="message-composer-select mailbox-agent-select"
value={selectedAgentId ?? ""}
onChange={(e) => { setSelectedAgentId(e.target.value || null); setAgentSubTab("inbox"); }}
data-testid="mailbox-agent-select"
>
<option value="">Select an agent</option>
{agents.map((agent) => (
<option key={agent.id} value={agent.id}>
{agent.name || agent.id}
</option>
))}
</select>
</div>
<button
className="btn btn-sm btn-secondary mailbox-compose-btn"
onClick={handleOpenCompose}
data-testid="mailbox-compose-btn"
>
<MessageSquare size={14} />
<span>Compose</span>
</button>
</div>
{selectedAgentId && (
<div className="mailbox-agent-subtabs" data-testid="mailbox-agent-subtabs">
<button
className={`btn btn-sm btn-secondary mailbox-agent-subtab ${agentSubTab === "inbox" ? "active" : ""}`}
onClick={() => setAgentSubTab("inbox")}
data-testid="mailbox-agent-subtab-inbox"
>
<InboxIcon size={12} />
<span>Inbox</span>
{agentMailbox && agentMailbox.unreadCount > 0 && (
<span className="mailbox-tab-badge">{agentMailbox.unreadCount}</span>
)}
</button>
<button
className={`btn btn-sm btn-secondary mailbox-agent-subtab ${agentSubTab === "outbox" ? "active" : ""}`}
onClick={() => setAgentSubTab("outbox")}
data-testid="mailbox-agent-subtab-outbox"
>
<Send size={12} />
<span>Outbox</span>
</button>
</div>
)}
<div className="mailbox-agents-content">
{!selectedAgentId && (
<div className="mailbox-empty">
<Bot size={32} />
<p>Select an agent to view their mailbox</p>
</div>
)}
{selectedAgentId && isLoading && !agentMailbox && <MailboxSkeleton />}
{selectedAgentId && agentMailbox && agentSubTab === "inbox" && agentMailbox.inbox.length === 0 && (
<div className="mailbox-empty">
<InboxIcon size={32} />
<p>No received messages for this agent</p>
</div>
)}
{selectedAgentId && agentMailbox && agentSubTab === "outbox" && agentMailbox.outbox.length === 0 && (
<div className="mailbox-empty">
<Send size={32} />
<p>No sent messages for this agent</p>
</div>
)}
{selectedAgentId && agentMailbox && agentSubTab === "inbox" && agentMailbox.inbox.map((msg) => (
<div
key={msg.id}
className={`mailbox-item ${!msg.read ? "unread" : ""}`}
onClick={() => handleOpenMessage(msg)}
data-testid={`mailbox-item-${msg.id}`}
>
<div className="mailbox-item-avatar">
{msg.fromType === "agent" ? <Bot size={16} /> : <User size={16} />}
</div>
<div className="mailbox-item-content">
<div className="mailbox-item-header">
<span className="mailbox-item-from">
{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 && agentMailbox && agentSubTab === "outbox" && agentMailbox.outbox.map((msg) => (
<div
key={msg.id}
className="mailbox-item"
onClick={() => handleOpenMessage(msg)}
data-testid={`mailbox-item-${msg.id}`}
>
<div className="mailbox-item-avatar">
{msg.toType === "agent" ? <Bot size={16} /> : <User size={16} />}
</div>
<div className="mailbox-item-content">
<div className="mailbox-item-header">
<span className="mailbox-item-to">
To: {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();
}
return (
<div className="mailbox-split-empty" data-testid="mailbox-split-empty">
<Mail size={24} />
<p>Select a conversation to read messages</p>
</div>
);
};
return (
<div className="mailbox-view" data-testid="mailbox-view">
{/* Header */}
@@ -477,335 +819,34 @@ export function MailboxView({
</button>
</div>
{/* Content */}
<div className="mailbox-content" data-testid="mailbox-content">
{/* Message Detail View */}
{selectedMessage && !showComposer && (
<div className="mailbox-message-detail" data-testid="mailbox-message-detail">
<div className="mailbox-message-detail-header">
<button
className="btn btn-sm btn-secondary"
onClick={handleCloseMessage}
data-testid="mailbox-back-to-list"
>
Back
</button>
<div className="mailbox-message-detail-meta">
<span className="mailbox-message-type">{messageTypeLabel(selectedMessage.type)}</span>
<span className="mailbox-message-time">{formatTimestamp(selectedMessage.createdAt)}</span>
</div>
<div className="mailbox-message-detail-actions">
{selectedMessage.fromType === "agent" && (
<button
className="btn btn-sm btn-secondary"
onClick={() => handleReply(selectedMessage)}
data-testid="mailbox-reply"
>
<MessageSquare size={14} />
<span>Reply</span>
</button>
)}
<button
className="btn btn-sm btn-secondary"
onClick={() => handleDeleteMessage(selectedMessage.id)}
data-testid="mailbox-delete"
>
<Trash2 size={14} />
<span>Delete</span>
</button>
</div>
{isSplitPane ? (
<div className="mailbox-split-layout" data-testid="mailbox-split-layout">
<div className="mailbox-split-list-pane" data-testid="mailbox-split-list-pane">
{renderListPane()}
</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 className="mailbox-split-detail-pane" data-testid="mailbox-split-detail-pane">
{renderDetailPane()}
</div>
{/* Conversation thread */}
{conversationMessages.length > 1 && (
<div className="mailbox-conversation" data-testid="mailbox-conversation">
<div className="mailbox-conversation-label">Conversation</div>
{conversationMessages.map((msg) => {
const replyToId = msg.metadata?.replyTo?.messageId;
const replyToMessage = replyToId
? conversationMessages.find((candidate) => candidate.id === replyToId)
: undefined;
return (
<div
key={msg.id}
className={`mailbox-conversation-msg ${msg.id === selectedMessage.id ? "current" : ""}`}
>
<div className="mailbox-conversation-msg-header">
<span>{getParticipantLabel(msg.fromId, msg.fromType)}</span>
<span className="mailbox-message-time">{formatTimestamp(msg.createdAt)}</span>
</div>
{replyToId && (
<div className="mailbox-reply-context" data-testid={`mailbox-reply-context-${msg.id}`}>
Replying to {replyToMessage ? messagePreview(replyToMessage.content, 60) : `message ${replyToId}`}
</div>
)}
<div className="mailbox-conversation-msg-body">{msg.content}</div>
</div>
);
})}
</div>
)}
{/* Full message content */}
{(conversationMessages.length <= 1) && (
<>
{selectedMessage.metadata?.replyTo?.messageId && (
<div className="mailbox-reply-context" data-testid="mailbox-selected-reply-context">
Replying to message {selectedMessage.metadata.replyTo.messageId}
</div>
)}
<div className="mailbox-message-body" data-testid="mailbox-message-body">
{selectedMessage.content}
</div>
</>
)}
</div>
)}
{/* Message Composer */}
{showComposer && (
<MessageComposer
recipient={composeRecipient}
replyContext={composeReplyContext}
agents={agents}
projectId={projectId}
onSend={handleMessageSent}
onCancel={handleComposeCancel}
addToast={addToast}
/>
)}
{/* Tab Content — message lists */}
{!selectedMessage && !showComposer && (
) : (
<>
{/* Inbox Tab - Grouped by conversation */}
{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 && inbox.messages.length > 0 && (
<div className="mailbox-conversations" data-testid="mailbox-conversations">
{groupMessagesByConversation(inbox.messages).map((group) => (
<div
key={group.key}
className={`mailbox-conversation-group ${group.unreadCount > 0 ? "unread" : ""}`}
onClick={() => handleOpenMessage(group.latestMessage)}
data-testid={`mailbox-conversation-${group.key}`}
>
<div className="mailbox-item-avatar">
{group.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(group.fromId, group.fromType)}
</span>
<span className="mailbox-item-time">
{formatTimestamp(group.latestMessage.createdAt)}
</span>
</div>
<div className="mailbox-item-preview">
{group.latestMessage.content.slice(0, 80)}
{group.latestMessage.content.length > 80 ? "…" : ""}
</div>
</div>
{group.unreadCount > 0 && (
<div className="mailbox-group-unread-badge" data-testid={`mailbox-unread-badge-${group.key}`}>
{group.unreadCount > 9 ? "9+" : group.unreadCount}
</div>
)}
</div>
))}
</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: {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>
)}
{/* Agent Mailboxes Tab */}
{activeTab === "agents" && (
<div className="mailbox-agents" data-testid="mailbox-agents">
{agents.length === 0 ? (
<div className="mailbox-empty">
<Bot size={32} />
<p>No agents found</p>
</div>
) : (
<>
<div className="mailbox-agents-header">
<div className="mailbox-agents-dropdown">
<select
className="message-composer-select mailbox-agent-select"
value={selectedAgentId ?? ""}
onChange={(e) => { setSelectedAgentId(e.target.value || null); setAgentSubTab("inbox"); }}
data-testid="mailbox-agent-select"
>
<option value="">Select an agent</option>
{agents.map((agent) => (
<option key={agent.id} value={agent.id}>
{agent.name || agent.id}
</option>
))}
</select>
</div>
<button
className="btn btn-sm btn-secondary mailbox-compose-btn"
onClick={handleOpenCompose}
data-testid="mailbox-compose-btn"
>
<MessageSquare size={14} />
<span>Compose</span>
</button>
</div>
{/* Agent Sub-Tabs (Inbox/Outbox) */}
{selectedAgentId && (
<div className="mailbox-agent-subtabs" data-testid="mailbox-agent-subtabs">
<button
className={`btn btn-sm btn-secondary mailbox-agent-subtab ${agentSubTab === "inbox" ? "active" : ""}`}
onClick={() => setAgentSubTab("inbox")}
data-testid="mailbox-agent-subtab-inbox"
>
<InboxIcon size={12} />
<span>Inbox</span>
{agentMailbox && agentMailbox.unreadCount > 0 && (
<span className="mailbox-tab-badge">{agentMailbox.unreadCount}</span>
)}
</button>
<button
className={`btn btn-sm btn-secondary mailbox-agent-subtab ${agentSubTab === "outbox" ? "active" : ""}`}
onClick={() => setAgentSubTab("outbox")}
data-testid="mailbox-agent-subtab-outbox"
>
<Send size={12} />
<span>Outbox</span>
</button>
</div>
)}
<div className="mailbox-agents-content">
{!selectedAgentId && (
<div className="mailbox-empty">
<Bot size={32} />
<p>Select an agent to view their mailbox</p>
</div>
)}
{selectedAgentId && isLoading && !agentMailbox && <MailboxSkeleton />}
{selectedAgentId && agentMailbox && agentSubTab === "inbox" && agentMailbox.inbox.length === 0 && (
<div className="mailbox-empty">
<InboxIcon size={32} />
<p>No received messages for this agent</p>
</div>
)}
{selectedAgentId && agentMailbox && agentSubTab === "outbox" && agentMailbox.outbox.length === 0 && (
<div className="mailbox-empty">
<Send size={32} />
<p>No sent messages for this agent</p>
</div>
)}
{selectedAgentId && agentMailbox && agentSubTab === "inbox" && agentMailbox.inbox.map((msg) => (
<div
key={msg.id}
className={`mailbox-item ${!msg.read ? "unread" : ""}`}
onClick={() => handleOpenMessage(msg)}
data-testid={`mailbox-item-${msg.id}`}
>
<div className="mailbox-item-avatar">
{msg.fromType === "agent" ? <Bot size={16} /> : <User size={16} />}
</div>
<div className="mailbox-item-content">
<div className="mailbox-item-header">
<span className="mailbox-item-from">
{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 && agentMailbox && agentSubTab === "outbox" && agentMailbox.outbox.map((msg) => (
<div
key={msg.id}
className="mailbox-item"
onClick={() => handleOpenMessage(msg)}
data-testid={`mailbox-item-${msg.id}`}
>
<div className="mailbox-item-avatar">
{msg.toType === "agent" ? <Bot size={16} /> : <User size={16} />}
</div>
<div className="mailbox-item-content">
<div className="mailbox-item-header">
<span className="mailbox-item-to">
To: {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>
{renderMessageDetail()}
{showComposer && (
<MessageComposer
recipient={composeRecipient}
replyContext={composeReplyContext}
agents={agents}
projectId={projectId}
onSend={handleMessageSent}
onCancel={handleComposeCancel}
addToast={addToast}
/>
)}
{!selectedMessage && !showComposer && renderListPane()}
</>
)}
</div>
</div>
);
}

View File

@@ -3,6 +3,7 @@ import { loadAllAppCss } from "../../test/cssFixture";
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
import { MailboxView } from "../MailboxView";
import * as apiModule from "../../api";
import * as viewportModule from "../../hooks/useViewportMode";
import type { Agent } from "../../api";
import type { Message } from "@fusion/core";
@@ -20,6 +21,10 @@ vi.mock("../../api", () => ({
fetchAgents: vi.fn(),
}));
vi.mock("../../hooks/useViewportMode", () => ({
useViewportMode: vi.fn(),
}));
// Mock lucide-react icons
vi.mock("lucide-react", () => ({
X: () => <span data-testid="icon-x">X</span>,
@@ -49,6 +54,7 @@ const mockMarkAllMessagesRead = vi.mocked(apiModule.markAllMessagesRead);
const mockDeleteMessage = vi.mocked(apiModule.deleteMessage);
const mockFetchConversation = vi.mocked(apiModule.fetchConversation);
const mockSendMessage = vi.mocked(apiModule.sendMessage);
const mockUseViewportMode = vi.mocked(viewportModule.useViewportMode);
const mockAgents: Agent[] = [
{
@@ -131,6 +137,7 @@ const defaultProps = {
describe("MailboxView", () => {
beforeEach(() => {
vi.clearAllMocks();
mockUseViewportMode.mockReturnValue("desktop");
mockFetchUnreadCount.mockResolvedValue({ unreadCount: 2 });
mockFetchAgents.mockResolvedValue(mockAgents);
mockSendMessage.mockResolvedValue({ ...mockMessage, id: "msg-sent" });
@@ -343,6 +350,82 @@ describe("MailboxView", () => {
});
});
it("keeps list pane visible alongside detail pane on desktop/tablet", async () => {
mockFetchInbox.mockResolvedValue({
messages: [mockMessage],
unreadCount: 1,
});
mockFetchConversation.mockResolvedValue([mockMessage]);
mockMarkMessageRead.mockResolvedValue(undefined);
render(<MailboxView {...defaultProps} />);
await waitFor(() => {
expect(screen.getByTestId("mailbox-split-layout")).toBeDefined();
expect(screen.getByTestId("mailbox-inbox-list")).toBeDefined();
});
await act(async () => {
fireEvent.click(screen.getByTestId("mailbox-conversation-agent:agent-001"));
});
await waitFor(() => {
expect(screen.getByTestId("mailbox-message-detail")).toBeDefined();
expect(screen.getByTestId("mailbox-inbox-list")).toBeDefined();
expect(screen.queryByTestId("mailbox-back-to-list")).toBeNull();
});
});
it("shows split-pane empty state when no message is selected on desktop/tablet", async () => {
mockFetchInbox.mockResolvedValue({
messages: [mockMessage],
unreadCount: 1,
});
render(<MailboxView {...defaultProps} />);
await waitFor(() => {
expect(screen.getByTestId("mailbox-split-empty")).toBeDefined();
expect(screen.getByTestId("mailbox-inbox-list")).toBeDefined();
});
});
it("keeps mobile single-pane flow for detail open and back navigation", async () => {
mockUseViewportMode.mockReturnValue("mobile");
mockFetchInbox.mockResolvedValue({
messages: [mockMessage],
unreadCount: 1,
});
mockFetchConversation.mockResolvedValue([mockMessage]);
mockMarkMessageRead.mockResolvedValue(undefined);
render(<MailboxView {...defaultProps} />);
await waitFor(() => {
expect(screen.queryByTestId("mailbox-split-layout")).toBeNull();
expect(screen.getByTestId("mailbox-inbox-list")).toBeDefined();
});
await act(async () => {
fireEvent.click(screen.getByTestId("mailbox-conversation-agent:agent-001"));
});
await waitFor(() => {
expect(screen.getByTestId("mailbox-message-detail")).toBeDefined();
expect(screen.queryByTestId("mailbox-inbox-list")).toBeNull();
expect(screen.getByTestId("mailbox-back-to-list")).toBeDefined();
});
await act(async () => {
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 agent names in message detail participant rows", async () => {
mockFetchInbox.mockResolvedValue({
messages: [mockAgentToAgentMessage],
@@ -362,7 +445,7 @@ describe("MailboxView", () => {
});
await waitFor(() => {
expect(screen.getByText("Agent: Test Agent 1 (agent-001)")).toBeDefined();
expect(screen.getAllByText("Agent: Test Agent 1 (agent-001)").length).toBeGreaterThan(0);
expect(screen.getByText("Agent: Test Agent 2 (agent-002)")).toBeDefined();
});
});
@@ -440,9 +523,8 @@ describe("MailboxView", () => {
expect(screen.getByTestId("mailbox-message-detail")).toBeDefined();
});
const backToListButton = screen.getByTestId("mailbox-back-to-list");
const deleteButton = screen.getByTestId("mailbox-delete");
expect(backToListButton).toHaveClass("btn", "btn-sm", "btn-secondary");
expect(screen.queryByTestId("mailbox-back-to-list")).toBeNull();
expect(deleteButton).toHaveClass("btn", "btn-sm", "btn-secondary");
await act(async () => {
@@ -1042,6 +1124,29 @@ describe("MailboxView", () => {
expect(viewBlock).toContain("overflow: hidden;");
});
it("defines desktop/tablet split-pane selectors under .mailbox-view scope", async () => {
const css = loadAllAppCss();
const splitLayoutBlockMatch = css.match(/\.mailbox-view\s+\.mailbox-split-layout\s*\{([^}]*)\}/);
expect(splitLayoutBlockMatch).toBeTruthy();
const splitLayoutBlock = splitLayoutBlockMatch![1];
expect(splitLayoutBlock).toContain("display: grid;");
expect(splitLayoutBlock).toContain("min-height: 0;");
expect(splitLayoutBlock).toContain("grid-template-columns");
const splitPaneBlockMatch = css.match(/\.mailbox-view\s+\.mailbox-split-list-pane,\s*\n\.mailbox-view\s+\.mailbox-split-detail-pane\s*\{([^}]*)\}/);
expect(splitPaneBlockMatch).toBeTruthy();
const splitPaneBlock = splitPaneBlockMatch![1];
expect(splitPaneBlock).toContain("overflow-y: auto;");
expect(splitPaneBlock).toContain("border: 1px solid var(--border);");
expect(splitPaneBlock).toContain("background: var(--surface);");
const splitEmptyBlockMatch = css.match(/\.mailbox-view\s+\.mailbox-split-empty\s*\{([^}]*)\}/);
expect(splitEmptyBlockMatch).toBeTruthy();
expect(splitEmptyBlockMatch![1]).toContain("color: var(--text-muted);");
expect(splitEmptyBlockMatch![1]).toContain("color-mix");
});
it("keeps mobile .mailbox-view overrides in the dedicated media-query section", async () => {
const fs = await import("fs");
const path = await import("path");
@@ -1060,6 +1165,9 @@ describe("MailboxView", () => {
expect(mailboxMobileSection).toContain(".mailbox-view .mailbox-header");
expect(mailboxMobileSection).toContain(".mailbox-view .mailbox-tabs");
expect(mailboxMobileSection).toContain(".mailbox-view .mailbox-content");
expect(mailboxMobileSection).toContain(".mailbox-view .mailbox-split-layout");
expect(mailboxMobileSection).toContain(".mailbox-view .mailbox-split-list-pane");
expect(mailboxMobileSection).toContain(".mailbox-view .mailbox-split-detail-pane");
expect(mailboxMobileSection).toContain(".mailbox-view .mailbox-empty");
});