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:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user