feat(FN-2333): merge fusion/fn-2333
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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: [],
|
||||||
|
|||||||
Reference in New Issue
Block a user