feat(FN-3720): expand mailbox messages to open detail pane and show reply c
The merge adds two Mailbox UX improvements: messages in the Mail tab now open the task detail pane (FN-3719), and reply context rows in the mailbox modal are expanded for better readability (FN-3720), with corresponding CSS and test coverage for both AgentDetailView and MailboxModal. Fusion-Task-Id: FN-3720
This commit is contained in:
5
.changeset/FN-3720-mailbox-expand-reply.md
Normal file
5
.changeset/FN-3720-mailbox-expand-reply.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Expand mailbox reply-context rows so users can inline-expand and traverse prior replied-to messages.
|
||||||
@@ -313,18 +313,59 @@
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mailbox-reply-context {
|
.mailbox-reply-context-wrapper {
|
||||||
margin-bottom: var(--space-xs);
|
margin-bottom: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-reply-context {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
margin: 0;
|
||||||
padding: var(--space-sm) var(--space-md);
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
border: 0;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
background: color-mix(in srgb, var(--text-muted) 12%, transparent);
|
background: color-mix(in srgb, var(--text-muted) 12%, transparent);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: var(--font-size-xs, 0.8rem);
|
font-size: var(--font-size-xs, 0.8rem);
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mailbox-reply-context:hover {
|
||||||
|
background: var(--card-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-reply-context:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: var(--focus-ring-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-reply-context__chevron {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-reply-context__nested {
|
||||||
|
margin-top: var(--space-xs);
|
||||||
|
margin-left: var(--space-md);
|
||||||
|
padding-left: var(--space-md);
|
||||||
|
border-left: var(--btn-border-width) solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-reply-context__error {
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
/* Conversation thread */
|
/* Conversation thread */
|
||||||
.mailbox-conversation {
|
.mailbox-conversation {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -633,6 +674,11 @@
|
|||||||
padding: var(--space-xs) var(--space-md);
|
padding: var(--space-xs) var(--space-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mailbox-modal .mailbox-reply-context__nested {
|
||||||
|
margin-left: var(--space-sm);
|
||||||
|
padding-left: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
.mailbox-modal .mailbox-agent-select {
|
.mailbox-modal .mailbox-agent-select {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
@@ -722,6 +768,11 @@
|
|||||||
padding: var(--space-xs) var(--space-md);
|
padding: var(--space-xs) var(--space-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mailbox-view .mailbox-reply-context__nested {
|
||||||
|
margin-left: var(--space-sm);
|
||||||
|
padding-left: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
.mailbox-view .mailbox-agent-select {
|
.mailbox-view .mailbox-agent-select {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
RefreshCw,
|
RefreshCw,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
User,
|
User,
|
||||||
|
ChevronRight,
|
||||||
|
ChevronDown,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { Message, MessageType, ParticipantType } from "@fusion/core";
|
import type { Message, MessageType, ParticipantType } from "@fusion/core";
|
||||||
import {
|
import {
|
||||||
@@ -23,6 +25,7 @@ import {
|
|||||||
markAllMessagesRead,
|
markAllMessagesRead,
|
||||||
deleteMessage,
|
deleteMessage,
|
||||||
fetchConversation,
|
fetchConversation,
|
||||||
|
fetchMessage,
|
||||||
type InboxResponse,
|
type InboxResponse,
|
||||||
type OutboxResponse,
|
type OutboxResponse,
|
||||||
type AgentMailboxResponse,
|
type AgentMailboxResponse,
|
||||||
@@ -135,6 +138,10 @@ export function MailboxModal({
|
|||||||
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
|
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
|
||||||
const [agentSubTab, setAgentSubTab] = useState<"inbox" | "outbox">("inbox");
|
const [agentSubTab, setAgentSubTab] = useState<"inbox" | "outbox">("inbox");
|
||||||
const [agentMailbox, setAgentMailbox] = useState<AgentMailboxResponse | null>(null);
|
const [agentMailbox, setAgentMailbox] = useState<AgentMailboxResponse | null>(null);
|
||||||
|
const [replyContextExpanded, setReplyContextExpanded] = useState<Record<string, boolean>>({});
|
||||||
|
const [replyContextLoading, setReplyContextLoading] = useState<Record<string, boolean>>({});
|
||||||
|
const [replyContextErrors, setReplyContextErrors] = useState<Record<string, string>>({});
|
||||||
|
const [replyContextCache, setReplyContextCache] = useState<Map<string, Message>>(new Map());
|
||||||
|
|
||||||
// ── Data fetching ─────────────────────────────────────────────────────
|
// ── Data fetching ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -237,6 +244,9 @@ export function MailboxModal({
|
|||||||
|
|
||||||
const handleOpenMessage = useCallback(async (message: Message) => {
|
const handleOpenMessage = useCallback(async (message: Message) => {
|
||||||
setSelectedMessage(message);
|
setSelectedMessage(message);
|
||||||
|
setReplyContextExpanded({});
|
||||||
|
setReplyContextLoading({});
|
||||||
|
setReplyContextErrors({});
|
||||||
// Only auto-mark as read when viewing the dashboard user's own inbox.
|
// Only auto-mark as read when viewing the dashboard user's own inbox.
|
||||||
// Browsing another agent's mailbox must not consume their unread messages
|
// Browsing another agent's mailbox must not consume their unread messages
|
||||||
// out from under them — the agent's heartbeat is the one that reads + acks.
|
// out from under them — the agent's heartbeat is the one that reads + acks.
|
||||||
@@ -270,6 +280,9 @@ export function MailboxModal({
|
|||||||
const handleCloseMessage = useCallback(() => {
|
const handleCloseMessage = useCallback(() => {
|
||||||
setSelectedMessage(null);
|
setSelectedMessage(null);
|
||||||
setConversationMessages([]);
|
setConversationMessages([]);
|
||||||
|
setReplyContextExpanded({});
|
||||||
|
setReplyContextLoading({});
|
||||||
|
setReplyContextErrors({});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleMarkAllRead = useCallback(async () => {
|
const handleMarkAllRead = useCallback(async () => {
|
||||||
@@ -342,9 +355,117 @@ export function MailboxModal({
|
|||||||
setComposeReplyContext(null);
|
setComposeReplyContext(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const threadMessages = selectedMessage ? buildReplyThread(conversationMessages, selectedMessage) : [];
|
||||||
|
|
||||||
|
const setReplyExpanded = (key: string, isExpanded: boolean) => {
|
||||||
|
setReplyContextExpanded((prev) => ({ ...prev, [key]: isExpanded }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadReplyMessage = async (messageId: string) => {
|
||||||
|
const cachedMessage = replyContextCache.get(messageId);
|
||||||
|
if (cachedMessage) {
|
||||||
|
return cachedMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
setReplyContextLoading((prev) => ({ ...prev, [messageId]: true }));
|
||||||
|
setReplyContextErrors((prev) => ({ ...prev, [messageId]: "" }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const message = await fetchMessage(messageId, projectId);
|
||||||
|
setReplyContextCache((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(messageId, message);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
return message;
|
||||||
|
} catch {
|
||||||
|
setReplyContextErrors((prev) => ({ ...prev, [messageId]: "Failed to load replied message. Click to retry." }));
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
setReplyContextLoading((prev) => ({ ...prev, [messageId]: false }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
const threadMessages = selectedMessage ? buildReplyThread(conversationMessages, selectedMessage) : [];
|
const ReplyContextExpandable = ({
|
||||||
|
ownerMessageId,
|
||||||
|
replyToId,
|
||||||
|
initialMessage,
|
||||||
|
ancestorIds,
|
||||||
|
testId,
|
||||||
|
}: {
|
||||||
|
ownerMessageId: string;
|
||||||
|
replyToId: string;
|
||||||
|
initialMessage?: Message;
|
||||||
|
ancestorIds: Set<string>;
|
||||||
|
testId?: string;
|
||||||
|
}) => {
|
||||||
|
const cacheMessage = replyContextCache.get(replyToId) ?? initialMessage;
|
||||||
|
const rowKey = `${ownerMessageId}-${replyToId}`;
|
||||||
|
const isExpanded = Boolean(replyContextExpanded[rowKey]);
|
||||||
|
const isLoadingReply = Boolean(replyContextLoading[replyToId]);
|
||||||
|
const errorMessage = replyContextErrors[replyToId];
|
||||||
|
const hasCycle = ancestorIds.has(replyToId);
|
||||||
|
|
||||||
|
const handleToggle = async () => {
|
||||||
|
if (isExpanded) {
|
||||||
|
setReplyExpanded(rowKey, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setReplyExpanded(rowKey, true);
|
||||||
|
if (!cacheMessage && !hasCycle) {
|
||||||
|
await loadReplyMessage(replyToId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextAncestorIds = new Set(ancestorIds);
|
||||||
|
nextAncestorIds.add(replyToId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mailbox-reply-context-wrapper">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mailbox-reply-context"
|
||||||
|
onClick={() => {
|
||||||
|
void handleToggle();
|
||||||
|
}}
|
||||||
|
aria-expanded={isExpanded}
|
||||||
|
data-testid={testId}
|
||||||
|
>
|
||||||
|
<span className="mailbox-reply-context__chevron" aria-hidden="true">
|
||||||
|
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
↪ Replying to {cacheMessage ? messagePreview(cacheMessage.content, 60) : `message ${replyToId}`}
|
||||||
|
</span>
|
||||||
|
{isLoadingReply && <Loader2 size={14} className="spin" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="mailbox-reply-context__nested" data-testid={`mailbox-reply-expanded-${replyToId}`}>
|
||||||
|
{errorMessage && <div className="mailbox-reply-context__error">{errorMessage}</div>}
|
||||||
|
{cacheMessage && (
|
||||||
|
<>
|
||||||
|
<div className="mailbox-conversation-msg-header">
|
||||||
|
<span>{participantLabel(cacheMessage.fromId, cacheMessage.fromType)}</span>
|
||||||
|
<span className="mailbox-message-time">{formatTimestamp(cacheMessage.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mailbox-conversation-msg-body">{cacheMessage.content}</div>
|
||||||
|
{cacheMessage.metadata?.replyTo?.messageId && !nextAncestorIds.has(cacheMessage.metadata.replyTo.messageId) && (
|
||||||
|
<ReplyContextExpandable
|
||||||
|
ownerMessageId={cacheMessage.id}
|
||||||
|
replyToId={cacheMessage.metadata.replyTo.messageId}
|
||||||
|
ancestorIds={nextAncestorIds}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// ── Render ────────────────────────────────────────────────────────────
|
// ── Render ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -519,9 +640,13 @@ export function MailboxModal({
|
|||||||
<span className="mailbox-message-time">{formatTimestamp(msg.createdAt)}</span>
|
<span className="mailbox-message-time">{formatTimestamp(msg.createdAt)}</span>
|
||||||
</div>
|
</div>
|
||||||
{replyToId && (
|
{replyToId && (
|
||||||
<div className="mailbox-reply-context" data-testid={`mailbox-reply-context-${msg.id}`}>
|
<ReplyContextExpandable
|
||||||
↪ Replying to {replyToMessage ? messagePreview(replyToMessage.content, 60) : `message ${replyToId}`}
|
ownerMessageId={msg.id}
|
||||||
</div>
|
replyToId={replyToId}
|
||||||
|
initialMessage={replyToMessage}
|
||||||
|
ancestorIds={new Set([msg.id])}
|
||||||
|
testId={`mailbox-reply-context-${msg.id}`}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="mailbox-conversation-msg-body">{msg.content}</div>
|
<div className="mailbox-conversation-msg-body">{msg.content}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -533,9 +658,13 @@ export function MailboxModal({
|
|||||||
{(threadMessages.length <= 1) && (
|
{(threadMessages.length <= 1) && (
|
||||||
<>
|
<>
|
||||||
{selectedMessage.metadata?.replyTo?.messageId && (
|
{selectedMessage.metadata?.replyTo?.messageId && (
|
||||||
<div className="mailbox-reply-context" data-testid="mailbox-selected-reply-context">
|
<ReplyContextExpandable
|
||||||
↪ Replying to message {selectedMessage.metadata.replyTo.messageId}
|
ownerMessageId={selectedMessage.id}
|
||||||
</div>
|
replyToId={selectedMessage.metadata.replyTo.messageId}
|
||||||
|
initialMessage={threadMessages.find((candidate) => candidate.id === selectedMessage.metadata?.replyTo?.messageId)}
|
||||||
|
ancestorIds={new Set([selectedMessage.id])}
|
||||||
|
testId="mailbox-selected-reply-context"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="mailbox-message-body" data-testid="mailbox-message-body">
|
<div className="mailbox-message-body" data-testid="mailbox-message-body">
|
||||||
{selectedMessage.content}
|
{selectedMessage.content}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ vi.mock("../../api", () => ({
|
|||||||
markAllMessagesRead: vi.fn(),
|
markAllMessagesRead: vi.fn(),
|
||||||
deleteMessage: vi.fn(),
|
deleteMessage: vi.fn(),
|
||||||
fetchConversation: vi.fn(),
|
fetchConversation: vi.fn(),
|
||||||
|
fetchMessage: vi.fn(),
|
||||||
sendMessage: vi.fn(),
|
sendMessage: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -35,6 +36,8 @@ vi.mock("lucide-react", () => ({
|
|||||||
RefreshCw: () => <span data-testid="icon-refresh">Refresh</span>,
|
RefreshCw: () => <span data-testid="icon-refresh">Refresh</span>,
|
||||||
MessageSquare: () => <span data-testid="icon-message">Message</span>,
|
MessageSquare: () => <span data-testid="icon-message">Message</span>,
|
||||||
User: () => <span data-testid="icon-user">User</span>,
|
User: () => <span data-testid="icon-user">User</span>,
|
||||||
|
ChevronRight: () => <span data-testid="icon-chevron-right">ChevronRight</span>,
|
||||||
|
ChevronDown: () => <span data-testid="icon-chevron-down">ChevronDown</span>,
|
||||||
AlertCircle: () => <span data-testid="icon-alert">Alert</span>,
|
AlertCircle: () => <span data-testid="icon-alert">Alert</span>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -46,6 +49,7 @@ const mockMarkMessageRead = vi.mocked(apiModule.markMessageRead);
|
|||||||
const mockMarkAllMessagesRead = vi.mocked(apiModule.markAllMessagesRead);
|
const mockMarkAllMessagesRead = vi.mocked(apiModule.markAllMessagesRead);
|
||||||
const mockDeleteMessage = vi.mocked(apiModule.deleteMessage);
|
const mockDeleteMessage = vi.mocked(apiModule.deleteMessage);
|
||||||
const mockFetchConversation = vi.mocked(apiModule.fetchConversation);
|
const mockFetchConversation = vi.mocked(apiModule.fetchConversation);
|
||||||
|
const mockFetchMessage = vi.mocked(apiModule.fetchMessage);
|
||||||
const mockSendMessage = vi.mocked(apiModule.sendMessage);
|
const mockSendMessage = vi.mocked(apiModule.sendMessage);
|
||||||
|
|
||||||
const mockAgents: Agent[] = [
|
const mockAgents: Agent[] = [
|
||||||
@@ -117,6 +121,7 @@ describe("MailboxModal", () => {
|
|||||||
mockFetchOutbox.mockResolvedValue({ messages: [], total: 0 });
|
mockFetchOutbox.mockResolvedValue({ messages: [], total: 0 });
|
||||||
mockFetchUnreadCount.mockResolvedValue({ unreadCount: 1 });
|
mockFetchUnreadCount.mockResolvedValue({ unreadCount: 1 });
|
||||||
mockFetchConversation.mockResolvedValue([mockMessage]);
|
mockFetchConversation.mockResolvedValue([mockMessage]);
|
||||||
|
mockFetchMessage.mockResolvedValue(mockMessage);
|
||||||
mockMarkMessageRead.mockResolvedValue({ ...mockMessage, read: true });
|
mockMarkMessageRead.mockResolvedValue({ ...mockMessage, read: true });
|
||||||
mockMarkAllMessagesRead.mockResolvedValue({ markedAsRead: 1 });
|
mockMarkAllMessagesRead.mockResolvedValue({ markedAsRead: 1 });
|
||||||
mockDeleteMessage.mockResolvedValue(undefined);
|
mockDeleteMessage.mockResolvedValue(undefined);
|
||||||
@@ -445,7 +450,31 @@ describe("MailboxModal", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders reply context inside modal conversation thread", async () => {
|
it("renders selected-message reply context row when metadata includes replyTo", async () => {
|
||||||
|
const reply: Message = {
|
||||||
|
...mockMessage,
|
||||||
|
id: "msg-reply-selected",
|
||||||
|
metadata: { replyTo: { messageId: "msg-root-remote" } },
|
||||||
|
};
|
||||||
|
|
||||||
|
mockFetchInbox.mockResolvedValue({ messages: [reply], total: 1, unreadCount: 1 });
|
||||||
|
mockFetchConversation.mockResolvedValue([reply]);
|
||||||
|
mockMarkMessageRead.mockResolvedValue({ ...reply, read: true });
|
||||||
|
|
||||||
|
render(<MailboxModal {...defaultProps} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("mailbox-item-msg-reply-selected")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId("mailbox-item-msg-reply-selected"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("mailbox-selected-reply-context")).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("expands reply context without fetch when parent is already in thread", async () => {
|
||||||
const root: Message = {
|
const root: Message = {
|
||||||
...mockMessage,
|
...mockMessage,
|
||||||
id: "msg-root",
|
id: "msg-root",
|
||||||
@@ -475,11 +504,82 @@ describe("MailboxModal", () => {
|
|||||||
|
|
||||||
fireEvent.click(screen.getByTestId("mailbox-item-msg-root"));
|
fireEvent.click(screen.getByTestId("mailbox-item-msg-root"));
|
||||||
|
|
||||||
|
const replyContext = await screen.findByTestId("mailbox-reply-context-msg-reply");
|
||||||
|
fireEvent.click(replyContext);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const replyContext = screen.getByTestId("mailbox-reply-context-msg-reply");
|
expect(mockFetchMessage).not.toHaveBeenCalled();
|
||||||
expect(replyContext).toBeDefined();
|
expect(screen.getAllByText("Need a status update.").length).toBeGreaterThan(0);
|
||||||
expect(replyContext).toHaveClass("mailbox-reply-context");
|
});
|
||||||
expect(screen.getByText(/Replying to Need a status update\./)).toBeDefined();
|
});
|
||||||
|
|
||||||
|
it("renders nested reply context rows for multi-level thread metadata", async () => {
|
||||||
|
const grandparent: Message = { ...mockMessage, id: "msg-grandparent", content: "Original message" };
|
||||||
|
const parent: Message = {
|
||||||
|
...mockMessage,
|
||||||
|
id: "msg-parent",
|
||||||
|
fromId: "dashboard",
|
||||||
|
fromType: "user",
|
||||||
|
toId: "agent-001",
|
||||||
|
toType: "agent",
|
||||||
|
type: "user-to-agent",
|
||||||
|
content: "Second reply",
|
||||||
|
metadata: { replyTo: { messageId: "msg-grandparent" } },
|
||||||
|
};
|
||||||
|
const child: Message = {
|
||||||
|
...mockMessage,
|
||||||
|
id: "msg-child",
|
||||||
|
fromId: "agent-001",
|
||||||
|
fromType: "agent",
|
||||||
|
toId: "dashboard",
|
||||||
|
toType: "user",
|
||||||
|
content: "Third reply",
|
||||||
|
metadata: { replyTo: { messageId: "msg-parent" } },
|
||||||
|
};
|
||||||
|
|
||||||
|
mockFetchInbox.mockResolvedValue({ messages: [grandparent], total: 1, unreadCount: 1 });
|
||||||
|
mockFetchConversation.mockResolvedValue([grandparent, parent, child]);
|
||||||
|
|
||||||
|
render(<MailboxModal {...defaultProps} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("mailbox-item-msg-grandparent")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId("mailbox-item-msg-grandparent"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("mailbox-reply-context-msg-parent")).toBeDefined();
|
||||||
|
expect(screen.getByTestId("mailbox-reply-context-msg-child")).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops recursive rendering when ancestor cycle is detected", async () => {
|
||||||
|
const cycleA: Message = { ...mockMessage, id: "msg-cycle-a", metadata: { replyTo: { messageId: "msg-cycle-b" } } };
|
||||||
|
const cycleB: Message = {
|
||||||
|
...mockMessage,
|
||||||
|
id: "msg-cycle-b",
|
||||||
|
fromId: "dashboard",
|
||||||
|
fromType: "user",
|
||||||
|
toId: "agent-001",
|
||||||
|
toType: "agent",
|
||||||
|
type: "user-to-agent",
|
||||||
|
metadata: { replyTo: { messageId: "msg-cycle-a" } },
|
||||||
|
};
|
||||||
|
|
||||||
|
mockFetchInbox.mockResolvedValue({ messages: [cycleA], total: 1, unreadCount: 1 });
|
||||||
|
mockFetchConversation.mockResolvedValue([cycleA, cycleB]);
|
||||||
|
|
||||||
|
render(<MailboxModal {...defaultProps} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("mailbox-item-msg-cycle-a")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId("mailbox-item-msg-cycle-a"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByTestId("mailbox-reply-context-msg-cycle-b")).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -934,7 +1034,7 @@ describe("MailboxModal", () => {
|
|||||||
expect(mailboxMobileSection).toContain("display: none;");
|
expect(mailboxMobileSection).toContain("display: none;");
|
||||||
expect(mailboxMobileSection).toContain(".mailbox-modal .mailbox-tab");
|
expect(mailboxMobileSection).toContain(".mailbox-modal .mailbox-tab");
|
||||||
expect(mailboxMobileSection).toContain("padding: var(--space-sm) var(--space-md);");
|
expect(mailboxMobileSection).toContain("padding: var(--space-sm) var(--space-md);");
|
||||||
expect(mailboxMobileSection).toContain("font-size: 0.8rem;");
|
expect(mailboxMobileSection).toContain("font-size: var(--font-size-xs, 0.8rem);");
|
||||||
expect(mailboxMobileSection).toContain("max-height: calc(100dvh - var(--header-height) - var(--space-2xl) - var(--space-xl));");
|
expect(mailboxMobileSection).toContain("max-height: calc(100dvh - var(--header-height) - var(--space-2xl) - var(--space-xl));");
|
||||||
expect(mailboxMobileSection).toContain(".mailbox-modal .mailbox-message-detail-header");
|
expect(mailboxMobileSection).toContain(".mailbox-modal .mailbox-message-detail-header");
|
||||||
expect(mailboxMobileSection).toContain("flex-direction: column;");
|
expect(mailboxMobileSection).toContain("flex-direction: column;");
|
||||||
@@ -1001,13 +1101,13 @@ describe("MailboxModal", () => {
|
|||||||
expect(tabBlockMatch![1]).not.toContain("background: none");
|
expect(tabBlockMatch![1]).not.toContain("background: none");
|
||||||
expect(tabBlockMatch![1]).not.toContain("border-bottom: 2px solid transparent");
|
expect(tabBlockMatch![1]).not.toContain("border-bottom: 2px solid transparent");
|
||||||
|
|
||||||
const subtabBlockMatch = css.match(/\.mailbox-agent-subtab\s*\{([^}]*)\}/);
|
const subtabBlocks = [...css.matchAll(/\.mailbox-agent-subtab\s*\{([^}]*)\}/g)].map((match) => match[1]);
|
||||||
expect(subtabBlockMatch).toBeTruthy();
|
const baseSubtabBlock = subtabBlocks.find((block) => block.includes("border-color: var(--border)"));
|
||||||
expect(subtabBlockMatch![1]).toContain("border-color: var(--border)");
|
expect(baseSubtabBlock).toBeTruthy();
|
||||||
expect(subtabBlockMatch![1]).toContain("background: var(--surface)");
|
expect(baseSubtabBlock!).toContain("background: var(--surface)");
|
||||||
expect(subtabBlockMatch![1]).not.toContain("border-radius: 0");
|
expect(baseSubtabBlock!).not.toContain("border-radius: 0");
|
||||||
expect(subtabBlockMatch![1]).not.toContain("border: none");
|
expect(baseSubtabBlock!).not.toContain("border: none");
|
||||||
expect(subtabBlockMatch![1]).not.toContain("background: transparent");
|
expect(baseSubtabBlock!).not.toContain("background: transparent");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("mission event type error uses CSS custom properties", () => {
|
it("mission event type error uses CSS custom properties", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user