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

This commit is contained in:
gsxdsm
2026-04-23 12:56:02 -07:00
parent 859c3c5abc
commit ac30b26d57
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 {
Mail,
Send,
@@ -73,9 +73,18 @@ function formatTimestamp(ts: string): string {
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 === "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";
}
@@ -145,6 +154,15 @@ export function MailboxView({
const [agentMailbox, setAgentMailbox] = useState<AgentMailboxResponse | null>(null);
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 ─────────────────────────────────────────────────────
const loadInbox = useCallback(async () => {
@@ -488,14 +506,14 @@ export function MailboxView({
<span className="mailbox-participant-label">From:</span>
<span className="mailbox-participant-value">
{selectedMessage.fromType === "agent" ? <Bot size={14} /> : <User size={14} />}
{participantLabel(selectedMessage.fromId, selectedMessage.fromType)}
{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} />}
{participantLabel(selectedMessage.toId, selectedMessage.toType)}
{getParticipantLabel(selectedMessage.toId, selectedMessage.toType)}
</span>
</div>
</div>
@@ -509,7 +527,7 @@ export function MailboxView({
className={`mailbox-conversation-msg ${msg.id === selectedMessage.id ? "current" : ""}`}
>
<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>
</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-header">
<span className="mailbox-item-from">
{participantLabel(group.fromId, group.fromType)}
{getParticipantLabel(group.fromId, group.fromType)}
</span>
<span className="mailbox-item-time">
{formatTimestamp(group.latestMessage.createdAt)}
@@ -612,7 +630,7 @@ export function MailboxView({
<div className="mailbox-item-content">
<div className="mailbox-item-header">
<span className="mailbox-item-to">
To: {participantLabel(msg.toId, msg.toType)}
To: {getParticipantLabel(msg.toId, msg.toType)}
</span>
<span className="mailbox-item-time">{formatTimestamp(msg.createdAt)}</span>
</div>
@@ -716,9 +734,7 @@ export function MailboxView({
<div className="mailbox-item-content">
<div className="mailbox-item-header">
<span className="mailbox-item-from">
{msg.fromType === "agent"
? participantLabel(msg.toId, msg.toType)
: participantLabel(msg.fromId, msg.fromType)}
{getParticipantLabel(msg.fromId, msg.fromType)}
</span>
<span className="mailbox-item-time">{formatTimestamp(msg.createdAt)}</span>
</div>
@@ -739,7 +755,7 @@ export function MailboxView({
<div className="mailbox-item-content">
<div className="mailbox-item-header">
<span className="mailbox-item-to">
To: {participantLabel(msg.toId, msg.toType)}
To: {getParticipantLabel(msg.toId, msg.toType)}
</span>
<span className="mailbox-item-time">{formatTimestamp(msg.createdAt)}</span>
</div>

View File

@@ -103,6 +103,25 @@ const mockOutboxMessage: Message = {
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 = {
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 () => {
const secondMessage = { ...mockMessage, id: "msg-003", read: false };
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 () => {
mockFetchInbox.mockResolvedValue({
messages: [mockMessage],
@@ -560,6 +629,47 @@ describe("MailboxView", () => {
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 () => {
mockFetchInbox.mockResolvedValue({
messages: [],