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:
@@ -313,18 +313,59 @@
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.mailbox-reply-context {
|
||||
.mailbox-reply-context-wrapper {
|
||||
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);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: color-mix(in srgb, var(--text-muted) 12%, transparent);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-xs, 0.8rem);
|
||||
line-height: 1.4;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
white-space: pre-wrap;
|
||||
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 */
|
||||
.mailbox-conversation {
|
||||
display: flex;
|
||||
@@ -633,6 +674,11 @@
|
||||
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 {
|
||||
max-width: 100%;
|
||||
}
|
||||
@@ -722,6 +768,11 @@
|
||||
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 {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
RefreshCw,
|
||||
MessageSquare,
|
||||
User,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
} from "lucide-react";
|
||||
import type { Message, MessageType, ParticipantType } from "@fusion/core";
|
||||
import {
|
||||
@@ -23,6 +25,7 @@ import {
|
||||
markAllMessagesRead,
|
||||
deleteMessage,
|
||||
fetchConversation,
|
||||
fetchMessage,
|
||||
type InboxResponse,
|
||||
type OutboxResponse,
|
||||
type AgentMailboxResponse,
|
||||
@@ -135,6 +138,10 @@ export function MailboxModal({
|
||||
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
|
||||
const [agentSubTab, setAgentSubTab] = useState<"inbox" | "outbox">("inbox");
|
||||
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 ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -237,6 +244,9 @@ export function MailboxModal({
|
||||
|
||||
const handleOpenMessage = useCallback(async (message: Message) => {
|
||||
setSelectedMessage(message);
|
||||
setReplyContextExpanded({});
|
||||
setReplyContextLoading({});
|
||||
setReplyContextErrors({});
|
||||
// Only auto-mark as read when viewing the dashboard user's own inbox.
|
||||
// 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.
|
||||
@@ -270,6 +280,9 @@ export function MailboxModal({
|
||||
const handleCloseMessage = useCallback(() => {
|
||||
setSelectedMessage(null);
|
||||
setConversationMessages([]);
|
||||
setReplyContextExpanded({});
|
||||
setReplyContextLoading({});
|
||||
setReplyContextErrors({});
|
||||
}, []);
|
||||
|
||||
const handleMarkAllRead = useCallback(async () => {
|
||||
@@ -342,9 +355,117 @@ export function MailboxModal({
|
||||
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;
|
||||
|
||||
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 ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -519,9 +640,13 @@ export function MailboxModal({
|
||||
<span className="mailbox-message-time">{formatTimestamp(msg.createdAt)}</span>
|
||||
</div>
|
||||
{replyToId && (
|
||||
<div className="mailbox-reply-context" data-testid={`mailbox-reply-context-${msg.id}`}>
|
||||
↪ Replying to {replyToMessage ? messagePreview(replyToMessage.content, 60) : `message ${replyToId}`}
|
||||
</div>
|
||||
<ReplyContextExpandable
|
||||
ownerMessageId={msg.id}
|
||||
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>
|
||||
@@ -533,9 +658,13 @@ export function MailboxModal({
|
||||
{(threadMessages.length <= 1) && (
|
||||
<>
|
||||
{selectedMessage.metadata?.replyTo?.messageId && (
|
||||
<div className="mailbox-reply-context" data-testid="mailbox-selected-reply-context">
|
||||
↪ Replying to message {selectedMessage.metadata.replyTo.messageId}
|
||||
</div>
|
||||
<ReplyContextExpandable
|
||||
ownerMessageId={selectedMessage.id}
|
||||
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">
|
||||
{selectedMessage.content}
|
||||
|
||||
@@ -16,6 +16,7 @@ vi.mock("../../api", () => ({
|
||||
markAllMessagesRead: vi.fn(),
|
||||
deleteMessage: vi.fn(),
|
||||
fetchConversation: vi.fn(),
|
||||
fetchMessage: vi.fn(),
|
||||
sendMessage: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -35,6 +36,8 @@ vi.mock("lucide-react", () => ({
|
||||
RefreshCw: () => <span data-testid="icon-refresh">Refresh</span>,
|
||||
MessageSquare: () => <span data-testid="icon-message">Message</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>,
|
||||
}));
|
||||
|
||||
@@ -46,6 +49,7 @@ const mockMarkMessageRead = vi.mocked(apiModule.markMessageRead);
|
||||
const mockMarkAllMessagesRead = vi.mocked(apiModule.markAllMessagesRead);
|
||||
const mockDeleteMessage = vi.mocked(apiModule.deleteMessage);
|
||||
const mockFetchConversation = vi.mocked(apiModule.fetchConversation);
|
||||
const mockFetchMessage = vi.mocked(apiModule.fetchMessage);
|
||||
const mockSendMessage = vi.mocked(apiModule.sendMessage);
|
||||
|
||||
const mockAgents: Agent[] = [
|
||||
@@ -117,6 +121,7 @@ describe("MailboxModal", () => {
|
||||
mockFetchOutbox.mockResolvedValue({ messages: [], total: 0 });
|
||||
mockFetchUnreadCount.mockResolvedValue({ unreadCount: 1 });
|
||||
mockFetchConversation.mockResolvedValue([mockMessage]);
|
||||
mockFetchMessage.mockResolvedValue(mockMessage);
|
||||
mockMarkMessageRead.mockResolvedValue({ ...mockMessage, read: true });
|
||||
mockMarkAllMessagesRead.mockResolvedValue({ markedAsRead: 1 });
|
||||
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 = {
|
||||
...mockMessage,
|
||||
id: "msg-root",
|
||||
@@ -475,11 +504,82 @@ describe("MailboxModal", () => {
|
||||
|
||||
fireEvent.click(screen.getByTestId("mailbox-item-msg-root"));
|
||||
|
||||
const replyContext = await screen.findByTestId("mailbox-reply-context-msg-reply");
|
||||
fireEvent.click(replyContext);
|
||||
|
||||
await waitFor(() => {
|
||||
const replyContext = screen.getByTestId("mailbox-reply-context-msg-reply");
|
||||
expect(replyContext).toBeDefined();
|
||||
expect(replyContext).toHaveClass("mailbox-reply-context");
|
||||
expect(screen.getByText(/Replying to Need a status update\./)).toBeDefined();
|
||||
expect(mockFetchMessage).not.toHaveBeenCalled();
|
||||
expect(screen.getAllByText("Need a status update.").length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
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(".mailbox-modal .mailbox-tab");
|
||||
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(".mailbox-modal .mailbox-message-detail-header");
|
||||
expect(mailboxMobileSection).toContain("flex-direction: column;");
|
||||
@@ -1001,13 +1101,13 @@ describe("MailboxModal", () => {
|
||||
expect(tabBlockMatch![1]).not.toContain("background: none");
|
||||
expect(tabBlockMatch![1]).not.toContain("border-bottom: 2px solid transparent");
|
||||
|
||||
const subtabBlockMatch = css.match(/\.mailbox-agent-subtab\s*\{([^}]*)\}/);
|
||||
expect(subtabBlockMatch).toBeTruthy();
|
||||
expect(subtabBlockMatch![1]).toContain("border-color: var(--border)");
|
||||
expect(subtabBlockMatch![1]).toContain("background: var(--surface)");
|
||||
expect(subtabBlockMatch![1]).not.toContain("border-radius: 0");
|
||||
expect(subtabBlockMatch![1]).not.toContain("border: none");
|
||||
expect(subtabBlockMatch![1]).not.toContain("background: transparent");
|
||||
const subtabBlocks = [...css.matchAll(/\.mailbox-agent-subtab\s*\{([^}]*)\}/g)].map((match) => match[1]);
|
||||
const baseSubtabBlock = subtabBlocks.find((block) => block.includes("border-color: var(--border)"));
|
||||
expect(baseSubtabBlock).toBeTruthy();
|
||||
expect(baseSubtabBlock!).toContain("background: var(--surface)");
|
||||
expect(baseSubtabBlock!).not.toContain("border-radius: 0");
|
||||
expect(baseSubtabBlock!).not.toContain("border: none");
|
||||
expect(baseSubtabBlock!).not.toContain("background: transparent");
|
||||
});
|
||||
|
||||
it("mission event type error uses CSS custom properties", () => {
|
||||
|
||||
Reference in New Issue
Block a user