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:
Fusion
2026-05-07 20:15:39 -07:00
committed by gsxdsm
parent 5514d3e25c
commit c93f61b506
4 changed files with 306 additions and 21 deletions

View File

@@ -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%;
}

View File

@@ -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}

View File

@@ -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", () => {