feat(FN-2333): merge fusion/fn-2333

This commit is contained in:
gsxdsm
2026-04-23 12:56:02 -07:00
parent d3c9476ce7
commit bf9930ac44
2 changed files with 138 additions and 12 deletions

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback, useMemo } from "react";
import { import {
Mail, Mail,
Send, Send,
@@ -73,9 +73,18 @@ function formatTimestamp(ts: string): string {
return date.toLocaleDateString(undefined, { month: "short", day: "numeric" }); return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
} }
function participantLabel(id: string, type: ParticipantType): string { function participantLabel(
id: string,
type: ParticipantType,
agentNamesById?: ReadonlyMap<string, string>,
): string {
if (type === "user") return id === "dashboard" ? "You" : `User: ${id}`; if (type === "user") return id === "dashboard" ? "You" : `User: ${id}`;
if (type === "agent") return `Agent: ${id}`; if (type === "agent") {
const name = agentNamesById?.get(id)?.trim();
if (!name) return `Agent: ${id}`;
if (name === id) return `Agent: ${id}`;
return `Agent: ${name} (${id})`;
}
return "System"; return "System";
} }
@@ -145,6 +154,15 @@ export function MailboxView({
const [agentMailbox, setAgentMailbox] = useState<AgentMailboxResponse | null>(null); const [agentMailbox, setAgentMailbox] = useState<AgentMailboxResponse | null>(null);
const [agents, setAgents] = useState<Agent[]>([]); const [agents, setAgents] = useState<Agent[]>([]);
const agentNamesById = useMemo(
() => new Map(agents.map((agent) => [agent.id, agent.name ?? ""])),
[agents],
);
const getParticipantLabel = useCallback(
(id: string, type: ParticipantType) => participantLabel(id, type, agentNamesById),
[agentNamesById],
);
// ── Data fetching ───────────────────────────────────────────────────── // ── Data fetching ─────────────────────────────────────────────────────
const loadInbox = useCallback(async () => { const loadInbox = useCallback(async () => {
@@ -488,14 +506,14 @@ export function MailboxView({
<span className="mailbox-participant-label">From:</span> <span className="mailbox-participant-label">From:</span>
<span className="mailbox-participant-value"> <span className="mailbox-participant-value">
{selectedMessage.fromType === "agent" ? <Bot size={14} /> : <User size={14} />} {selectedMessage.fromType === "agent" ? <Bot size={14} /> : <User size={14} />}
{participantLabel(selectedMessage.fromId, selectedMessage.fromType)} {getParticipantLabel(selectedMessage.fromId, selectedMessage.fromType)}
</span> </span>
</div> </div>
<div className="mailbox-participant"> <div className="mailbox-participant">
<span className="mailbox-participant-label">To:</span> <span className="mailbox-participant-label">To:</span>
<span className="mailbox-participant-value"> <span className="mailbox-participant-value">
{selectedMessage.toType === "agent" ? <Bot size={14} /> : <User size={14} />} {selectedMessage.toType === "agent" ? <Bot size={14} /> : <User size={14} />}
{participantLabel(selectedMessage.toId, selectedMessage.toType)} {getParticipantLabel(selectedMessage.toId, selectedMessage.toType)}
</span> </span>
</div> </div>
</div> </div>
@@ -509,7 +527,7 @@ export function MailboxView({
className={`mailbox-conversation-msg ${msg.id === selectedMessage.id ? "current" : ""}`} className={`mailbox-conversation-msg ${msg.id === selectedMessage.id ? "current" : ""}`}
> >
<div className="mailbox-conversation-msg-header"> <div className="mailbox-conversation-msg-header">
<span>{participantLabel(msg.fromId, msg.fromType)}</span> <span>{getParticipantLabel(msg.fromId, msg.fromType)}</span>
<span className="mailbox-message-time">{formatTimestamp(msg.createdAt)}</span> <span className="mailbox-message-time">{formatTimestamp(msg.createdAt)}</span>
</div> </div>
<div className="mailbox-conversation-msg-body">{msg.content}</div> <div className="mailbox-conversation-msg-body">{msg.content}</div>
@@ -566,7 +584,7 @@ export function MailboxView({
<div className="mailbox-item-content"> <div className="mailbox-item-content">
<div className="mailbox-item-header"> <div className="mailbox-item-header">
<span className="mailbox-item-from"> <span className="mailbox-item-from">
{participantLabel(group.fromId, group.fromType)} {getParticipantLabel(group.fromId, group.fromType)}
</span> </span>
<span className="mailbox-item-time"> <span className="mailbox-item-time">
{formatTimestamp(group.latestMessage.createdAt)} {formatTimestamp(group.latestMessage.createdAt)}
@@ -612,7 +630,7 @@ export function MailboxView({
<div className="mailbox-item-content"> <div className="mailbox-item-content">
<div className="mailbox-item-header"> <div className="mailbox-item-header">
<span className="mailbox-item-to"> <span className="mailbox-item-to">
To: {participantLabel(msg.toId, msg.toType)} To: {getParticipantLabel(msg.toId, msg.toType)}
</span> </span>
<span className="mailbox-item-time">{formatTimestamp(msg.createdAt)}</span> <span className="mailbox-item-time">{formatTimestamp(msg.createdAt)}</span>
</div> </div>
@@ -716,9 +734,7 @@ export function MailboxView({
<div className="mailbox-item-content"> <div className="mailbox-item-content">
<div className="mailbox-item-header"> <div className="mailbox-item-header">
<span className="mailbox-item-from"> <span className="mailbox-item-from">
{msg.fromType === "agent" {getParticipantLabel(msg.fromId, msg.fromType)}
? participantLabel(msg.toId, msg.toType)
: participantLabel(msg.fromId, msg.fromType)}
</span> </span>
<span className="mailbox-item-time">{formatTimestamp(msg.createdAt)}</span> <span className="mailbox-item-time">{formatTimestamp(msg.createdAt)}</span>
</div> </div>
@@ -739,7 +755,7 @@ export function MailboxView({
<div className="mailbox-item-content"> <div className="mailbox-item-content">
<div className="mailbox-item-header"> <div className="mailbox-item-header">
<span className="mailbox-item-to"> <span className="mailbox-item-to">
To: {participantLabel(msg.toId, msg.toType)} To: {getParticipantLabel(msg.toId, msg.toType)}
</span> </span>
<span className="mailbox-item-time">{formatTimestamp(msg.createdAt)}</span> <span className="mailbox-item-time">{formatTimestamp(msg.createdAt)}</span>
</div> </div>

View File

@@ -103,6 +103,25 @@ const mockOutboxMessage: Message = {
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}; };
const mockAgentToAgentMessage: Message = {
id: "msg-004",
fromId: "agent-001",
fromType: "agent",
toId: "agent-002",
toType: "agent",
content: "Agent to agent ping.",
type: "agent-to-agent",
read: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
const mockUnknownAgentMessage: Message = {
...mockMessage,
id: "msg-005",
fromId: "agent-999",
};
const defaultProps = { const defaultProps = {
addToast: vi.fn(), addToast: vi.fn(),
}; };
@@ -190,6 +209,32 @@ describe("MailboxView", () => {
}); });
}); });
it("renders known agent senders by name in inbox conversation rows", async () => {
mockFetchInbox.mockResolvedValue({
messages: [mockMessage],
unreadCount: 1,
});
render(<MailboxView {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText("Agent: Test Agent 1 (agent-001)")).toBeDefined();
});
});
it("falls back to stable agent identifier when agent metadata is missing", async () => {
mockFetchInbox.mockResolvedValue({
messages: [mockUnknownAgentMessage],
unreadCount: 1,
});
render(<MailboxView {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText("Agent: agent-999")).toBeDefined();
});
});
it("groups messages by sender and shows unread count per group", async () => { it("groups messages by sender and shows unread count per group", async () => {
const secondMessage = { ...mockMessage, id: "msg-003", read: false }; const secondMessage = { ...mockMessage, id: "msg-003", read: false };
mockFetchInbox.mockResolvedValue({ mockFetchInbox.mockResolvedValue({
@@ -295,6 +340,30 @@ describe("MailboxView", () => {
}); });
}); });
it("shows agent names in message detail participant rows", async () => {
mockFetchInbox.mockResolvedValue({
messages: [mockAgentToAgentMessage],
unreadCount: 1,
});
mockFetchConversation.mockResolvedValue([mockAgentToAgentMessage]);
mockMarkMessageRead.mockResolvedValue(undefined);
render(<MailboxView {...defaultProps} />);
await waitFor(() => {
expect(screen.getByTestId("mailbox-conversation-agent:agent-001")).toBeDefined();
});
await act(async () => {
fireEvent.click(screen.getByTestId("mailbox-conversation-agent:agent-001"));
});
await waitFor(() => {
expect(screen.getByText("Agent: Test Agent 1 (agent-001)")).toBeDefined();
expect(screen.getByText("Agent: Test Agent 2 (agent-002)")).toBeDefined();
});
});
it("marks message as read when opening unread message", async () => { it("marks message as read when opening unread message", async () => {
mockFetchInbox.mockResolvedValue({ mockFetchInbox.mockResolvedValue({
messages: [mockMessage], messages: [mockMessage],
@@ -560,6 +629,47 @@ describe("MailboxView", () => {
expect(agentsComposeButton).toHaveClass("btn", "btn-sm", "btn-secondary", "mailbox-compose-btn"); expect(agentsComposeButton).toHaveClass("btn", "btn-sm", "btn-secondary", "mailbox-compose-btn");
}); });
it("shows agent sender names in agent inbox rows", async () => {
const agentInboxMessage: Message = {
id: "msg-agent-inbox",
fromId: "agent-002",
fromType: "agent",
toId: "agent-001",
toType: "agent",
content: "Hello from another agent",
type: "agent-to-agent",
read: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
mockFetchInbox.mockResolvedValue({
messages: [],
unreadCount: 0,
});
mockFetchAgentMailbox.mockResolvedValue({
ownerId: "agent-001",
ownerType: "agent",
unreadCount: 1,
messages: [agentInboxMessage],
inbox: [agentInboxMessage],
outbox: [],
});
render(<MailboxView {...defaultProps} />);
const agentsTab = screen.getByTestId("mailbox-tab-agents");
await act(async () => {
fireEvent.click(agentsTab);
});
fireEvent.change(screen.getByTestId("mailbox-agent-select"), { target: { value: "agent-001" } });
await waitFor(() => {
expect(screen.getByText("Agent: Test Agent 2 (agent-002)")).toBeDefined();
});
});
it("switches to outbox view when clicking outbox sub-tab", async () => { it("switches to outbox view when clicking outbox sub-tab", async () => {
mockFetchInbox.mockResolvedValue({ mockFetchInbox.mockResolvedValue({
messages: [], messages: [],