feat(FN-4109): add aggregate all-agents view to mailbox modal

Adds aggregate mailbox functionality to the MailboxModal, including the plumbing for aggregating participants across all agents, an "all agents" view mode, and corresponding tests. The feature is documented with participant aggregation logic.

Fusion-Task-Id: FN-4109

Fusion-Task-Lineage: 122b19cd-2b14-4f50-836b-e524d3b96a9c
This commit is contained in:
Fusion
2026-05-12 16:29:16 -07:00
committed by gsxdsm
parent 6bc3fa03c2
commit cb2d24f226
3 changed files with 130 additions and 32 deletions

View File

@@ -21,6 +21,7 @@ import {
fetchOutbox,
fetchUnreadCount,
fetchAgentMailbox,
fetchAllAgentMailbox,
markMessageRead,
markAllMessagesRead,
deleteMessage,
@@ -29,6 +30,7 @@ import {
type InboxResponse,
type OutboxResponse,
type AgentMailboxResponse,
type AllAgentsMailboxResponse,
} from "../api";
import { MessageComposer } from "./MessageComposer";
import { MailboxMessageContent } from "./MailboxMessageContent";
@@ -42,6 +44,8 @@ import { subscribeSse } from "../sse-bus";
type MailboxTab = "inbox" | "outbox" | "agents";
const ALL_AGENTS_MAILBOX_ID = "__all_agents__";
interface MailboxModalProps {
isOpen: boolean;
onClose: () => void;
@@ -161,9 +165,10 @@ export function MailboxModal({
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 | 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 [replyContextExpanded, setReplyContextExpanded] = useState<Record<string, boolean>>({});
const [replyContextLoading, setReplyContextLoading] = useState<Record<string, boolean>>({});
const [replyContextErrors, setReplyContextErrors] = useState<Record<string, string>>({});
@@ -234,6 +239,18 @@ export function MailboxModal({
}
}, [projectId]);
const loadAllAgentsMailbox = useCallback(async () => {
setIsLoading(true);
try {
const data = await fetchAllAgentMailbox(projectId);
setAllAgentsMailbox(data);
} catch {
// Silently fail
} finally {
setIsLoading(false);
}
}, [projectId]);
const refreshUnreadCount = useCallback(async () => {
try {
const data = await fetchUnreadCount(projectId);
@@ -252,9 +269,13 @@ export function MailboxModal({
// Load agent mailbox when selected
useEffect(() => {
if (!isOpen || !selectedAgentId) return;
loadAgentMailbox(selectedAgentId);
}, [isOpen, selectedAgentId, loadAgentMailbox]);
if (!isOpen) return;
if (selectedAgentId === ALL_AGENTS_MAILBOX_ID) {
void loadAllAgentsMailbox();
return;
}
void loadAgentMailbox(selectedAgentId);
}, [isOpen, selectedAgentId, loadAgentMailbox, loadAllAgentsMailbox]);
// Refresh unread count on open
useEffect(() => {
@@ -277,7 +298,9 @@ export function MailboxModal({
void loadOutbox();
}
if (selectedAgentId) {
if (selectedAgentId === ALL_AGENTS_MAILBOX_ID) {
void loadAllAgentsMailbox();
} else {
void loadAgentMailbox(selectedAgentId);
}
};
@@ -290,7 +313,7 @@ export function MailboxModal({
"message:deleted": onMailboxUpdate,
},
});
}, [isOpen, projectId, activeTab, selectedAgentId, refreshUnreadCount, loadInbox, loadOutbox, loadAgentMailbox]);
}, [isOpen, projectId, activeTab, selectedAgentId, refreshUnreadCount, loadInbox, loadOutbox, loadAgentMailbox, loadAllAgentsMailbox]);
// ── Actions ───────────────────────────────────────────────────────────
@@ -345,6 +368,7 @@ export function MailboxModal({
...(outbox?.messages ?? []),
...(agentMailbox?.inbox ?? []),
...(agentMailbox?.outbox ?? []),
...(allAgentsMailbox?.messages ?? []),
...conversationMessages,
].find((candidate) => candidate.id === deepLinkedMessageId);
@@ -353,7 +377,7 @@ export function MailboxModal({
}
void handleOpenMessage(message);
}, [isOpen, inbox, outbox, agentMailbox, conversationMessages, handleOpenMessage]);
}, [isOpen, inbox, outbox, agentMailbox, allAgentsMailbox, conversationMessages, handleOpenMessage]);
useEffect(() => {
if (!isOpen) {
@@ -416,12 +440,13 @@ export function MailboxModal({
// 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, addToast]);
}, [projectId, activeTab, selectedAgentId, loadInbox, loadOutbox, loadAgentMailbox, loadAllAgentsMailbox, addToast]);
const handleReply = useCallback((message: Message) => {
setComposeRecipient({ id: message.fromId, type: message.fromType });
@@ -439,12 +464,13 @@ export function MailboxModal({
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);
}, [activeTab, loadOutbox, selectedAgentId, loadAgentMailbox, addToast]);
}, [activeTab, loadOutbox, selectedAgentId, loadAgentMailbox, loadAllAgentsMailbox, addToast]);
const handleOpenCompose = useCallback(() => {
// Pre-fill recipient from selected agent if available
if (activeTab === "agents" && selectedAgentId) {
if (activeTab === "agents" && selectedAgentId && selectedAgentId !== ALL_AGENTS_MAILBOX_ID) {
setComposeRecipient({ id: selectedAgentId, type: "agent" });
} else {
setComposeRecipient(null);
@@ -621,6 +647,7 @@ export function MailboxModal({
onClick={() => {
if (activeTab === "inbox") loadInbox();
else if (activeTab === "outbox") loadOutbox();
else if (selectedAgentId === ALL_AGENTS_MAILBOX_ID) loadAllAgentsMailbox();
else if (selectedAgentId) loadAgentMailbox(selectedAgentId);
}}
disabled={isLoading}
@@ -885,11 +912,11 @@ export function MailboxModal({
<div className="mailbox-agents-dropdown">
<select
className="message-composer-select mailbox-agent-select"
value={selectedAgentId ?? ""}
onChange={(e) => { setSelectedAgentId(e.target.value || null); setAgentSubTab("inbox"); }}
value={selectedAgentId}
onChange={(e) => { setSelectedAgentId(e.target.value); setAgentSubTab("inbox"); }}
data-testid="mailbox-agent-select"
>
<option value="">Select an agent</option>
<option value={ALL_AGENTS_MAILBOX_ID}>All agents</option>
{agents.map((agent) => (
<option key={agent.id} value={agent.id}>
{agent.name || agent.id}
@@ -908,7 +935,7 @@ export function MailboxModal({
</div>
{/* Agent Sub-Tabs (Inbox/Outbox) */}
{selectedAgentId && (
{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" : ""}`}
@@ -932,26 +959,54 @@ export function MailboxModal({
</div>
)}
<div className="mailbox-agents-content">
{!selectedAgentId && (
{selectedAgentId === ALL_AGENTS_MAILBOX_ID && isLoading && !allAgentsMailbox && <MailboxSkeleton />}
{selectedAgentId === ALL_AGENTS_MAILBOX_ID && allAgentsMailbox && allAgentsMailbox.messages.length === 0 && (
<div className="mailbox-empty">
<Bot size={32} />
<p>Select an agent to view their mailbox</p>
<InboxIcon size={32} />
<p>No agent-to-agent messages</p>
</div>
)}
{selectedAgentId && isLoading && !agentMailbox && <MailboxSkeleton />}
{selectedAgentId && agentMailbox && agentSubTab === "inbox" && agentMailbox.inbox.length === 0 && (
{selectedAgentId === ALL_AGENTS_MAILBOX_ID && allAgentsMailbox && allAgentsMailbox.messages.map((msg) => (
<div
key={msg.id}
id={`message-${msg.id}`}
className={`mailbox-item ${!msg.read ? "unread" : ""}`}
onClick={() => handleOpenMessage(msg)}
data-testid={`mailbox-item-${msg.id}`}
>
<div className="mailbox-item-avatar">
{msg.fromType === "agent" ? <Bot size={16} /> : <User size={16} />}
</div>
<div className="mailbox-item-content">
<div className="mailbox-item-header">
<span className="mailbox-item-from">
{participantLabel(msg.fromId, msg.fromType, agentNamesById)}
</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: {participantLabel(msg.fromId, msg.fromType, agentNamesById)}</span>
<span>To: {participantLabel(msg.toId, msg.toType, agentNamesById)}</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>
))}
{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 && agentMailbox && agentSubTab === "outbox" && agentMailbox.outbox.length === 0 && (
{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 && agentMailbox && agentSubTab === "inbox" && agentMailbox.inbox.map((msg) => (
{selectedAgentId && selectedAgentId !== ALL_AGENTS_MAILBOX_ID && agentMailbox && agentSubTab === "inbox" && agentMailbox.inbox.map((msg) => (
<div
key={msg.id}
id={`message-${msg.id}`}
@@ -973,7 +1028,7 @@ export function MailboxModal({
</div>
</div>
))}
{selectedAgentId && agentMailbox && agentSubTab === "outbox" && agentMailbox.outbox.map((msg) => (
{selectedAgentId && selectedAgentId !== ALL_AGENTS_MAILBOX_ID && agentMailbox && agentSubTab === "outbox" && agentMailbox.outbox.map((msg) => (
<div
key={msg.id}
id={`message-${msg.id}`}

View File

@@ -13,6 +13,7 @@ vi.mock("../../api", () => ({
fetchOutbox: vi.fn(),
fetchUnreadCount: vi.fn(),
fetchAgentMailbox: vi.fn(),
fetchAllAgentMailbox: vi.fn(),
markMessageRead: vi.fn(),
markAllMessagesRead: vi.fn(),
deleteMessage: vi.fn(),
@@ -54,6 +55,7 @@ const mockFetchInbox = vi.mocked(apiModule.fetchInbox);
const mockFetchOutbox = vi.mocked(apiModule.fetchOutbox);
const mockFetchUnreadCount = vi.mocked(apiModule.fetchUnreadCount);
const mockFetchAgentMailbox = vi.mocked(apiModule.fetchAgentMailbox);
const mockFetchAllAgentMailbox = vi.mocked(apiModule.fetchAllAgentMailbox);
const mockMarkMessageRead = vi.mocked(apiModule.markMessageRead);
const mockMarkAllMessagesRead = vi.mocked(apiModule.markAllMessagesRead);
const mockDeleteMessage = vi.mocked(apiModule.deleteMessage);
@@ -136,6 +138,7 @@ describe("MailboxModal", () => {
mockFetchInbox.mockResolvedValue({ messages: [mockMessage, mockReadMessage], total: 2, unreadCount: 1 });
mockFetchOutbox.mockResolvedValue({ messages: [], total: 0 });
mockFetchUnreadCount.mockResolvedValue({ unreadCount: 1 });
mockFetchAllAgentMailbox.mockResolvedValue({ messages: [], total: 0, unreadCount: 0 });
mockFetchConversation.mockResolvedValue([mockMessage]);
mockFetchMessage.mockResolvedValue(mockMessage);
mockMarkMessageRead.mockResolvedValue({ ...mockMessage, read: true });
@@ -289,24 +292,64 @@ describe("MailboxModal", () => {
await waitFor(() => {
expect(screen.getByTestId("mailbox-agent-select")).toBeDefined();
});
// Should have placeholder plus two agent options
const select = screen.getByTestId("mailbox-agent-select") as HTMLSelectElement;
expect(select.options.length).toBe(3); // placeholder + 2 agents
expect(select.options[0].textContent).toBe("Select an agent");
expect(select.options.length).toBe(3);
expect(select.options[0].value).toBe("__all_agents__");
expect(select.options[0].textContent).toBe("All agents");
expect(select.options[1].textContent).toBe("Test Agent 1");
expect(select.options[2].textContent).toBe("Test Agent 2");
});
it("shows Select an agent… placeholder in dropdown", async () => {
it("defaults the agent dropdown to All agents with no empty placeholder", async () => {
render(<MailboxModal {...defaultProps} />);
fireEvent.click(screen.getByTestId("mailbox-tab-agents"));
await waitFor(() => {
const select = screen.getByTestId("mailbox-agent-select") as HTMLSelectElement;
expect(select.value).toBe("");
expect(select.options[0].textContent).toBe("Select an agent");
expect(select.value).toBe("__all_agents__");
expect(select.options[0].value).toBe("__all_agents__");
expect(select.options[0].textContent).toBe("All agents");
expect(Array.from(select.options).some((option) => option.value === "")).toBe(false);
});
});
it("FN-4109 defaults the Agents tab to All agents and loads the aggregate mailbox", async () => {
mockFetchAllAgentMailbox.mockResolvedValue({
messages: [
{
id: "msg-agent-thread",
fromId: "agent-001",
fromType: "agent",
toId: "agent-002",
toType: "agent",
content: "Coordinator handoff update for the mailbox modal.",
type: "agent-to-agent",
read: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
],
total: 1,
unreadCount: 1,
});
render(<MailboxModal {...defaultProps} />);
fireEvent.click(screen.getByTestId("mailbox-tab-agents"));
await waitFor(() => {
const select = screen.getByTestId("mailbox-agent-select") as HTMLSelectElement;
expect(select.value).toBe("__all_agents__");
expect(select.options[0].value).toBe("__all_agents__");
expect(select.options[0].textContent).toBe("All agents");
expect(mockFetchAllAgentMailbox).toHaveBeenCalledWith(undefined);
expect(screen.getByTestId("mailbox-item-msg-agent-thread")).toBeDefined();
});
expect(screen.getByTestId("mailbox-item-participants-msg-agent-thread").textContent).toContain("From: Agent: Test Agent 1");
expect(screen.getByTestId("mailbox-item-participants-msg-agent-thread").textContent).toContain("To: Agent: Test Agent 2");
expect(screen.queryByText("No agent-to-agent messages")).toBeNull();
expect(screen.queryByTestId("mailbox-agent-subtabs")).toBeNull();
});
it("loads agent mailbox when selecting an agent from dropdown", async () => {
mockFetchAgentMailbox.mockResolvedValue({
ownerId: "agent-001",
@@ -742,7 +785,7 @@ describe("MailboxModal", () => {
expect(agentsComposeButton).toHaveClass("btn", "btn-sm", "btn-secondary", "mailbox-compose-btn");
});
it("compose opened from Agents tab without selected agent shows recipient select", async () => {
it("compose opened from Agents tab with All agents selected shows recipient select", async () => {
render(<MailboxModal {...defaultProps} />);
fireEvent.click(screen.getByTestId("mailbox-tab-agents"));
await waitFor(() => {
@@ -1217,8 +1260,8 @@ describe("MailboxModal", () => {
});
it("light theme overrides new tokens", () => {
// Find the base [data-theme="light"] block (not combined with other selectors)
const lightBlockMatch = css.match(/^\[data-theme="light"\]\s*\{[\s\S]*?^\}\s*$/m);
// Match the root-scoped light theme token block used by the app stylesheet.
const lightBlockMatch = css.match(/^:root\[data-theme="light"\]\s*\{[\s\S]*?^\}\s*$/m);
expect(lightBlockMatch).toBeTruthy();
const lightContent = lightBlockMatch![0];
expect(lightContent).toContain("--terminal-bg");