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