Files
fusion/packages/dashboard/app/components/__tests__/MailboxView.test.tsx
gsxdsm fc56161c9d FN-9014: add chat and mailbox message archiving
Add archived-message lifecycle support across chat and mailbox views.

- Persist archive state with a PostgreSQL migration and message-store APIs.
- Expose archive, restore, and permanent-delete messaging endpoints.
- Add archived-message views and archive-by-default actions with regression coverage.
- Document storage behavior and add a release changeset.

Files changed:
 .changeset/fn-9014-message-archive.md              |   7 ++
 docs/dashboard-guide.md                            |   7 +-
 docs/storage.md                                    |   2 +-
 .../__tests__/postgres/message-store.pg.test.ts    |  34 +++++++
 .../src/__tests__/postgres/schema-applier.test.ts  |   9 +-
 .../core/src/async-stores/async-message-store.ts   |  73 +++++++++++---
 .../migrations/0058_fn_9014_message_archive.sql    |  13 +++
 packages/core/src/postgres/schema-applier.ts       |  14 ++-
 packages/core/src/postgres/schema/project.ts       |   2 +
 packages/core/src/stores/message-store.ts          |  68 ++++++++++---
 packages/core/src/types/messaging/messages.ts      |   9 ++
 packages/dashboard/app/api/chat/messaging.ts       |  37 +++++--
 packages/dashboard/app/api/legacy.ts               |   2 +
 packages/dashboard/app/components/ChatView.tsx     |  21 +++-
 packages/dashboard/app/components/MailboxModal.tsx |  74 ++++++++++++--
 packages/dashboard/app/components/MailboxView.tsx  | 108 +++++++++++++++++++--
 .../__tests__/ChatView.core-contracts.test.tsx     |  24 +++++
 .../components/__tests__/ChatView.test-harness.tsx |   3 +
 .../app/components/__tests__/MailboxModal.test.tsx |   6 +-
 .../app/components/__tests__/MailboxView.test.tsx  |  10 +-
 .../dashboard/app/hooks/__tests__/useChat.test.ts  |  35 ++++++-
 packages/dashboard/app/hooks/useChat.ts            |  38 +++++++-
 .../__tests__/messaging-archive-routes.test.ts     |  86 ++++++++++++++++
 .../src/routes/register-messaging-scripts.ts       |  33 ++++++-
 24 files changed, 635 insertions(+), 80 deletions(-)

Fusion-Task-Id: FN-9014

Fusion-Task-Lineage: 1dbd02c3-1180-4d72-8cbc-9594e5f355dc

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-08-12 16:03:30 -07:00

2758 lines
112 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { useEffect, type ReactNode } from "react";
import { loadAllAppCss } from "../../test/cssFixture";
import { render, screen, fireEvent, waitFor, act, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MailboxView } from "../MailboxView";
import { NavigationHistoryProvider, useNavigationHistory, type UseNavigationHistoryResult } from "../../hooks/useNavigationHistory";
import * as apiModule from "../../api";
import * as viewportModule from "../../hooks/useViewportMode";
import * as mobileKeyboardModule from "../../hooks/useMobileKeyboard";
import * as sseBusModule from "../../sse-bus";
import type { Agent } from "../../api";
import type { Message } from "@fusion/core";
// Mock the API module
vi.mock("../../api", () => ({
fetchInbox: vi.fn(),
fetchOutbox: vi.fn(),
fetchUnreadCount: vi.fn(),
fetchAgentMailbox: vi.fn(),
fetchAllAgentMailbox: vi.fn(),
markMessageRead: vi.fn(),
markAllMessagesRead: vi.fn(),
deleteMessage: vi.fn(),
fetchConversation: vi.fn(),
sendMessage: vi.fn(),
fetchAgents: vi.fn(),
fetchApprovals: vi.fn(),
fetchApprovalDetail: vi.fn(),
decideApproval: vi.fn(),
artifactMediaUrlWithToken: vi.fn((id: string, projectId?: string) => `/api/artifacts/${id}/media${projectId ? `?projectId=${projectId}&` : "?"}fn_token=daemon-token`),
fetchNativeStructurePreview: vi.fn(),
}));
vi.mock("../../hooks/useViewportMode", () => {
const useViewportMode = vi.fn();
return { isFullScreenSheetViewport: () => false,
isShortViewport: () => false,
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)",
getViewportMode: () => useViewportMode(),
isMobileViewport: () => useViewportMode() === "mobile",
isTabletTouchViewport: (mode?: string) => mode === "tablet",
useViewportMode,
};
});
vi.mock("../../hooks/useMobileKeyboard", () => ({
useMobileKeyboard: vi.fn(),
}));
/*
FNXC:StructuralMail 2026-08-09-10:50:
Report-mode prefill opens the existing drafting panel by default. Keep mailbox integration tests focused
on the real host-to-composer handoff rather than the panel's separately tested chat session.
*/
vi.mock("../ComposeChatPanel", () => ({ ComposeChatPanel: () => null }));
const sseSubscriptions: Array<Record<string, () => void>> = [];
vi.mock("../../sse-bus", () => ({
subscribeSse: vi.fn((_url: string, options: { events: Record<string, () => void> }) => {
sseSubscriptions.push(options.events);
return () => {};
}),
}));
// Mock lucide-react icons
vi.mock("lucide-react", () => ({
X: () => <span data-testid="icon-x">X</span>,
Mail: () => <span data-testid="icon-mail">Mail</span>,
Send: () => <span data-testid="icon-send">Send</span>,
Inbox: () => <span data-testid="icon-inbox">Inbox</span>,
Bot: () => <span data-testid="icon-bot">Bot</span>,
Trash2: () => <span data-testid="icon-trash">Trash</span>,
Archive: () => <span data-testid="icon-archive">Archive</span>,
Check: () => <span data-testid="icon-check">Check</span>,
CheckCheck: () => <span data-testid="icon-checkcheck">CheckCheck</span>,
Loader2: ({ className }: { className?: string }) => (
<span data-testid="icon-loader" className={className}>Loader</span>
),
RefreshCw: () => <span data-testid="icon-refresh">Refresh</span>,
MessageSquare: () => <span data-testid="icon-message">Message</span>,
User: () => <span data-testid="icon-user">User</span>,
AlertCircle: () => <span data-testid="icon-alert">Alert</span>,
Map: () => <span data-testid="icon-map">Map</span>,
Flag: () => <span data-testid="icon-flag">Flag</span>,
Lightbulb: () => <span data-testid="icon-lightbulb">Lightbulb</span>,
BarChart3: () => <span data-testid="icon-chart">Chart</span>,
Target: () => <span data-testid="icon-target">Target</span>,
CircleAlert: () => <span data-testid="icon-circle-alert">CircleAlert</span>,
}));
const mockFetchInbox = vi.mocked(apiModule.fetchInbox);
const mockFetchOutbox = vi.mocked(apiModule.fetchOutbox);
const mockFetchUnreadCount = vi.mocked(apiModule.fetchUnreadCount);
const mockFetchAgentMailbox = vi.mocked(apiModule.fetchAgentMailbox);
const mockFetchAllAgentMailbox = vi.mocked(apiModule.fetchAllAgentMailbox);
const mockFetchAgents = vi.mocked(apiModule.fetchAgents);
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 mockSendMessage = vi.mocked(apiModule.sendMessage);
const mockFetchApprovals = vi.mocked(apiModule.fetchApprovals);
const mockFetchApprovalDetail = vi.mocked(apiModule.fetchApprovalDetail);
const mockDecideApproval = vi.mocked(apiModule.decideApproval);
const mockSubscribeSse = vi.mocked(sseBusModule.subscribeSse);
const mockUseViewportMode = vi.mocked(viewportModule.useViewportMode);
const mockUseMobileKeyboard = vi.mocked(mobileKeyboardModule.useMobileKeyboard);
const mockAgents: Agent[] = [
{
id: "agent-001",
name: "Test Agent 1",
role: "executor",
state: "idle",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
metadata: {},
},
{
id: "agent-002",
name: "Test Agent 2",
role: "triage",
state: "active",
taskId: "FN-001",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
metadata: {},
},
];
const mockMessage: Message = {
id: "msg-001",
fromId: "agent-001",
fromType: "agent",
toId: "dashboard",
toType: "user",
content: "Hello, this is a test message from the agent.",
type: "agent-to-user",
read: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
const mockReadMessage: Message = {
...mockMessage,
id: "msg-002",
read: true,
content: "This message has been read already.",
};
const mockOutboxMessage: Message = {
id: "msg-003",
fromId: "agent-001",
fromType: "agent",
toId: "user-001",
toType: "user",
content: "This is a sent message from the agent.",
type: "agent-to-user",
read: true,
createdAt: 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 = {
addToast: vi.fn(),
onOpenNativeStructure: vi.fn(),
nativeStructureCandidates: [],
};
/** Build a valid InboxResponse shape — `total` defaults to `messages.length` */
function makeInboxResponse(messages: Message[], unreadCount = 0) {
return { messages, unreadCount, total: messages.length };
}
/** Build a valid OutboxResponse shape (no unreadCount) */
function makeOutboxResponse(messages: Message[]) {
return { messages, total: messages.length };
}
function HistoryHarness({ children, historyRef }: { children: ReactNode; historyRef?: { current: UseNavigationHistoryResult | null } }) {
const history = useNavigationHistory({ enabled: true });
useEffect(() => {
if (historyRef) historyRef.current = history;
}, [history, historyRef]);
return <NavigationHistoryProvider value={history}>{children}</NavigationHistoryProvider>;
}
describe("MailboxView", () => {
afterEach(() => {
vi.useRealTimers();
});
beforeEach(() => {
vi.clearAllMocks();
window.history.replaceState({}, "", "/");
Element.prototype.scrollIntoView = vi.fn();
window.localStorage.clear();
sseSubscriptions.length = 0;
mockUseViewportMode.mockReturnValue("desktop");
mockUseMobileKeyboard.mockReturnValue({
keyboardOverlap: 0,
viewportHeight: null,
viewportOffsetTop: 0,
keyboardOpen: false,
});
mockFetchUnreadCount.mockResolvedValue({ unreadCount: 2 });
mockFetchAgents.mockResolvedValue(mockAgents);
mockSendMessage.mockResolvedValue({ ...mockMessage, id: "msg-sent" });
mockFetchApprovals.mockResolvedValue({ requests: [], total: 0, pendingCount: 0 });
});
it("renders the mailbox view", async () => {
mockFetchInbox.mockResolvedValue({
messages: [],
unreadCount: 0,
total: 0,
});
render(<MailboxView {...defaultProps} />);
expect(screen.getByTestId("mailbox-view")).toBeDefined();
expect(screen.getByTestId("mailbox-tabs")).toBeDefined();
});
it.each(["desktop", "mobile"] as const)("opens a %s report composer from a fresh chat handoff without leaking it after close", async (viewport) => {
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
mockUseViewportMode.mockReturnValue(viewport);
mockFetchInbox.mockResolvedValue(makeInboxResponse([], 0));
mockFetchOutbox.mockResolvedValue(makeOutboxResponse([]));
const prefill = { body: "Assistant report body", title: "Assistant report", nonce: 10 };
const { rerender } = render(<MailboxView {...defaultProps} composePrefill={prefill} />);
expect(await screen.findByTestId("report-title")).toHaveValue("Assistant report");
expect(screen.getByTestId("message-composer-content")).toHaveValue("Assistant report body");
expect(screen.getByTestId("message-composer-send")).toBeDisabled();
await user.click(screen.getByTestId("message-composer-cancel"));
expect(screen.queryByTestId("report-title")).not.toBeInTheDocument();
rerender(<MailboxView {...defaultProps} composePrefill={prefill} />);
expect(screen.queryByTestId("report-title")).not.toBeInTheDocument();
rerender(<MailboxView {...defaultProps} composePrefill={{ ...prefill, nonce: 11 }} />);
expect(await screen.findByTestId("report-title")).toHaveValue("Assistant report");
});
it("shows the Mailbox title with unread count badge", async () => {
mockFetchInbox.mockResolvedValue({
messages: [mockMessage],
unreadCount: 1,
total: 1,
});
render(<MailboxView {...defaultProps} />);
await waitFor(() => {
expect(screen.getByTestId("mailbox-unread-badge")).toBeDefined();
});
});
it("preserves composed inbox timestamp buckets", async () => {
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date("2026-06-17T20:00:00.000Z"));
const messages = [
["now", "2026-06-17T19:59:30.000Z"],
["minute", "2026-06-17T19:55:00.000Z"],
["hour", "2026-06-17T17:00:00.000Z"],
["day", "2026-06-14T20:00:00.000Z"],
["future", "2026-06-17T20:00:01.000Z"],
["invalid", "not-a-date"],
["older", "2026-06-10T20:00:00.000Z"],
].map(([id, createdAt]) => ({ ...mockMessage, id: `msg-${id}`, createdAt, updatedAt: createdAt, content: id, read: true }));
mockFetchInbox.mockResolvedValue(makeInboxResponse(messages, 0));
const { container } = render(<MailboxView {...defaultProps} />);
await waitFor(() => {
const times = Array.from(container.querySelectorAll(".mailbox-item-time")).map((node) => node.textContent);
expect(times).toEqual(expect.arrayContaining([
"Just now",
"5m ago",
"3h ago",
"3d ago",
"Invalid Date",
new Date("2026-06-10T20:00:00.000Z").toLocaleDateString(undefined, { month: "short", day: "numeric" }),
]));
});
});
it("renders all four tabs", async () => {
mockFetchInbox.mockResolvedValue({
messages: [],
unreadCount: 0,
total: 0,
});
render(<MailboxView {...defaultProps} />);
expect(screen.getByTestId("mailbox-tab-inbox")).toBeDefined();
expect(screen.getByTestId("mailbox-tab-outbox")).toBeDefined();
expect(screen.getByTestId("mailbox-tab-agents")).toBeDefined();
expect(screen.getByTestId("mailbox-tab-approvals")).toBeDefined();
});
it("shows approvals pending badge on mount without opening Approvals tab", async () => {
mockFetchInbox.mockResolvedValue({ messages: [], unreadCount: 0, total: 0 });
mockFetchUnreadCount.mockResolvedValue({ unreadCount: 0, pendingApprovalCount: 3 });
render(<MailboxView {...defaultProps} />);
await waitFor(() => {
expect(screen.getByTestId("mailbox-tab-inbox")).toHaveClass("active");
expect(screen.getByTestId("mailbox-approvals-pending-badge")).toHaveTextContent("3");
});
expect(mockFetchApprovals).not.toHaveBeenCalled();
});
it("shows approvals pending badge and loads approvals tab", async () => {
mockFetchInbox.mockResolvedValue({ messages: [], unreadCount: 0, total: 0 });
mockFetchApprovals.mockResolvedValue({
requests: [{
id: "apr-1",
status: "pending",
actionCategory: "command_execution",
actionSummary: "Run npm test",
agentId: "agent-001",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}],
total: 1,
pendingCount: 2,
});
render(<MailboxView {...defaultProps} />);
await act(async () => {
fireEvent.click(screen.getByTestId("mailbox-tab-approvals"));
});
await waitFor(() => {
expect(screen.getByTestId("mailbox-approvals-pending-badge")).toHaveTextContent("2");
expect(screen.getByTestId("mailbox-approval-item-apr-1")).toBeDefined();
});
});
it("renders approval detail metadata and history", async () => {
const now = new Date().toISOString();
mockFetchInbox.mockResolvedValue({ messages: [], unreadCount: 0, total: 0 });
mockFetchApprovals.mockResolvedValue({
requests: [{ id: "apr-1", status: "pending", actionCategory: "command_execution", actionSummary: "Run npm test", agentId: "agent-001", taskId: "FN-1", createdAt: now, updatedAt: now }],
total: 1,
pendingCount: 1,
});
mockFetchApprovalDetail.mockResolvedValue({
id: "apr-1", status: "pending", actionCategory: "command_execution", actionSummary: "Run npm test", agentId: "agent-001", taskId: "FN-1", createdAt: now, updatedAt: now,
requester: { actorId: "agent-001", actorType: "agent", actorName: "Agent 1" }, requestedAt: now,
targetAction: { category: "command_execution", action: "bash", summary: "Run npm test", resourceType: "command", resourceId: "cmd" },
history: [{ id: "evt-1", eventType: "created", actor: { actorId: "agent-001", actorType: "agent", actorName: "Agent 1" }, createdAt: now }],
});
render(<MailboxView {...defaultProps} />);
await act(async () => { fireEvent.click(screen.getByTestId("mailbox-tab-approvals")); });
await act(async () => { fireEvent.click(await screen.findByTestId("mailbox-approval-item-apr-1")); });
await waitFor(() => {
expect(screen.getByTestId("mailbox-approval-detail")).toBeDefined();
expect(screen.getByText(/Requester: Agent 1/)).toBeDefined();
expect(screen.getByText(/Task: FN-1/)).toBeDefined();
expect(screen.getByTestId("mailbox-approval-history")).toBeDefined();
});
});
it("renders worktrunk install details for worktrunk approvals", async () => {
const now = new Date().toISOString();
mockFetchInbox.mockResolvedValue({ messages: [], unreadCount: 0, total: 0 });
mockFetchApprovals.mockResolvedValue({
requests: [{ id: "apr-1", status: "pending", actionCategory: "network_api", actionSummary: "Install worktrunk", agentId: "user", createdAt: now, updatedAt: now }],
total: 1,
pendingCount: 1,
});
mockFetchApprovalDetail.mockResolvedValue({
id: "apr-1", status: "pending", actionCategory: "network_api", actionSummary: "Install worktrunk", agentId: "user", createdAt: now, updatedAt: now,
requester: { actorId: "user", actorType: "user", actorName: "User" }, requestedAt: now,
targetAction: {
category: "network_api",
action: "worktrunk_install",
summary: "Install worktrunk",
resourceType: "binary",
resourceId: "~/.fusion/bin/worktrunk",
context: {
version: "v1.2.3",
installPath: "~/.fusion/bin/worktrunk",
assets: {
darwin_arm64: {
url: "https://example.com/worktrunk.tar.gz",
sha256: "abc123",
},
},
},
},
history: [{ id: "evt-1", eventType: "created", actor: { actorId: "user", actorType: "user", actorName: "User" }, createdAt: now }],
});
render(<MailboxView {...defaultProps} />);
await act(async () => { fireEvent.click(screen.getByTestId("mailbox-tab-approvals")); });
await act(async () => { fireEvent.click(await screen.findByTestId("mailbox-approval-item-apr-1")); });
await waitFor(() => {
expect(screen.getByTestId("worktrunk-install-approval-details")).toBeInTheDocument();
expect(screen.getByText("v1.2.3")).toBeInTheDocument();
});
});
// FN-7609: an agent-gating approval must render the real gated payload
// (command visible), and this must be mutually exclusive with the
// dedicated worktrunk_install branch (covered by the test above).
it("renders gated action payload details for agent-gating approvals", async () => {
const now = new Date().toISOString();
mockFetchInbox.mockResolvedValue({ messages: [], unreadCount: 0, total: 0 });
mockFetchApprovals.mockResolvedValue({
requests: [{ id: "apr-1", status: "pending", actionCategory: "command_execution", actionSummary: "Run: pnpm test", agentId: "agent-001", createdAt: now, updatedAt: now }],
total: 1,
pendingCount: 1,
});
mockFetchApprovalDetail.mockResolvedValue({
id: "apr-1", status: "pending", actionCategory: "command_execution", actionSummary: "Run: pnpm test", agentId: "agent-001", createdAt: now, updatedAt: now,
requester: { actorId: "agent-001", actorType: "agent", actorName: "Agent 1" }, requestedAt: now,
targetAction: {
category: "command_execution",
action: "bash",
summary: "Run: pnpm test",
resourceType: "tool",
resourceId: "bash",
context: {
toolName: "bash",
toolArgs: { command: "pnpm test" },
source: "agent-gating",
command: "pnpm test",
},
},
history: [{ id: "evt-1", eventType: "created", actor: { actorId: "agent-001", actorType: "agent", actorName: "Agent 1" }, createdAt: now }],
});
render(<MailboxView {...defaultProps} />);
await act(async () => { fireEvent.click(screen.getByTestId("mailbox-tab-approvals")); });
await act(async () => { fireEvent.click(await screen.findByTestId("mailbox-approval-item-apr-1")); });
await waitFor(() => {
expect(screen.getByTestId("gated-action-approval-details")).toBeInTheDocument();
expect(screen.getByText("pnpm test")).toBeInTheDocument();
expect(screen.queryByTestId("worktrunk-install-approval-details")).not.toBeInTheDocument();
});
});
it("allows approving a pending approval request", async () => {
const now = new Date().toISOString();
mockFetchInbox.mockResolvedValue({ messages: [], unreadCount: 0, total: 0 });
mockFetchApprovals.mockResolvedValue({
requests: [{ id: "apr-1", status: "pending", actionCategory: "command_execution", actionSummary: "Run npm test", agentId: "agent-001", createdAt: now, updatedAt: now }],
total: 1,
pendingCount: 1,
});
mockFetchApprovalDetail.mockResolvedValue({
id: "apr-1",
status: "pending",
actionCategory: "command_execution",
actionSummary: "Run npm test",
agentId: "agent-001",
createdAt: now,
updatedAt: now,
requester: { actorId: "agent-001", actorType: "agent", actorName: "Agent 1" },
requestedAt: now,
targetAction: { category: "command_execution", action: "bash", summary: "Run npm test", resourceType: "command", resourceId: "cmd" },
history: [{ id: "evt-1", eventType: "created", actor: { actorId: "agent-001", actorType: "agent", actorName: "Agent 1" }, createdAt: now }],
});
mockDecideApproval.mockResolvedValue({
id: "apr-1",
status: "approved",
actionCategory: "command_execution",
actionSummary: "Run npm test",
agentId: "agent-001",
createdAt: now,
updatedAt: now,
requester: { actorId: "agent-001", actorType: "agent", actorName: "Agent 1" },
requestedAt: now,
targetAction: { category: "command_execution", action: "bash", summary: "Run npm test", resourceType: "command", resourceId: "cmd" },
history: [{ id: "evt-2", eventType: "approved", actor: { actorId: "user", actorType: "user", actorName: "User" }, createdAt: now }],
});
render(<MailboxView {...defaultProps} />);
await act(async () => { fireEvent.click(screen.getByTestId("mailbox-tab-approvals")); });
await act(async () => { fireEvent.click(await screen.findByTestId("mailbox-approval-item-apr-1")); });
await act(async () => { fireEvent.click(await screen.findByTestId("mailbox-approval-approve")); });
await waitFor(() => {
expect(mockDecideApproval).toHaveBeenCalledWith("apr-1", { decision: "approve", comment: undefined }, undefined);
});
});
it("allows denying a pending approval request", async () => {
const now = new Date().toISOString();
mockFetchInbox.mockResolvedValue({ messages: [], unreadCount: 0, total: 0 });
mockFetchApprovals.mockResolvedValue({ requests: [{ id: "apr-1", status: "pending", actionCategory: "command_execution", actionSummary: "Run npm test", agentId: "agent-001", createdAt: now, updatedAt: now }], total: 1, pendingCount: 1 });
mockFetchApprovalDetail.mockResolvedValue({ id: "apr-1", status: "pending", actionCategory: "command_execution", actionSummary: "Run npm test", agentId: "agent-001", createdAt: now, updatedAt: now, requester: { actorId: "agent-001", actorType: "agent", actorName: "Agent 1" }, requestedAt: now, targetAction: { category: "command_execution", action: "bash", summary: "Run npm test", resourceType: "command", resourceId: "cmd" }, history: [] });
mockDecideApproval.mockResolvedValue({ id: "apr-1", status: "denied", actionCategory: "command_execution", actionSummary: "Run npm test", agentId: "agent-001", createdAt: now, updatedAt: now, requester: { actorId: "agent-001", actorType: "agent", actorName: "Agent 1" }, requestedAt: now, targetAction: { category: "command_execution", action: "bash", summary: "Run npm test", resourceType: "command", resourceId: "cmd" }, history: [] });
render(<MailboxView {...defaultProps} />);
await act(async () => { fireEvent.click(screen.getByTestId("mailbox-tab-approvals")); });
await act(async () => { fireEvent.click(await screen.findByTestId("mailbox-approval-item-apr-1")); });
await act(async () => { fireEvent.click(await screen.findByTestId("mailbox-approval-deny")); });
await waitFor(() => {
expect(mockDecideApproval).toHaveBeenCalledWith("apr-1", { decision: "deny", comment: undefined }, undefined);
});
});
it("surfaces the safe server decision error on the desktop approval controls", async () => {
const now = new Date().toISOString();
mockFetchInbox.mockResolvedValue({ messages: [], unreadCount: 0, total: 0 });
mockFetchApprovals.mockResolvedValue({ requests: [{ id: "apr-1", status: "pending", actionCategory: "secrets_access", actionSummary: "Read secret", agentId: "user", createdAt: now, updatedAt: now }], total: 1, pendingCount: 1 });
mockFetchApprovalDetail.mockResolvedValue({ id: "apr-1", status: "pending", actionCategory: "secrets_access", actionSummary: "Read secret", agentId: "user", createdAt: now, updatedAt: now, requester: { actorId: "user", actorType: "user", actorName: "User" }, requestedAt: now, targetAction: { category: "secrets_access", action: "read", summary: "Read secret", resourceType: "secret", resourceId: "secret" }, history: [] });
mockDecideApproval.mockRejectedValue(new Error("An approval request cannot be decided by its own requester"));
const addToast = vi.fn();
render(<MailboxView {...defaultProps} addToast={addToast} />);
await act(async () => { fireEvent.click(screen.getByTestId("mailbox-tab-approvals")); });
await act(async () => { fireEvent.click(await screen.findByTestId("mailbox-approval-item-apr-1")); });
await act(async () => { fireEvent.click(await screen.findByTestId("mailbox-approval-approve")); });
await waitFor(() => expect(addToast).toHaveBeenCalledWith("An approval request cannot be decided by its own requester", "error"));
});
it("surfaces the safe server denial error on the mobile approval controls", async () => {
const now = new Date().toISOString();
mockUseViewportMode.mockReturnValue("mobile");
mockFetchInbox.mockResolvedValue({ messages: [], unreadCount: 0, total: 0 });
mockFetchApprovals.mockResolvedValue({ requests: [{ id: "apr-1", status: "pending", actionCategory: "secrets_access", actionSummary: "Read secret", agentId: "user", createdAt: now, updatedAt: now }], total: 1, pendingCount: 1 });
mockFetchApprovalDetail.mockResolvedValue({ id: "apr-1", status: "pending", actionCategory: "secrets_access", actionSummary: "Read secret", agentId: "user", createdAt: now, updatedAt: now, requester: { actorId: "user", actorType: "user", actorName: "User" }, requestedAt: now, targetAction: { category: "secrets_access", action: "read", summary: "Read secret", resourceType: "secret", resourceId: "secret" }, history: [] });
mockDecideApproval.mockRejectedValue(new Error("An approval request cannot be decided by its own requester"));
const addToast = vi.fn();
render(<MailboxView {...defaultProps} addToast={addToast} />);
await act(async () => { fireEvent.click(screen.getByTestId("mailbox-tab-approvals")); });
await act(async () => { fireEvent.click(await screen.findByTestId("mailbox-approval-item-apr-1")); });
await act(async () => { fireEvent.click(await screen.findByTestId("mailbox-approval-deny")); });
await waitFor(() => expect(addToast).toHaveBeenCalledWith("An approval request cannot be decided by its own requester", "error"));
});
it("uses the generic decision error only for unknown rejections", async () => {
const now = new Date().toISOString();
mockFetchInbox.mockResolvedValue({ messages: [], unreadCount: 0, total: 0 });
mockFetchApprovals.mockResolvedValue({ requests: [{ id: "apr-1", status: "pending", actionCategory: "secrets_access", actionSummary: "Read secret", agentId: "agent-1", createdAt: now, updatedAt: now }], total: 1, pendingCount: 1 });
mockFetchApprovalDetail.mockResolvedValue({ id: "apr-1", status: "pending", actionCategory: "secrets_access", actionSummary: "Read secret", agentId: "agent-1", createdAt: now, updatedAt: now, requester: { actorId: "agent-1", actorType: "agent", actorName: "Agent" }, requestedAt: now, targetAction: { category: "secrets_access", action: "read", summary: "Read secret", resourceType: "secret", resourceId: "secret" }, history: [] });
mockDecideApproval.mockRejectedValue("unknown failure");
const addToast = vi.fn();
render(<MailboxView {...defaultProps} addToast={addToast} />);
await act(async () => { fireEvent.click(screen.getByTestId("mailbox-tab-approvals")); });
await act(async () => { fireEvent.click(await screen.findByTestId("mailbox-approval-item-apr-1")); });
await act(async () => { fireEvent.click(await screen.getByTestId("mailbox-approval-deny")); });
await waitFor(() => expect(addToast).toHaveBeenCalledWith("Failed to submit decision", "error"));
});
it("disables decision buttons while submission is pending", async () => {
const now = new Date().toISOString();
let resolveDecision: (() => void) | undefined;
mockFetchInbox.mockResolvedValue({ messages: [], unreadCount: 0, total: 0 });
mockFetchApprovals.mockResolvedValue({ requests: [{ id: "apr-1", status: "pending", actionCategory: "command_execution", actionSummary: "Run npm test", agentId: "agent-001", createdAt: now, updatedAt: now }], total: 1, pendingCount: 1 });
mockFetchApprovalDetail.mockResolvedValue({ id: "apr-1", status: "pending", actionCategory: "command_execution", actionSummary: "Run npm test", agentId: "agent-001", createdAt: now, updatedAt: now, requester: { actorId: "agent-001", actorType: "agent", actorName: "Agent 1" }, requestedAt: now, targetAction: { category: "command_execution", action: "bash", summary: "Run npm test", resourceType: "command", resourceId: "cmd" }, history: [] });
mockDecideApproval.mockImplementation(() => new Promise((resolve) => { resolveDecision = () => resolve({} as any); }));
render(<MailboxView {...defaultProps} />);
await act(async () => { fireEvent.click(screen.getByTestId("mailbox-tab-approvals")); });
await act(async () => { fireEvent.click(await screen.findByTestId("mailbox-approval-item-apr-1")); });
await act(async () => { fireEvent.click(await screen.findByTestId("mailbox-approval-approve")); });
expect(screen.getByTestId("mailbox-approval-approve")).toBeDisabled();
expect(screen.getByTestId("mailbox-approval-deny")).toBeDisabled();
expect(mockDecideApproval).toHaveBeenCalledTimes(1);
resolveDecision?.();
});
it("uses mobile stacked layout for approvals detail and back navigation", async () => {
const now = new Date().toISOString();
mockUseViewportMode.mockReturnValue("mobile");
mockFetchInbox.mockResolvedValue({ messages: [], unreadCount: 0, total: 0 });
mockFetchApprovals.mockResolvedValue({ requests: [{ id: "apr-1", status: "pending", actionCategory: "command_execution", actionSummary: "Run npm test", agentId: "agent-001", createdAt: now, updatedAt: now }], total: 1, pendingCount: 1 });
mockFetchApprovalDetail.mockResolvedValue({ id: "apr-1", status: "pending", actionCategory: "command_execution", actionSummary: "Run npm test", agentId: "agent-001", createdAt: now, updatedAt: now, requester: { actorId: "agent-001", actorType: "agent", actorName: "Agent 1" }, requestedAt: now, targetAction: { category: "command_execution", action: "bash", summary: "Run npm test", resourceType: "command", resourceId: "cmd" }, history: [] });
render(<MailboxView {...defaultProps} />);
await act(async () => { fireEvent.click(screen.getByTestId("mailbox-tab-approvals")); });
await act(async () => { fireEvent.click(await screen.findByTestId("mailbox-approval-item-apr-1")); });
await waitFor(() => {
expect(screen.getByTestId("mailbox-approval-detail")).toBeDefined();
expect(screen.queryByTestId("mailbox-approval-list")).toBeNull();
expect(screen.getByTestId("mailbox-approval-back-to-list")).toBeDefined();
});
await act(async () => { fireEvent.click(screen.getByTestId("mailbox-approval-back-to-list")); });
await waitFor(() => {
expect(screen.getByTestId("mailbox-approval-list")).toBeDefined();
});
});
it("updates approvals pending badge on approval:requested SSE without opening Approvals tab", async () => {
mockFetchInbox.mockResolvedValue({ messages: [], unreadCount: 0, total: 0 });
mockFetchUnreadCount
.mockResolvedValueOnce({ unreadCount: 0, pendingApprovalCount: 1 })
.mockResolvedValueOnce({ unreadCount: 0, pendingApprovalCount: 5 });
render(<MailboxView {...defaultProps} />);
await waitFor(() => {
expect(screen.getByTestId("mailbox-approvals-pending-badge")).toHaveTextContent("1");
});
const latest = sseSubscriptions.at(-1);
expect(latest).toBeDefined();
await act(async () => {
latest?.["approval:requested"]?.();
});
await waitFor(() => {
expect(screen.getByTestId("mailbox-approvals-pending-badge")).toHaveTextContent("5");
});
expect(mockFetchApprovals).not.toHaveBeenCalled();
});
it("refreshes approvals on approval SSE events", async () => {
const now = new Date().toISOString();
mockFetchInbox.mockResolvedValue({ messages: [], unreadCount: 0, total: 0 });
mockFetchApprovals.mockResolvedValue({ requests: [{ id: "apr-1", status: "pending", actionCategory: "command_execution", actionSummary: "Run npm test", agentId: "agent-001", createdAt: now, updatedAt: now }], total: 1, pendingCount: 1 });
render(<MailboxView {...defaultProps} />);
await act(async () => { fireEvent.click(screen.getByTestId("mailbox-tab-approvals")); });
const latest = sseSubscriptions.at(-1);
expect(latest).toBeDefined();
await act(async () => {
latest?.["approval:requested"]?.();
latest?.["approval:updated"]?.();
latest?.["approval:decided"]?.();
});
await waitFor(() => {
expect(mockFetchApprovals).toHaveBeenCalled();
expect(mockSubscribeSse).toHaveBeenCalled();
});
});
it("moves decided requests into history view", async () => {
const now = new Date().toISOString();
mockFetchInbox.mockResolvedValue({ messages: [], unreadCount: 0, total: 0 });
mockFetchApprovals
.mockResolvedValueOnce({ requests: [{ id: "apr-1", status: "pending", actionCategory: "command_execution", actionSummary: "Run npm test", agentId: "agent-001", createdAt: now, updatedAt: now }], total: 1, pendingCount: 1 })
.mockResolvedValueOnce({ requests: [], total: 0, pendingCount: 0 })
.mockResolvedValueOnce({ requests: [{ id: "apr-1", status: "approved", actionCategory: "command_execution", actionSummary: "Run npm test", agentId: "agent-001", createdAt: now, updatedAt: now }], total: 1, pendingCount: 0 })
.mockResolvedValue({ requests: [], total: 0, pendingCount: 0 });
mockFetchApprovalDetail.mockResolvedValue({ id: "apr-1", status: "pending", actionCategory: "command_execution", actionSummary: "Run npm test", agentId: "agent-001", createdAt: now, updatedAt: now, requester: { actorId: "agent-001", actorType: "agent", actorName: "Agent 1" }, requestedAt: now, targetAction: { category: "command_execution", action: "bash", summary: "Run npm test", resourceType: "command", resourceId: "cmd" }, history: [] });
mockDecideApproval.mockResolvedValue({ id: "apr-1", status: "approved", actionCategory: "command_execution", actionSummary: "Run npm test", agentId: "agent-001", createdAt: now, updatedAt: now, requester: { actorId: "agent-001", actorType: "agent", actorName: "Agent 1" }, requestedAt: now, targetAction: { category: "command_execution", action: "bash", summary: "Run npm test", resourceType: "command", resourceId: "cmd" }, history: [] });
render(<MailboxView {...defaultProps} />);
await act(async () => { fireEvent.click(screen.getByTestId("mailbox-tab-approvals")); });
await act(async () => { fireEvent.click(await screen.findByTestId("mailbox-approval-item-apr-1")); });
await act(async () => { fireEvent.click(await screen.findByTestId("mailbox-approval-approve")); });
await act(async () => { fireEvent.click(screen.getByTestId("mailbox-approval-filter-history")); });
await waitFor(() => {
expect(mockFetchApprovals).toHaveBeenCalled();
});
});
it("shows inbox tab as active by default", async () => {
mockFetchInbox.mockResolvedValue({
messages: [],
unreadCount: 0,
total: 0,
});
render(<MailboxView {...defaultProps} />);
const inboxTab = screen.getByTestId("mailbox-tab-inbox");
expect(inboxTab).toHaveClass("active");
});
it("loads inbox on mount", async () => {
mockFetchInbox.mockResolvedValue({
messages: [mockMessage, mockReadMessage],
unreadCount: 1,
total: 1,
});
render(<MailboxView {...defaultProps} />);
await waitFor(() => {
expect(mockFetchInbox).toHaveBeenCalled();
});
});
it("shows inbox messages after loading", async () => {
mockFetchInbox.mockResolvedValue({
messages: [mockMessage, mockReadMessage],
unreadCount: 1,
total: 1,
});
render(<MailboxView {...defaultProps} />);
await waitFor(() => {
expect(screen.getByTestId("mailbox-inbox-list")).toBeDefined();
});
});
it("renders known agent senders by name in inbox conversation rows", async () => {
mockFetchInbox.mockResolvedValue({
messages: [mockMessage],
unreadCount: 1,
total: 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,
total: 1,
});
render(<MailboxView {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText("Agent: agent-999")).toBeDefined();
});
});
it("renders separate inbox rows for independent messages from the same sender", async () => {
const secondMessage = {
...mockMessage,
id: "msg-003",
content: "Second top-level message",
metadata: undefined,
};
mockFetchInbox.mockResolvedValue({
messages: [mockMessage, secondMessage],
unreadCount: 2,
total: 2,
});
render(<MailboxView {...defaultProps} />);
await waitFor(() => {
expect(screen.getByTestId("mailbox-item-msg-001")).toBeDefined();
expect(screen.getByTestId("mailbox-item-msg-003")).toBeDefined();
expect(screen.getByTestId("mailbox-unread-dot-msg-001")).toBeDefined();
expect(screen.getByTestId("mailbox-unread-dot-msg-003")).toBeDefined();
});
});
it("opens the specific selected inbox row when same-sender messages are separate", async () => {
const secondMessage: Message = {
...mockMessage,
id: "msg-003",
content: "Second top-level message",
createdAt: new Date(Date.now() + 1000).toISOString(),
};
mockFetchInbox.mockResolvedValue({
messages: [mockMessage, secondMessage],
unreadCount: 2,
total: 2,
});
mockFetchConversation.mockResolvedValue([secondMessage]);
mockMarkMessageRead.mockResolvedValue({ ...secondMessage, read: true });
render(<MailboxView {...defaultProps} />);
await waitFor(() => {
expect(screen.getByTestId("mailbox-item-msg-003")).toBeDefined();
});
await act(async () => {
fireEvent.click(screen.getByTestId("mailbox-item-msg-003"));
});
await waitFor(() => {
expect(screen.getByTestId("mailbox-message-body")).toHaveTextContent("Second top-level message");
expect(mockMarkMessageRead).toHaveBeenCalledWith("msg-003", undefined);
});
});
it("shows unread dot for unread messages", async () => {
mockFetchInbox.mockResolvedValue({
messages: [mockMessage],
unreadCount: 1,
total: 1,
});
render(<MailboxView {...defaultProps} />);
await waitFor(() => {
expect(screen.getByTestId("mailbox-unread-dot-msg-001")).toBeDefined();
});
});
it("does not show unread dot for read messages", async () => {
mockFetchInbox.mockResolvedValue({
messages: [mockReadMessage],
unreadCount: 0,
total: 0,
});
render(<MailboxView {...defaultProps} />);
await waitFor(() => {
expect(screen.queryByTestId("mailbox-unread-dot-msg-002")).toBeNull();
});
});
it("switches to outbox tab on click", async () => {
mockFetchInbox.mockResolvedValue({
messages: [],
unreadCount: 0,
total: 0,
});
mockFetchOutbox.mockResolvedValue({
messages: [],
total: 0,
});
render(<MailboxView {...defaultProps} />);
const outboxTab = screen.getByTestId("mailbox-tab-outbox");
await act(async () => {
fireEvent.click(outboxTab);
});
expect(mockFetchOutbox).toHaveBeenCalled();
});
it("switches to agents tab on click", async () => {
mockFetchInbox.mockResolvedValue({
messages: [],
unreadCount: 0,
total: 0,
});
render(<MailboxView {...defaultProps} />);
const agentsTab = screen.getByTestId("mailbox-tab-agents");
await act(async () => {
fireEvent.click(agentsTab);
});
await waitFor(() => {
expect(mockFetchAgents).toHaveBeenCalled();
});
});
it("opens message detail when clicking a message", async () => {
mockFetchInbox.mockResolvedValue({
messages: [mockMessage],
unreadCount: 1,
total: 1,
});
mockFetchConversation.mockResolvedValue([mockMessage]);
// Mock markMessageRead to return undefined (simulating no read update needed)
mockMarkMessageRead.mockResolvedValue({ ...mockMessage, read: true });
render(<MailboxView {...defaultProps} />);
await waitFor(() => {
expect(screen.getByTestId("mailbox-item-msg-001")).toBeDefined();
});
await act(async () => {
fireEvent.click(screen.getByTestId("mailbox-item-msg-001"));
});
await waitFor(() => {
expect(screen.getByTestId("mailbox-message-detail")).toBeDefined();
});
});
it("opens markdown task links from the selected mail detail in the existing desktop tab", async () => {
const taskMessage: Message = {
...mockMessage,
id: "msg-markdown-task-link",
content: "See [FN-1234](/?project=project-1&task=FN-1234) and [external](https://example.com).",
};
const onOpenTask = vi.fn();
const user = userEvent.setup();
mockFetchInbox.mockResolvedValue(makeInboxResponse([taskMessage], 1));
mockFetchConversation.mockResolvedValue([taskMessage]);
mockMarkMessageRead.mockResolvedValue({ ...taskMessage, read: true });
render(<MailboxView {...defaultProps} onOpenTask={onOpenTask} />);
await user.click(await screen.findByTestId("mailbox-item-msg-markdown-task-link"));
const detail = await screen.findByTestId("mailbox-message-body");
const taskLink = within(detail).getByTestId("mailbox-task-link");
expect(taskLink).not.toHaveAttribute("target", "_blank");
await user.click(taskLink);
expect(onOpenTask).toHaveBeenCalledTimes(1);
expect(onOpenTask).toHaveBeenCalledWith("FN-1234");
expect(within(detail).getByRole("link", { name: "external" })).toHaveAttribute("target", "_blank");
});
it("opens task-only and planning-clarification related work from mailbox detail", async () => {
const taskMessage: Message = {
...mockMessage,
id: "msg-task-only",
fromId: "task-agent",
metadata: { taskId: "FN-8428" },
};
const planningMessage: Message = {
...mockMessage,
id: "msg-planning-clarification",
fromId: "planning-agent",
metadata: { kind: "planning-clarification", sessionId: "planning-8428", questionId: "question-8428" },
};
const onOpenTask = vi.fn();
const onOpenPlanningSession = vi.fn();
mockFetchInbox.mockResolvedValue(makeInboxResponse([taskMessage, planningMessage], 2));
mockFetchConversation.mockImplementation(async (fromId) => [fromId === taskMessage.fromId ? taskMessage : planningMessage]);
mockMarkMessageRead.mockResolvedValue({ ...taskMessage, read: true });
render(<MailboxView {...defaultProps} onOpenTask={onOpenTask} onOpenPlanningSession={onOpenPlanningSession} />);
await screen.findByTestId("mailbox-item-msg-task-only");
await act(async () => { fireEvent.click(screen.getByTestId("mailbox-item-msg-task-only")); });
fireEvent.click(await screen.findByTestId("mailbox-view-task"));
expect(onOpenTask).toHaveBeenCalledWith("FN-8428");
await act(async () => { fireEvent.click(screen.getByTestId("mailbox-item-msg-planning-clarification")); });
fireEvent.click(await screen.findByTestId("mailbox-open-planning-session"));
expect(onOpenPlanningSession).toHaveBeenCalledWith("planning-8428");
});
it("renders an inline artifact attachment in the single-message detail path", async () => {
const artifactMessage: Message = {
...mockMessage,
metadata: {
artifactId: "art-mailbox-image",
artifactType: "image",
title: "Mailbox Screenshot",
mimeType: "image/png",
taskId: "FN-1234",
},
};
const onOpenTask = vi.fn();
mockFetchInbox.mockResolvedValue(makeInboxResponse([artifactMessage], 1));
mockFetchConversation.mockResolvedValue([artifactMessage]);
mockMarkMessageRead.mockResolvedValue({ ...artifactMessage, read: true });
render(<MailboxView {...defaultProps} projectId="project-a" onOpenTask={onOpenTask} />);
await waitFor(() => {
expect(screen.getByTestId("mailbox-item-msg-001")).toBeDefined();
});
await act(async () => {
fireEvent.click(screen.getByTestId("mailbox-item-msg-001"));
});
await waitFor(() => {
expect(screen.getByTestId("mailbox-message-body")).toHaveTextContent(artifactMessage.content);
expect(screen.getByTestId("mailbox-artifact-attachment")).toBeInTheDocument();
expect(screen.getByRole("img", { name: "Mailbox Screenshot" })).toHaveAttribute("src", "/api/artifacts/art-mailbox-image/media?projectId=project-a&fn_token=daemon-token");
expect(screen.getByRole("link", { name: "Open artifact: Mailbox Screenshot" })).toHaveAttribute("href", "/api/artifacts/art-mailbox-image/media?projectId=project-a&fn_token=daemon-token");
expect(screen.getByTestId("mailbox-view-task")).toBeInTheDocument();
expect(screen.queryByTestId("mailbox-artifact-view-task")).toBeNull();
});
fireEvent.click(screen.getByTestId("mailbox-view-task"));
expect(onOpenTask).toHaveBeenCalledWith("FN-1234");
});
it("does not render a View task affordance for artifact messages without task metadata", async () => {
const artifactMessage: Message = {
...mockMessage,
metadata: {
artifactId: "art-mailbox-image",
artifactType: "image",
title: "Mailbox Screenshot",
},
};
mockFetchInbox.mockResolvedValue(makeInboxResponse([artifactMessage], 1));
mockFetchConversation.mockResolvedValue([artifactMessage]);
mockMarkMessageRead.mockResolvedValue({ ...artifactMessage, read: true });
render(<MailboxView {...defaultProps} onOpenTask={vi.fn()} />);
await waitFor(() => {
expect(screen.getByTestId("mailbox-item-msg-001")).toBeDefined();
});
await act(async () => {
fireEvent.click(screen.getByTestId("mailbox-item-msg-001"));
});
await waitFor(() => {
expect(screen.getByTestId("mailbox-artifact-attachment")).toBeInTheDocument();
expect(screen.queryByTestId("mailbox-artifact-view-task")).toBeNull();
});
});
it("renders no artifact attachment for messages without artifact metadata", async () => {
mockFetchInbox.mockResolvedValue(makeInboxResponse([mockMessage], 1));
mockFetchConversation.mockResolvedValue([mockMessage]);
mockMarkMessageRead.mockResolvedValue({ ...mockMessage, read: true });
render(<MailboxView {...defaultProps} />);
await waitFor(() => {
expect(screen.getByTestId("mailbox-item-msg-001")).toBeDefined();
});
await act(async () => {
fireEvent.click(screen.getByTestId("mailbox-item-msg-001"));
});
await waitFor(() => {
expect(screen.getByTestId("mailbox-message-body")).toHaveTextContent(mockMessage.content);
expect(screen.queryByTestId("mailbox-artifact-attachment")).toBeNull();
});
});
it("renders artifact attachments inside conversation thread messages", async () => {
const rootMessage: Message = {
...mockMessage,
id: "msg-artifact-root",
content: "Artifact root",
};
const artifactReply: Message = {
...mockMessage,
id: "msg-artifact-reply",
content: "New image artifact registered: Thread Image",
metadata: {
replyTo: { messageId: "msg-artifact-root" },
artifactId: "art-thread-image",
artifactType: "image",
title: "Thread Image",
taskId: "FN-5678",
},
read: true,
};
const onOpenTask = vi.fn();
mockFetchInbox.mockResolvedValue(makeInboxResponse([rootMessage], 1));
mockFetchConversation.mockResolvedValue([rootMessage, artifactReply]);
mockMarkMessageRead.mockResolvedValue({ ...rootMessage, read: true });
render(<MailboxView {...defaultProps} onOpenTask={onOpenTask} />);
await waitFor(() => {
expect(screen.getByTestId("mailbox-item-msg-artifact-root")).toBeDefined();
});
await act(async () => {
fireEvent.click(screen.getByTestId("mailbox-item-msg-artifact-root"));
});
await waitFor(() => {
expect(screen.getByTestId("mailbox-conversation")).toBeInTheDocument();
expect(screen.getByTestId("mailbox-artifact-attachment")).toBeInTheDocument();
expect(screen.getByRole("img", { name: "Thread Image" })).toHaveAttribute("src", "/api/artifacts/art-thread-image/media?fn_token=daemon-token");
expect(screen.getByTestId("mailbox-view-task")).toBeInTheDocument();
expect(screen.queryByTestId("mailbox-artifact-view-task")).toBeNull();
});
fireEvent.click(screen.getByTestId("mailbox-view-task"));
expect(onOpenTask).toHaveBeenCalledWith("FN-5678");
});
it("keeps list pane visible alongside detail pane on desktop/tablet", async () => {
mockFetchInbox.mockResolvedValue({
messages: [mockMessage],
unreadCount: 1,
total: 1,
});
mockFetchConversation.mockResolvedValue([mockMessage]);
mockMarkMessageRead.mockResolvedValue({ ...mockMessage, read: true });
render(<MailboxView {...defaultProps} />);
await waitFor(() => {
expect(screen.getByTestId("mailbox-split-layout")).toBeDefined();
expect(screen.getByTestId("mailbox-inbox-list")).toBeDefined();
});
await act(async () => {
fireEvent.click(screen.getByTestId("mailbox-item-msg-001"));
});
await waitFor(() => {
expect(screen.getByTestId("mailbox-message-detail")).toBeDefined();
expect(screen.getByTestId("mailbox-inbox-list")).toBeDefined();
expect(screen.queryByTestId("mailbox-back-to-list")).toBeNull();
});
});
it("shows split-pane empty state when no message is selected on desktop/tablet", async () => {
mockFetchInbox.mockResolvedValue({
messages: [mockMessage],
unreadCount: 1,
total: 1,
});
render(<MailboxView {...defaultProps} />);
await waitFor(() => {
expect(screen.getByTestId("mailbox-split-empty")).toBeDefined();
expect(screen.getByTestId("mailbox-inbox-list")).toBeDefined();
});
});
it("applies visual viewport CSS variables when mobile keyboard is open", async () => {
mockUseViewportMode.mockReturnValue("mobile");
mockUseMobileKeyboard.mockReturnValue({
keyboardOverlap: 240,
viewportHeight: 480,
viewportOffsetTop: 32,
keyboardOpen: true,
});
mockFetchInbox.mockResolvedValue({
messages: [],
unreadCount: 0,
total: 0,
});
render(<MailboxView {...defaultProps} />);
const mailboxView = await screen.findByTestId("mailbox-view");
expect(mailboxView.getAttribute("style")).toContain("--vv-offset-top: 32px");
expect(mailboxView.getAttribute("style")).toContain("--vv-height: 480px");
});
it("dismisses a mobile message detail on browser popstate and drains its nav entry", async () => {
mockUseViewportMode.mockReturnValue("mobile");
mockFetchInbox.mockResolvedValue(makeInboxResponse([mockMessage], 1));
mockFetchConversation.mockResolvedValue([mockMessage]);
mockMarkMessageRead.mockResolvedValue({ ...mockMessage, read: true });
render(<HistoryHarness><MailboxView {...defaultProps} /></HistoryHarness>);
await screen.findByTestId("mailbox-item-msg-001");
fireEvent.click(screen.getByTestId("mailbox-item-msg-001"));
await screen.findByTestId("mailbox-message-detail");
act(() => {
window.dispatchEvent(new PopStateEvent("popstate", { state: { navIndex: 0 } }));
});
await waitFor(() => {
expect(screen.queryByTestId("mailbox-message-detail")).toBeNull();
expect(screen.getByTestId("mailbox-item-msg-001")).toBeDefined();
});
act(() => {
window.dispatchEvent(new PopStateEvent("popstate", { state: { navIndex: 0 } }));
});
expect(screen.queryByTestId("mailbox-message-detail")).toBeNull();
});
it("routes Android native Back through popstate before dismissing a mobile message", async () => {
mockUseViewportMode.mockReturnValue("mobile");
mockFetchInbox.mockResolvedValue(makeInboxResponse([mockMessage], 1));
mockFetchConversation.mockResolvedValue([mockMessage]);
mockMarkMessageRead.mockResolvedValue({ ...mockMessage, read: true });
const backSpy = vi.spyOn(window.history, "back").mockImplementation(() => {});
try {
render(<HistoryHarness><MailboxView {...defaultProps} /></HistoryHarness>);
await screen.findByTestId("mailbox-item-msg-001");
fireEvent.click(screen.getByTestId("mailbox-item-msg-001"));
await screen.findByTestId("mailbox-message-detail");
const nativeBack = new CustomEvent("fusion:native-back", { cancelable: true });
expect(window.dispatchEvent(nativeBack)).toBe(false);
expect(backSpy).toHaveBeenCalledTimes(1);
expect(screen.getByTestId("mailbox-message-detail")).toBeDefined();
act(() => {
window.dispatchEvent(new PopStateEvent("popstate", { state: { navIndex: 0 } }));
});
await waitFor(() => expect(screen.queryByTestId("mailbox-message-detail")).toBeNull());
} finally {
backSpy.mockRestore();
}
});
it.each([
["the in-pane Back button", async () => fireEvent.click(screen.getByTestId("mailbox-back-to-list"))],
["delete", async () => {
fireEvent.click(screen.getByTestId("mailbox-delete"));
fireEvent.click(screen.getByTestId("mailbox-delete-confirm"));
}],
["an agent tab switch", async () => fireEvent.click(screen.getByTestId("mailbox-tab-outbox"))],
])("consumes the message entry before %s", async (_label, close) => {
mockUseViewportMode.mockReturnValue("mobile");
mockFetchInbox.mockResolvedValue(makeInboxResponse([mockMessage], 1));
mockFetchConversation.mockResolvedValue([mockMessage]);
mockMarkMessageRead.mockResolvedValue({ ...mockMessage, read: true });
mockDeleteMessage.mockResolvedValue(undefined);
const historyRef: { current: UseNavigationHistoryResult | null } = { current: null };
const sentinel = vi.fn();
const backSpy = vi.spyOn(window.history, "back").mockImplementation(() => {});
try {
render(<HistoryHarness historyRef={historyRef}><MailboxView {...defaultProps} /></HistoryHarness>);
await screen.findByTestId("mailbox-item-msg-001");
await waitFor(() => expect(historyRef.current).not.toBeNull());
historyRef.current?.pushNav({ type: "modal", close: sentinel });
fireEvent.click(screen.getByTestId("mailbox-item-msg-001"));
await screen.findByTestId("mailbox-message-detail");
await close();
await waitFor(() => expect(screen.queryByTestId("mailbox-message-detail")).toBeNull());
expect(backSpy).toHaveBeenCalledTimes(1);
act(() => {
window.dispatchEvent(new PopStateEvent("popstate", { state: { navIndex: 1 } }));
window.dispatchEvent(new PopStateEvent("popstate", { state: { navIndex: 0 } }));
});
expect(sentinel).toHaveBeenCalledTimes(1);
} finally {
backSpy.mockRestore();
}
});
it("consumes the message entry before reply replaces it with the composer", async () => {
mockUseViewportMode.mockReturnValue("mobile");
mockFetchInbox.mockResolvedValue(makeInboxResponse([mockMessage], 1));
mockFetchConversation.mockResolvedValue([mockMessage]);
mockMarkMessageRead.mockResolvedValue({ ...mockMessage, read: true });
const historyRef: { current: UseNavigationHistoryResult | null } = { current: null };
const sentinel = vi.fn();
const backSpy = vi.spyOn(window.history, "back").mockImplementation(() => {});
try {
render(<HistoryHarness historyRef={historyRef}><MailboxView {...defaultProps} /></HistoryHarness>);
await screen.findByTestId("mailbox-item-msg-001");
await waitFor(() => expect(historyRef.current).not.toBeNull());
historyRef.current?.pushNav({ type: "modal", close: sentinel });
fireEvent.click(screen.getByTestId("mailbox-item-msg-001"));
await screen.findByTestId("mailbox-message-detail");
fireEvent.click(screen.getByTestId("mailbox-reply"));
await screen.findByTestId("message-composer");
fireEvent.click(screen.getByTestId("message-composer-cancel"));
await waitFor(() => expect(screen.queryByTestId("message-composer")).toBeNull());
act(() => {
window.dispatchEvent(new PopStateEvent("popstate", { state: { navIndex: 1 } }));
window.dispatchEvent(new PopStateEvent("popstate", { state: { navIndex: 0 } }));
});
expect(sentinel).toHaveBeenCalledTimes(1);
} finally {
backSpy.mockRestore();
}
});
it("does not push a nav entry for desktop split-pane message selection", async () => {
mockUseViewportMode.mockReturnValue("desktop");
mockFetchInbox.mockResolvedValue(makeInboxResponse([mockMessage], 1));
mockFetchConversation.mockResolvedValue([mockMessage]);
mockMarkMessageRead.mockResolvedValue({ ...mockMessage, read: true });
const pushStateSpy = vi.spyOn(window.history, "pushState");
try {
render(<HistoryHarness><MailboxView {...defaultProps} /></HistoryHarness>);
await screen.findByTestId("mailbox-item-msg-001");
fireEvent.click(screen.getByTestId("mailbox-item-msg-001"));
await screen.findByTestId("mailbox-message-detail");
expect(pushStateSpy).not.toHaveBeenCalled();
} finally {
pushStateSpy.mockRestore();
}
});
it("keeps mobile single-pane flow for detail open and back navigation", async () => {
mockUseViewportMode.mockReturnValue("mobile");
mockFetchInbox.mockResolvedValue({
messages: [mockMessage],
unreadCount: 1,
total: 1,
});
mockFetchConversation.mockResolvedValue([mockMessage]);
mockMarkMessageRead.mockResolvedValue({ ...mockMessage, read: true });
render(<MailboxView {...defaultProps} />);
await waitFor(() => {
expect(screen.queryByTestId("mailbox-split-layout")).toBeNull();
expect(screen.getByTestId("mailbox-inbox-list")).toBeDefined();
});
await act(async () => {
fireEvent.click(screen.getByTestId("mailbox-item-msg-001"));
});
await waitFor(() => {
expect(screen.getByTestId("mailbox-message-detail")).toBeDefined();
expect(screen.queryByTestId("mailbox-inbox-list")).toBeNull();
expect(screen.getByTestId("mailbox-back-to-list")).toBeDefined();
});
await act(async () => {
fireEvent.click(screen.getByTestId("mailbox-back-to-list"));
});
await waitFor(() => {
expect(screen.queryByTestId("mailbox-message-detail")).toBeNull();
expect(screen.getByTestId("mailbox-inbox-list")).toBeDefined();
});
});
it("keeps a manually selected mobile message after a stale deep link and refresh", async () => {
mockUseViewportMode.mockReturnValue("mobile");
window.history.replaceState({}, "", "?view=mailbox&mailbox-message=msg-001#message-msg-001");
const clickedMessage: Message = {
...mockReadMessage,
read: false,
content: "Newer mobile selection body",
fromId: mockMessage.fromId,
fromType: mockMessage.fromType,
};
mockFetchInbox
.mockResolvedValueOnce(makeInboxResponse([mockMessage, clickedMessage], 1))
.mockResolvedValue(makeInboxResponse([mockMessage, clickedMessage], 1));
const staleThread = [mockMessage];
mockFetchConversation.mockResolvedValue(staleThread);
mockMarkMessageRead.mockImplementation(async (messageId) => ({
...(messageId === clickedMessage.id ? clickedMessage : mockMessage),
read: true,
}));
render(<MailboxView {...defaultProps} />);
await waitFor(() => {
expect(screen.getByTestId("mailbox-message-body")).toHaveTextContent(mockMessage.content);
expect(mockMarkMessageRead).toHaveBeenCalledWith("msg-001", undefined);
});
await act(async () => {
fireEvent.click(screen.getByTestId("mailbox-back-to-list"));
});
await waitFor(() => {
expect(screen.getByTestId("mailbox-item-msg-002")).toBeDefined();
});
await act(async () => {
fireEvent.click(screen.getByTestId("mailbox-item-msg-002"));
});
await waitFor(() => {
expect(screen.getByTestId("mailbox-message-body")).toHaveTextContent("Newer mobile selection body");
expect(mockMarkMessageRead).toHaveBeenCalledWith("msg-002", undefined);
expect(mockFetchConversation).toHaveBeenLastCalledWith(clickedMessage.fromId, clickedMessage.fromType, undefined);
});
await act(async () => {
sseSubscriptions.at(-1)?.["message:received"]?.();
});
await waitFor(() => {
expect(screen.getByTestId("mailbox-message-body")).toHaveTextContent("Newer mobile selection body");
expect(screen.getByTestId("mailbox-message-detail")).toHaveAttribute("id", "mailbox-detail-message-msg-002");
});
});
it("lets desktop split-pane row selection override a stale deep link", async () => {
window.history.replaceState({}, "", "?view=mailbox&mailbox-message=msg-001#message-msg-001");
const clickedMessage: Message = {
...mockReadMessage,
read: false,
content: "Desktop selected message body",
fromId: mockMessage.fromId,
fromType: mockMessage.fromType,
};
mockFetchInbox.mockResolvedValue(makeInboxResponse([mockMessage, clickedMessage], 1));
mockFetchConversation.mockResolvedValue([mockMessage]);
mockMarkMessageRead.mockImplementation(async (messageId) => ({
...(messageId === clickedMessage.id ? clickedMessage : mockMessage),
read: true,
}));
render(<MailboxView {...defaultProps} />);
await waitFor(() => {
expect(screen.getByTestId("mailbox-message-body")).toHaveTextContent(mockMessage.content);
expect(screen.getByTestId("mailbox-inbox-list")).toBeDefined();
});
await act(async () => {
fireEvent.click(screen.getByTestId("mailbox-item-msg-002"));
});
await waitFor(() => {
expect(screen.getByTestId("mailbox-message-body")).toHaveTextContent("Desktop selected message body");
expect(screen.getByTestId("mailbox-message-detail")).toHaveAttribute("id", "mailbox-detail-message-msg-002");
});
});
it("keeps tab changes from restoring a consumed mailbox deep link", async () => {
window.history.replaceState({}, "", "?view=mailbox&mailbox-message=msg-001#message-msg-001");
mockFetchInbox.mockResolvedValue(makeInboxResponse([mockMessage], 1));
mockFetchOutbox.mockResolvedValue(makeOutboxResponse([]));
mockFetchConversation.mockResolvedValue([mockMessage]);
mockMarkMessageRead.mockResolvedValue({ ...mockMessage, read: true });
render(<MailboxView {...defaultProps} />);
await waitFor(() => {
expect(screen.getByTestId("mailbox-message-detail")).toHaveAttribute("id", "mailbox-detail-message-msg-001");
});
await act(async () => {
fireEvent.click(screen.getByTestId("mailbox-tab-outbox"));
});
await waitFor(() => {
expect(screen.getByTestId("mailbox-outbox-empty")).toBeDefined();
expect(screen.queryByTestId("mailbox-message-detail")).toBeNull();
});
});
it("ignores unknown deep links and empty inboxes without fabricating a selected message", async () => {
window.history.replaceState({}, "", "?view=mailbox&mailbox-message=missing#message-missing");
mockFetchInbox.mockResolvedValue(makeInboxResponse([], 0));
mockFetchConversation.mockResolvedValue([]);
render(<MailboxView {...defaultProps} />);
await waitFor(() => {
expect(screen.getByTestId("mailbox-inbox-empty")).toBeDefined();
});
expect(screen.queryByTestId("mailbox-message-detail")).toBeNull();
expect(mockMarkMessageRead).not.toHaveBeenCalled();
expect(mockFetchConversation).not.toHaveBeenCalled();
});
it("shows agent names in message detail participant rows", async () => {
mockFetchInbox.mockResolvedValue({
messages: [mockAgentToAgentMessage],
unreadCount: 1,
total: 1,
});
mockFetchConversation.mockResolvedValue([mockAgentToAgentMessage]);
mockMarkMessageRead.mockResolvedValue({ ...mockMessage, read: true });
render(<MailboxView {...defaultProps} />);
await waitFor(() => {
expect(screen.getByTestId("mailbox-item-msg-004")).toBeDefined();
});
await act(async () => {
fireEvent.click(screen.getByTestId("mailbox-item-msg-004"));
});
await waitFor(() => {
expect(screen.getAllByText("Agent: Test Agent 1 (agent-001)").length).toBeGreaterThan(0);
expect(screen.getByText("Agent: Test Agent 2 (agent-002)")).toBeDefined();
});
});
it("marks message as read when opening unread message", async () => {
mockFetchInbox.mockResolvedValue({
messages: [mockMessage],
unreadCount: 1,
total: 1,
});
mockMarkMessageRead.mockResolvedValue({ ...mockMessage, read: true });
mockFetchConversation.mockResolvedValue([mockMessage]);
const onUnreadCountChange = vi.fn();
render(<MailboxView {...defaultProps} onUnreadCountChange={onUnreadCountChange} />);
await waitFor(() => {
expect(screen.getByTestId("mailbox-item-msg-001")).toBeDefined();
});
await act(async () => {
fireEvent.click(screen.getByTestId("mailbox-item-msg-001"));
});
await waitFor(() => {
expect(mockMarkMessageRead).toHaveBeenCalledWith("msg-001", undefined);
});
});
it("calls markAllMessagesRead when clicking mark all read", async () => {
mockFetchInbox.mockResolvedValue({
messages: [mockMessage],
unreadCount: 1,
total: 1,
});
mockMarkAllMessagesRead.mockResolvedValue({ markedAsRead: 1 });
const onUnreadCountChange = vi.fn();
render(<MailboxView {...defaultProps} onUnreadCountChange={onUnreadCountChange} />);
await waitFor(() => {
expect(screen.getByTestId("mailbox-mark-all-read")).toBeDefined();
});
const markAllReadButton = screen.getByTestId("mailbox-mark-all-read");
expect(markAllReadButton).toHaveClass("btn", "btn-sm", "btn-secondary");
await act(async () => {
fireEvent.click(markAllReadButton);
});
await waitFor(() => {
expect(mockMarkAllMessagesRead).toHaveBeenCalledWith(undefined);
expect(onUnreadCountChange).toHaveBeenCalledWith(0);
});
});
it("deletes message when clicking delete in detail view", async () => {
mockFetchInbox.mockResolvedValue({
messages: [mockMessage],
unreadCount: 1,
total: 1,
});
mockDeleteMessage.mockResolvedValue(undefined);
mockFetchConversation.mockResolvedValue([mockMessage]);
render(<MailboxView {...defaultProps} />);
await waitFor(() => {
expect(screen.getByTestId("mailbox-item-msg-001")).toBeDefined();
});
await act(async () => {
fireEvent.click(screen.getByTestId("mailbox-item-msg-001"));
});
await waitFor(() => {
expect(screen.getByTestId("mailbox-message-detail")).toBeDefined();
});
const deleteButton = screen.getByTestId("mailbox-delete");
expect(screen.queryByTestId("mailbox-back-to-list")).toBeNull();
expect(deleteButton).toHaveClass("btn", "btn-sm", "btn-secondary");
await act(async () => {
fireEvent.click(deleteButton);
});
expect(mockDeleteMessage).not.toHaveBeenCalled();
await act(async () => {
fireEvent.click(screen.getByTestId("mailbox-delete-confirm"));
});
await waitFor(() => {
expect(mockDeleteMessage).toHaveBeenCalledWith("msg-001", undefined);
});
});
it("opens reply composer with linked reply context and sends metadata", async () => {
mockFetchInbox.mockResolvedValue({
messages: [mockMessage],
unreadCount: 1,
total: 1,
});
mockFetchConversation.mockResolvedValue([mockMessage]);
mockMarkMessageRead.mockResolvedValue({ ...mockMessage, read: true });
render(<MailboxView {...defaultProps} />);
await waitFor(() => {
expect(screen.getByTestId("mailbox-item-msg-001")).toBeDefined();
});
await act(async () => {
fireEvent.click(screen.getByTestId("mailbox-item-msg-001"));
});
await waitFor(() => {
expect(screen.getByTestId("mailbox-reply")).toBeDefined();
});
const replyButton = screen.getByTestId("mailbox-reply");
expect(replyButton).toHaveClass("btn", "btn-sm", "btn-secondary");
await act(async () => {
fireEvent.click(replyButton);
});
await waitFor(() => {
expect(screen.getByTestId("message-composer")).toBeDefined();
expect(screen.getByTestId("message-composer-reply-context")).toBeDefined();
});
await act(async () => {
fireEvent.change(screen.getByTestId("message-composer-content"), { target: { value: "Thanks for the update." } });
});
await act(async () => {
fireEvent.click(screen.getByTestId("message-composer-send"));
});
await waitFor(() => {
expect(mockSendMessage).toHaveBeenCalledWith(
expect.objectContaining({
toId: "agent-001",
toType: "agent",
type: "user-to-agent",
metadata: { replyTo: { messageId: "msg-001" } },
}),
undefined,
);
});
});
it("renders reply context inside conversation thread", async () => {
const agentMessage: Message = {
...mockMessage,
id: "msg-thread-root",
content: "Can you share your current status?",
};
const userReply: Message = {
...mockMessage,
id: "msg-thread-reply",
fromId: "dashboard",
fromType: "user",
toId: "agent-001",
toType: "agent",
type: "user-to-agent",
content: "Status: still investigating",
metadata: { replyTo: { messageId: "msg-thread-root" } },
read: true,
};
mockFetchInbox.mockResolvedValue({
messages: [agentMessage],
unreadCount: 1,
total: 1,
});
mockFetchConversation.mockResolvedValue([agentMessage, userReply]);
mockMarkMessageRead.mockResolvedValue({ ...mockMessage, read: true });
render(<MailboxView {...defaultProps} />);
await waitFor(() => {
expect(screen.getByTestId("mailbox-item-msg-thread-root")).toBeDefined();
});
await act(async () => {
fireEvent.click(screen.getByTestId("mailbox-item-msg-thread-root"));
});
await waitFor(() => {
expect(screen.getByTestId("mailbox-conversation")).toBeDefined();
const replyContext = screen.getByTestId("mailbox-reply-context-msg-thread-reply");
expect(replyContext).toBeDefined();
expect(replyContext).toHaveClass("mailbox-reply-context-static");
expect(screen.getByText(/Replying to Can you share your current status\?/)).toBeDefined();
});
});
it("does not pull unrelated same-sender messages into selected detail thread", async () => {
const root: Message = {
...mockMessage,
id: "msg-thread-root-only",
content: "Root request",
};
const unrelated: Message = {
...mockMessage,
id: "msg-unrelated",
content: "Independent update",
createdAt: new Date(Date.now() + 10_000).toISOString(),
};
mockFetchInbox.mockResolvedValue({
messages: [root],
unreadCount: 1,
total: 1,
});
mockFetchConversation.mockResolvedValue([root, unrelated]);
mockMarkMessageRead.mockResolvedValue({ ...root, read: true });
render(<MailboxView {...defaultProps} />);
await waitFor(() => {
expect(screen.getByTestId("mailbox-item-msg-thread-root-only")).toBeDefined();
});
await act(async () => {
fireEvent.click(screen.getByTestId("mailbox-item-msg-thread-root-only"));
});
await waitFor(() => {
expect(screen.queryByTestId("mailbox-conversation")).toBeNull();
expect(screen.getByTestId("mailbox-message-body")).toHaveTextContent("Root request");
expect(screen.queryByText("Independent update")).toBeNull();
});
});
it("renders selected-message reply context with dedicated styling", async () => {
const replyMessage: Message = {
...mockMessage,
id: "msg-reply-single",
content: "I have the answer now",
metadata: { replyTo: { messageId: "msg-root-single" } },
read: true,
};
mockFetchInbox.mockResolvedValue({
messages: [replyMessage],
unreadCount: 0,
total: 0,
});
mockFetchConversation.mockResolvedValue([replyMessage]);
render(<MailboxView {...defaultProps} />);
await waitFor(() => {
expect(screen.getByTestId("mailbox-item-msg-reply-single")).toBeDefined();
});
await act(async () => {
fireEvent.click(screen.getByTestId("mailbox-item-msg-reply-single"));
});
await waitFor(() => {
const replyContext = screen.getByTestId("mailbox-selected-reply-context");
expect(replyContext).toBeDefined();
expect(replyContext).toHaveClass("mailbox-reply-context-static");
expect(screen.getByTestId("mailbox-message-body")).toHaveTextContent("I have the answer now");
});
});
it("shows compose button in header on inbox tab", async () => {
mockFetchInbox.mockResolvedValue({
messages: [],
unreadCount: 0,
total: 0,
});
render(<MailboxView {...defaultProps} />);
await waitFor(() => {
expect(screen.getByTestId("mailbox-header-compose")).toBeDefined();
});
const headerComposeButton = screen.getByTestId("mailbox-header-compose");
expect(headerComposeButton).toHaveClass("btn", "btn-sm", "btn-primary");
});
it("shows compose button in header on agents tab", async () => {
mockFetchInbox.mockResolvedValue({
messages: [],
unreadCount: 0,
total: 0,
});
render(<MailboxView {...defaultProps} />);
const agentsTab = screen.getByTestId("mailbox-tab-agents");
await act(async () => {
fireEvent.click(agentsTab);
});
await waitFor(() => {
expect(screen.getByTestId("mailbox-header-compose")).toBeDefined();
});
});
it("renders mailbox tabs and agent subtabs with shared button classes", async () => {
mockFetchInbox.mockResolvedValue({
messages: [],
unreadCount: 0,
total: 0,
});
mockFetchAgentMailbox.mockResolvedValue({
ownerId: "agent-001",
ownerType: "agent",
unreadCount: 0,
messages: [],
inbox: [],
outbox: [],
});
render(<MailboxView {...defaultProps} />);
expect(screen.getByTestId("mailbox-tab-inbox")).toHaveClass("btn", "btn-sm", "btn-secondary", "mailbox-tab");
expect(screen.getByTestId("mailbox-tab-outbox")).toHaveClass("btn", "btn-sm", "btn-secondary", "mailbox-tab");
expect(screen.getByTestId("mailbox-tab-agents")).toHaveClass("btn", "btn-sm", "btn-secondary", "mailbox-tab");
await act(async () => {
fireEvent.click(screen.getByTestId("mailbox-tab-agents"));
});
fireEvent.change(screen.getByTestId("mailbox-agent-select"), { target: { value: "agent-001" } });
await waitFor(() => {
expect(screen.getByTestId("mailbox-agent-subtabs")).toBeDefined();
});
expect(screen.getByTestId("mailbox-agent-subtab-inbox")).toHaveClass("btn", "btn-sm", "btn-secondary", "mailbox-agent-subtab");
expect(screen.getByTestId("mailbox-agent-subtab-outbox")).toHaveClass("btn", "btn-sm", "btn-secondary", "mailbox-agent-subtab");
});
it("shows loading skeleton while loading", async () => {
mockFetchInbox.mockImplementation(() => new Promise(() => {})); // Never resolves
render(<MailboxView {...defaultProps} />);
await waitFor(() => {
expect(screen.getByTestId("mailbox-skeleton")).toBeDefined();
});
});
it("shows empty inbox state when no messages", async () => {
mockFetchInbox.mockResolvedValue({
messages: [],
unreadCount: 0,
total: 0,
});
render(<MailboxView {...defaultProps} />);
await waitFor(() => {
expect(screen.getByTestId("mailbox-inbox-empty")).toBeDefined();
});
});
it("passes projectId to API calls", async () => {
mockFetchInbox.mockResolvedValue({
messages: [],
unreadCount: 0,
total: 0,
});
render(<MailboxView {...defaultProps} projectId="test-project" />);
await waitFor(() => {
expect(mockFetchInbox).toHaveBeenCalledWith(
expect.objectContaining({ limit: 50 }),
"test-project"
);
expect(mockFetchUnreadCount).toHaveBeenCalledWith("test-project");
});
});
it("passes projectId to fetchAgents in agents tab", async () => {
mockFetchInbox.mockResolvedValue({
messages: [],
unreadCount: 0,
total: 0,
});
render(<MailboxView {...defaultProps} projectId="test-project" />);
// Switch to agents tab
const agentsTab = screen.getByTestId("mailbox-tab-agents");
await act(async () => {
fireEvent.click(agentsTab);
});
await waitFor(() => {
expect(mockFetchAgents).toHaveBeenCalledWith(undefined, "test-project");
});
});
it("calls onUnreadCountChange when unread count changes", async () => {
mockFetchInbox.mockResolvedValue({
messages: [],
unreadCount: 5,
total: 5,
});
const onUnreadCountChange = vi.fn();
render(<MailboxView {...defaultProps} onUnreadCountChange={onUnreadCountChange} />);
await waitFor(() => {
expect(onUnreadCountChange).toHaveBeenCalledWith(5);
});
});
it("shows MessageComposer with agents when clicking compose button from header", async () => {
mockFetchInbox.mockResolvedValue({
messages: [],
unreadCount: 0,
total: 0,
});
render(<MailboxView {...defaultProps} />);
// Verify compose button is visible in header
await waitFor(() => {
expect(screen.getByTestId("mailbox-header-compose")).toBeDefined();
});
// Click compose button
await act(async () => {
fireEvent.click(screen.getByTestId("mailbox-header-compose"));
});
// Verify MessageComposer is shown
await waitFor(() => {
expect(screen.getByTestId("message-composer")).toBeDefined();
});
// Verify agents are available (not "No agents available")
// The select should have agents as options, not just the placeholder
const recipientSelect = screen.getByTestId("message-composer-recipient");
expect(recipientSelect).toBeDefined();
// Should have agents option, not just "No agents available" placeholder
expect(screen.queryByText("No agents available")).toBeNull();
// Should show the mock agents
expect(screen.getByText("Test Agent 1")).toBeDefined();
expect(screen.getByText("Test Agent 2")).toBeDefined();
});
describe("agent mailbox sub-tabs", () => {
it("FN-4103 defaults the Agents tab selector to All agents without a placeholder", async () => {
mockFetchInbox.mockResolvedValue({ messages: [], unreadCount: 0, total: 0 });
mockFetchAllAgentMailbox.mockResolvedValue({ messages: [], total: 0, unreadCount: 0 });
render(<MailboxView {...defaultProps} />);
await act(async () => {
fireEvent.click(screen.getByTestId("mailbox-tab-agents"));
});
await waitFor(() => {
expect(mockFetchAllAgentMailbox).toHaveBeenCalledWith(undefined);
});
const agentSelect = screen.getByTestId("mailbox-agent-select") as HTMLSelectElement;
expect(agentSelect.value).toBe("__all_agents__");
const firstOption = agentSelect.options[0];
expect(firstOption?.value).toBe("__all_agents__");
expect(firstOption?.text).toBe("All agents");
expect(screen.queryByText("Select an agent to view their mailbox")).toBeNull();
expect(screen.getByText("No agent-to-agent messages")).toBeDefined();
});
it("shows All agents option and renders aggregate stream without subtabs", async () => {
const aggregateMessage: Message = {
...mockAgentToAgentMessage,
id: "msg-all-agents",
fromId: "agent-001",
toId: "agent-002",
type: "agent-to-agent",
};
mockFetchInbox.mockResolvedValue({ messages: [], unreadCount: 0, total: 0 });
mockFetchAllAgentMailbox.mockResolvedValue({
messages: [aggregateMessage],
total: 1,
unreadCount: 1,
});
mockFetchConversation.mockResolvedValue([aggregateMessage]);
render(<MailboxView {...defaultProps} />);
await act(async () => {
fireEvent.click(screen.getByTestId("mailbox-tab-agents"));
});
await waitFor(() => {
expect(mockFetchAllAgentMailbox).toHaveBeenCalledWith(undefined);
expect(screen.queryByTestId("mailbox-agent-subtabs")).toBeNull();
expect(screen.getByTestId("mailbox-item-participants-msg-all-agents")).toHaveTextContent("From: Agent: Test Agent 1 (agent-001)");
expect(screen.getByTestId("mailbox-item-participants-msg-all-agents")).toHaveTextContent("To: Agent: Test Agent 2 (agent-002)");
});
});
it("does not preselect a recipient when composing from All agents", async () => {
mockFetchInbox.mockResolvedValue({ messages: [], unreadCount: 0, total: 0 });
mockFetchAllAgentMailbox.mockResolvedValue({ messages: [], total: 0, unreadCount: 0 });
render(<MailboxView {...defaultProps} />);
await act(async () => {
fireEvent.click(screen.getByTestId("mailbox-tab-agents"));
});
await act(async () => {
fireEvent.click(screen.getByTestId("mailbox-compose-btn"));
});
await waitFor(() => {
const recipientSelect = screen.getByTestId("message-composer-recipient") as HTMLSelectElement;
expect(recipientSelect.value).toBe("");
});
});
it("refreshes all-agents mailbox on message SSE events", async () => {
mockFetchInbox.mockResolvedValue({ messages: [], unreadCount: 0, total: 0 });
mockFetchAllAgentMailbox.mockResolvedValue({ messages: [], total: 0, unreadCount: 0 });
render(<MailboxView {...defaultProps} />);
await act(async () => {
fireEvent.click(screen.getByTestId("mailbox-tab-agents"));
});
const latest = sseSubscriptions.at(-1);
await act(async () => {
latest?.["message:sent"]?.();
});
await waitFor(() => {
expect(mockFetchAllAgentMailbox).toHaveBeenCalled();
});
});
it("shows inbox and outbox sub-tabs when agent is selected", async () => {
mockFetchInbox.mockResolvedValue({
messages: [],
unreadCount: 0,
total: 0,
});
mockFetchAgentMailbox.mockResolvedValue({
ownerId: "agent-001",
ownerType: "agent",
unreadCount: 1,
messages: [mockMessage],
inbox: [mockMessage],
outbox: [],
});
render(<MailboxView {...defaultProps} />);
// Switch to agents tab
const agentsTab = screen.getByTestId("mailbox-tab-agents");
await act(async () => {
fireEvent.click(agentsTab);
});
await waitFor(() => {
expect(mockFetchAgents).toHaveBeenCalled();
});
// Select an agent
fireEvent.change(screen.getByTestId("mailbox-agent-select"), { target: { value: "agent-001" } });
await waitFor(() => {
expect(mockFetchAgentMailbox).toHaveBeenCalledWith("agent-001", undefined);
});
// Sub-tabs should be visible
await waitFor(() => {
expect(screen.getByTestId("mailbox-agent-subtabs")).toBeDefined();
expect(screen.getByTestId("mailbox-agent-subtab-inbox")).toBeDefined();
expect(screen.getByTestId("mailbox-agent-subtab-outbox")).toBeDefined();
});
const agentsComposeButton = screen.getByTestId("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,
total: 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("does not mark agent inbox messages as read when the dashboard user opens them", async () => {
const agentInboxMessage: Message = {
id: "msg-agent-unread",
fromId: "user-001",
fromType: "user",
toId: "agent-001",
toType: "agent",
content: "Important — please reply",
type: "user-to-agent",
read: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
mockFetchInbox.mockResolvedValue({ messages: [], unreadCount: 0, total: 0 });
mockFetchAgentMailbox.mockResolvedValue({
ownerId: "agent-001",
ownerType: "agent",
unreadCount: 1,
messages: [agentInboxMessage],
inbox: [agentInboxMessage],
outbox: [],
});
mockFetchConversation.mockResolvedValue([agentInboxMessage]);
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.getByTestId("mailbox-item-msg-agent-unread")).toBeDefined();
});
await act(async () => {
fireEvent.click(screen.getByTestId("mailbox-item-msg-agent-unread"));
});
await waitFor(() => {
expect(screen.getByTestId("mailbox-message-detail")).toBeDefined();
});
// Critical: the dashboard user browsing an agent's mailbox MUST NOT
// consume the agent's unread state — the agent's heartbeat is the
// authoritative reader.
expect(mockMarkMessageRead).not.toHaveBeenCalled();
});
it("switches to outbox view when clicking outbox sub-tab", async () => {
mockFetchInbox.mockResolvedValue({
messages: [],
unreadCount: 0,
total: 0,
});
mockFetchAgentMailbox.mockResolvedValue({
ownerId: "agent-001",
ownerType: "agent",
unreadCount: 1,
messages: [mockMessage],
inbox: [mockMessage],
outbox: [mockOutboxMessage],
});
render(<MailboxView {...defaultProps} />);
// Switch to agents tab and select agent
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.getByTestId("mailbox-agent-subtabs")).toBeDefined();
});
// Click outbox sub-tab
const outboxTab = screen.getByTestId("mailbox-agent-subtab-outbox");
await act(async () => {
fireEvent.click(outboxTab);
});
// Should show outbox message (with "To:" label)
await waitFor(() => {
expect(screen.getByText("To: User: user-001")).toBeDefined();
});
});
it("switches back to inbox view when clicking inbox sub-tab", async () => {
mockFetchInbox.mockResolvedValue({
messages: [],
unreadCount: 0,
total: 0,
});
mockFetchAgentMailbox.mockResolvedValue({
ownerId: "agent-001",
ownerType: "agent",
unreadCount: 0,
messages: [mockOutboxMessage],
inbox: [],
outbox: [mockOutboxMessage],
});
render(<MailboxView {...defaultProps} />);
// Switch to agents tab and select agent
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.getByTestId("mailbox-agent-subtabs")).toBeDefined();
});
// Click outbox first (default should be inbox)
const outboxTab = screen.getByTestId("mailbox-agent-subtab-outbox");
await act(async () => {
fireEvent.click(outboxTab);
});
await waitFor(() => {
expect(screen.getByText("To: User: user-001")).toBeDefined();
});
// Click inbox sub-tab
const inboxTab = screen.getByTestId("mailbox-agent-subtab-inbox");
await act(async () => {
fireEvent.click(inboxTab);
});
// Should show empty inbox state
await waitFor(() => {
expect(screen.getByText("No received messages for this agent")).toBeDefined();
});
});
it("resets sub-tab to inbox when switching agents", async () => {
mockFetchInbox.mockResolvedValue({
messages: [],
unreadCount: 0,
total: 0,
});
mockFetchAgentMailbox.mockResolvedValue({
ownerId: "agent-001",
ownerType: "agent",
unreadCount: 0,
messages: [],
inbox: [],
outbox: [mockOutboxMessage],
});
render(<MailboxView {...defaultProps} />);
// Switch to agents tab
const agentsTab = screen.getByTestId("mailbox-tab-agents");
await act(async () => {
fireEvent.click(agentsTab);
});
// Select first agent
fireEvent.change(screen.getByTestId("mailbox-agent-select"), { target: { value: "agent-001" } });
await waitFor(() => {
expect(screen.getByTestId("mailbox-agent-subtabs")).toBeDefined();
});
// Switch to outbox
const outboxTab = screen.getByTestId("mailbox-agent-subtab-outbox");
await act(async () => {
fireEvent.click(outboxTab);
});
await waitFor(() => {
expect(screen.getByText("To: User: user-001")).toBeDefined();
});
// Switch to second agent - should reset to inbox
mockFetchAgentMailbox.mockResolvedValue({
ownerId: "agent-002",
ownerType: "agent",
unreadCount: 1,
messages: [mockMessage],
inbox: [mockMessage],
outbox: [],
});
fireEvent.change(screen.getByTestId("mailbox-agent-select"), { target: { value: "agent-002" } });
await waitFor(() => {
// Should be on inbox (default) with the message
expect(screen.getByTestId("mailbox-agent-subtab-inbox")).toHaveClass("active");
});
});
it("shows unread count badge on inbox sub-tab when agent has unread messages", async () => {
mockFetchInbox.mockResolvedValue({
messages: [],
unreadCount: 0,
total: 0,
});
mockFetchAgentMailbox.mockResolvedValue({
ownerId: "agent-001",
ownerType: "agent",
unreadCount: 3,
messages: [mockMessage],
inbox: [mockMessage],
outbox: [],
});
render(<MailboxView {...defaultProps} />);
// Switch to agents tab and select agent
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(() => {
const inboxTab = screen.getByTestId("mailbox-agent-subtab-inbox");
expect(inboxTab.querySelector(".mailbox-tab-badge")?.textContent).toBe("3");
});
});
});
describe("resizable split pane", () => {
it("renders a desktop resize handle with separator aria semantics", async () => {
mockUseViewportMode.mockReturnValue("desktop");
mockFetchInbox.mockResolvedValue(makeInboxResponse([mockMessage], 1));
render(<MailboxView {...defaultProps} projectId="proj-resize" />);
const handle = await screen.findByTestId("mailbox-split-resize-handle");
expect(handle.getAttribute("role")).toBe("separator");
expect(handle.getAttribute("aria-orientation")).toBe("vertical");
expect(Number(handle.getAttribute("aria-valuenow"))).toBeGreaterThan(0);
});
it("does not render the resize handle on mobile", async () => {
mockUseViewportMode.mockReturnValue("mobile");
mockFetchInbox.mockResolvedValue(makeInboxResponse([mockMessage], 1));
render(<MailboxView {...defaultProps} />);
await screen.findByTestId("mailbox-view");
expect(screen.queryByTestId("mailbox-split-resize-handle")).toBeNull();
});
it("supports keyboard resize and Home/End clamping", async () => {
mockUseViewportMode.mockReturnValue("desktop");
mockFetchInbox.mockResolvedValue(makeInboxResponse([mockMessage], 1));
render(<MailboxView {...defaultProps} projectId="proj-keys" />);
const handle = await screen.findByTestId("mailbox-split-resize-handle");
const initial = Number(handle.getAttribute("aria-valuenow"));
fireEvent.keyDown(handle, { key: "ArrowLeft" });
const afterLeft = Number(handle.getAttribute("aria-valuenow"));
expect(afterLeft).toBeLessThan(initial);
fireEvent.keyDown(handle, { key: "ArrowRight" });
const afterRight = Number(handle.getAttribute("aria-valuenow"));
expect(afterRight).toBeGreaterThanOrEqual(afterLeft);
// FNXC:Mailbox 2026-06-22-18:05: Home clamps to MAILBOX_SIDEBAR_MIN_WIDTH (locked at 180); End clamps to the container max ratio.
fireEvent.keyDown(handle, { key: "Home" });
expect(Number(handle.getAttribute("aria-valuenow"))).toBe(180);
fireEvent.keyDown(handle, { key: "End" });
expect(Number(handle.getAttribute("aria-valuenow"))).toBeGreaterThanOrEqual(180);
});
it("persists and restores scoped mailbox sidebar width", async () => {
mockUseViewportMode.mockReturnValue("desktop");
mockFetchInbox.mockResolvedValue(makeInboxResponse([mockMessage], 1));
const projectId = "proj-persist";
const storageKey = `kb:${projectId}:kb-dashboard-mailbox-sidebar-width`;
window.localStorage.setItem(storageKey, "360");
const { unmount } = render(<MailboxView {...defaultProps} projectId={projectId} />);
const handle = await screen.findByTestId("mailbox-split-resize-handle");
expect(Number(handle.getAttribute("aria-valuenow"))).toBe(360);
fireEvent.keyDown(handle, { key: "ArrowRight" });
await waitFor(() => {
const savedWidth = Number(window.localStorage.getItem(storageKey));
expect(savedWidth).toBeGreaterThan(360);
});
unmount();
render(<MailboxView {...defaultProps} projectId={projectId} />);
const remountedHandle = await screen.findByTestId("mailbox-split-resize-handle");
const persistedWidth = Number(window.localStorage.getItem(storageKey));
expect(Number(remountedHandle.getAttribute("aria-valuenow"))).toBe(Math.round(persistedWidth));
});
});
describe("mobile layout CSS regressions", () => {
it("adds the runtime mobile layout gate only in mobile mode", async () => {
mockFetchInbox.mockResolvedValue(makeInboxResponse([], 0));
mockUseViewportMode.mockReturnValue("mobile");
const { unmount } = render(<MailboxView {...defaultProps} />);
expect(await screen.findByTestId("mailbox-view")).toHaveClass("mailbox-view--mobile");
unmount();
mockUseViewportMode.mockReturnValue("desktop");
render(<MailboxView {...defaultProps} />);
expect(await screen.findByTestId("mailbox-view")).not.toHaveClass("mailbox-view--mobile");
});
it("defines class-gated, compact single-row mailbox mobile layout rules", () => {
const css = loadAllAppCss();
expect(css).toMatch(/\.mailbox-view--mobile\s+\.view-header\s*\{[^}]*flex-wrap:\s*nowrap;[^}]*align-items:\s*center;[^}]*\}/);
expect(css).toMatch(/\.mailbox-view--mobile\s+\.view-header__title\s*\{[^}]*flex:\s*1\s+1\s+auto;[^}]*min-width:\s*0;[^}]*\}/);
expect(css).toMatch(/\.mailbox-view--mobile\s+\.view-header__actions\s*\{[^}]*flex:\s*0\s+1\s+auto;[^}]*min-width:\s*0;[^}]*flex-wrap:\s*nowrap;[^}]*justify-content:\s*flex-end;[^}]*margin-left:\s*auto;[^}]*\}/);
expect(css).toMatch(/\.mailbox-view--mobile\s+\.view-header__actions\s+\.btn\s+span\s*\{[^}]*min-width:\s*0;[^}]*overflow:\s*hidden;[^}]*text-overflow:\s*ellipsis;[^}]*white-space:\s*nowrap;[^}]*\}/);
expect(css).not.toMatch(/\.mailbox-view--mobile\s+\.view-header__title\s*\{[^}]*flex:\s*0\s+0\s+100%;[^}]*\}/);
expect(css).not.toMatch(/\.mailbox-view--mobile\s+\.view-header__actions\s*\{[^}]*flex:\s*1\s+1\s+100%;[^}]*\}/);
expect(css).toMatch(/\.mailbox-view--mobile\s+\.mailbox-tabs\s*\{[^}]*flex-wrap:\s*nowrap;[^}]*overflow-x:\s*auto;[^}]*\}/);
expect(css).toMatch(/\.mailbox-view--mobile\s+\.mailbox-tab\s*\{[^}]*min-width:\s*0;[^}]*flex:\s*1\s+1\s+0;[^}]*flex-shrink:\s*1;[^}]*\}/);
expect(css).toMatch(/\.mailbox-view--mobile\s+\.mailbox-message-detail-header\s*\{[^}]*flex-direction:\s*row;[^}]*flex-wrap:\s*nowrap;[^}]*\}/);
expect(css).toMatch(/\.mailbox-view--mobile\s+\.mailbox-message-detail-actions\s*\{[^}]*flex-wrap:\s*nowrap;[^}]*\}/);
// The later width-query block must not override the runtime compact tab/detail rules on phones.
expect(css).toMatch(/\.mailbox-view:not\(\.mailbox-view--mobile\)\s+\.mailbox-tab\s*\{[^}]*flex-shrink:\s*0;[^}]*\}/);
expect(css).toMatch(/\.mailbox-view:not\(\.mailbox-view--mobile\)\s+\.mailbox-message-detail-header\s*\{[^}]*flex-direction:\s*column;[^}]*\}/);
expect(css).toMatch(/\.mailbox-view:not\(\.mailbox-view--mobile\)\s+\.mailbox-message-detail-actions\s*\{[^}]*flex-wrap:\s*wrap;[^}]*\}/);
// The runtime class, not a height or pointer media proxy, is the only FN-8407 gate.
expect(css).not.toMatch(/@media\s*\([^)]*(?:max-height:\s*480px|pointer:\s*coarse)[^)]*\)\s*\{[\s\S]*?\.mailbox-view--mobile/);
});
it("keeps system and agent message actions on the mobile detail row", async () => {
mockUseViewportMode.mockReturnValue("mobile");
const systemMessage: Message = {
...mockMessage,
id: "msg-system",
fromId: "system",
fromType: "system",
type: "system",
};
mockFetchInbox.mockResolvedValue(makeInboxResponse([systemMessage], 0));
mockFetchConversation.mockResolvedValue([systemMessage]);
const { unmount } = render(<MailboxView {...defaultProps} />);
await screen.findByTestId("mailbox-item-msg-system");
fireEvent.click(screen.getByTestId("mailbox-item-msg-system"));
await screen.findByTestId("mailbox-message-detail");
expect(screen.getByTestId("mailbox-delete")).toBeInTheDocument();
expect(screen.queryByTestId("mailbox-reply")).toBeNull();
unmount();
mockFetchInbox.mockResolvedValue(makeInboxResponse([mockMessage], 0));
mockFetchConversation.mockResolvedValue([mockMessage]);
render(<MailboxView {...defaultProps} />);
await screen.findByTestId("mailbox-item-msg-001");
fireEvent.click(screen.getByTestId("mailbox-item-msg-001"));
await screen.findByTestId("mailbox-message-detail");
expect(screen.getByTestId("mailbox-reply")).toBeInTheDocument();
expect(screen.getByTestId("mailbox-delete")).toBeInTheDocument();
});
it("renders every unread Inbox header action together in mobile mode", async () => {
mockUseViewportMode.mockReturnValue("mobile");
mockFetchInbox.mockResolvedValue(makeInboxResponse([mockMessage], 1));
render(<MailboxView {...defaultProps} />);
expect(await screen.findByTestId("mailbox-unread-badge")).toBeInTheDocument();
expect(screen.getByTestId("mailbox-header-compose")).toBeInTheDocument();
expect(screen.getByTestId("mailbox-mark-all-read")).toBeInTheDocument();
expect(screen.getByTestId("mailbox-refresh")).toBeInTheDocument();
});
it("defines .mailbox-view base flex layout with min-height: 0", async () => {
const fs = await import("fs");
const path = await import("path");
const css = loadAllAppCss();
const viewBlockMatch = css.match(/\.mailbox-view\s*\{([^}]*)\}/);
expect(viewBlockMatch).toBeTruthy();
const viewBlock = viewBlockMatch![1];
expect(viewBlock).toContain("display: flex;");
expect(viewBlock).toContain("flex-direction: column;");
expect(viewBlock).toContain("height: 100%;");
expect(viewBlock).toContain("min-height: 0;");
expect(viewBlock).toContain("overflow: hidden;");
});
it("defines .mailbox-view .mailbox-content as a bounded flex child", async () => {
const css = loadAllAppCss();
const contentBlockMatch = css.match(/\.mailbox-view\s+\.mailbox-content\s*\{([^}]*)\}/);
expect(contentBlockMatch).toBeTruthy();
const contentBlock = contentBlockMatch![1];
expect(contentBlock).toContain("flex: 1;");
expect(contentBlock).toContain("min-height: 0;");
expect(contentBlock).toContain("overflow-y: auto;");
expect(contentBlock).toContain("max-height: none;");
expect(contentBlock).toContain("padding: 0;");
});
it("defines desktop/tablet split-pane selectors under .mailbox-view scope", async () => {
const css = loadAllAppCss();
// FNXC:Mailbox 2026-06-22-18:05: split layout is a flex row so the list pane's inline width is honored (drag-resizable); grid `auto` tracks ignored it.
expect(css).toMatch(/\.mailbox-view\s+\.mailbox-split-layout\s*\{[^}]*display:\s*flex;[^}]*flex-direction:\s*row;[^}]*gap:\s*0;[^}]*min-height:\s*0;[^}]*\}/);
const splitPaneBlockMatch = css.match(/\.mailbox-view\s+\.mailbox-split-list-pane,\s*\n\.mailbox-view\s+\.mailbox-split-detail-pane\s*\{([^}]*)\}/);
expect(splitPaneBlockMatch).toBeTruthy();
const splitPaneBlock = splitPaneBlockMatch![1];
expect(splitPaneBlock).toContain("overflow-y: auto;");
expect(splitPaneBlock).toContain("background: var(--surface);");
expect(splitPaneBlock).not.toContain("border: var(--btn-border-width) solid var(--border);");
expect(splitPaneBlock).not.toContain("border-radius: var(--radius-md);");
// FNXC:MailboxView 2026-06-22-12:58: full-page Mailbox list pane mirrors Chat's left sidebar surface and spacing.
const listPaneBlockMatch = css.match(/\.mailbox-view\s+\.mailbox-split-list-pane\s*\{([^}]*)\}/);
expect(listPaneBlockMatch).toBeTruthy();
expect(listPaneBlockMatch![1]).toContain("flex: 0 0 auto;");
expect(listPaneBlockMatch![1]).toContain("min-width: 0;");
expect(listPaneBlockMatch![1]).toContain("max-width: 500px;");
expect(listPaneBlockMatch![1]).toContain("border-right: var(--btn-border-width) solid var(--border);");
expect(listPaneBlockMatch![1]).toContain("background: var(--bg-secondary);");
// Match the standalone detail-pane rule (the one declaring `display: flex;`), not the shared border/background block.
const detailPaneBlockMatch = css.match(/\.mailbox-view\s+\.mailbox-split-detail-pane\s*\{([^}]*display:\s*flex;[^}]*)\}/);
expect(detailPaneBlockMatch).toBeTruthy();
expect(detailPaneBlockMatch![1]).toContain("flex: 1 1 auto;");
expect(detailPaneBlockMatch![1]).toContain("min-width: 0;");
expect(detailPaneBlockMatch![1]).toContain("padding: var(--space-lg);");
const resizeHandleBlockMatch = css.match(/\.mailbox-view\s+\.mailbox-split-resize-handle\s*\{([^}]*)\}/);
expect(resizeHandleBlockMatch).toBeTruthy();
const resizeHandleBlock = resizeHandleBlockMatch![1];
// FNXC:SidebarDivider 2026-06-22-13:26: handle mirrors the Chat sidebar divider — hit area var(--space-sm), transparent handle background, centered hover-only var(--space-xs) tint.
expect(resizeHandleBlock).toContain("width: var(--space-sm);");
expect(resizeHandleBlock).toContain("cursor: col-resize;");
expect(resizeHandleBlock).toContain("background: transparent;");
const resizeHandleTargetBlockMatch = css.match(/\.mailbox-view\s+\.mailbox-split-resize-handle::before\s*\{([^}]*)\}/);
expect(resizeHandleTargetBlockMatch).toBeTruthy();
expect(resizeHandleTargetBlockMatch![1]).toContain("width: var(--space-xs);");
expect(resizeHandleTargetBlockMatch![1]).not.toContain("background: var(--border);");
expect(css).toMatch(/\.mailbox-view\s+\.mailbox-split-resize-handle:hover::before,\s*\n\.mailbox-view\s+\.mailbox-split-resize-handle:active::before\s*\{[^}]*background:\s*color-mix\(in srgb,\s*var\(--todo\)\s*30%,\s*transparent\);[^}]*\}/);
const splitEmptyBlockMatch = css.match(/\.mailbox-view\s+\.mailbox-split-empty\s*\{([^}]*)\}/);
expect(splitEmptyBlockMatch).toBeTruthy();
expect(splitEmptyBlockMatch![1]).toContain("color: var(--text-muted);");
expect(splitEmptyBlockMatch![1]).toContain("color-mix");
});
it("keeps mobile .mailbox-view overrides in the dedicated media-query section", async () => {
const fs = await import("fs");
const path = await import("path");
const css = loadAllAppCss();
const sectionStart = css.indexOf("/* ── Mailbox — Mobile");
expect(sectionStart).toBeGreaterThan(-1);
const sectionEnd = css.indexOf("/* ── Message Composer", sectionStart);
expect(sectionEnd).toBeGreaterThan(sectionStart);
const mailboxMobileSection = css.slice(sectionStart, sectionEnd);
expect(mailboxMobileSection).toContain("@media (max-width: 768px)");
// Verify .mailbox-view selectors are in mobile section
expect(mailboxMobileSection).toContain(".mailbox-view .mailbox-header");
expect(mailboxMobileSection).toMatch(/\.mailbox-modal \.mailbox-header-actions,\s*\.mailbox-view \.mailbox-header-actions\s*\{[^}]*gap:\s*var\(--space-sm\);[^}]*\}/);
expect(mailboxMobileSection).toMatch(/\.mailbox-modal \.mailbox-header-actions \.btn,[^}]*\.mailbox-view \.mailbox-header-actions \.btn-icon\s*\{[^}]*min-height:\s*2\.25rem;[^}]*\}/);
expect(mailboxMobileSection).toMatch(/\.mailbox-modal \.mailbox-header-actions \.btn-icon,[^}]*\.mailbox-view \.mailbox-header-actions \.btn-icon\s*\{[^}]*min-width:\s*2\.25rem;[^}]*display:\s*inline-flex;[^}]*\}/);
expect(mailboxMobileSection).toContain(".mailbox-view .mailbox-tabs");
expect(mailboxMobileSection).toContain(".mailbox-view .mailbox-content");
expect(mailboxMobileSection).toContain(".mailbox-view .mailbox-split-layout");
expect(mailboxMobileSection).toContain(".mailbox-view .mailbox-split-list-pane");
expect(mailboxMobileSection).toContain(".mailbox-view .mailbox-split-detail-pane");
expect(mailboxMobileSection).toContain(".mailbox-view .mailbox-split-resize-handle");
expect(mailboxMobileSection).toContain(".mailbox-view .mailbox-empty");
expect(mailboxMobileSection).toMatch(/\.mailbox-view\s+\.mailbox-split-resize-handle\s*\{[^}]*display:\s*none;[^}]*\}/);
});
it("uses mobile-specific values for .mailbox-view content and FAB", async () => {
const fs = await import("fs");
const path = await import("path");
const css = loadAllAppCss();
const sectionStart = css.indexOf("/* ── Mailbox — Mobile");
expect(sectionStart).toBeGreaterThan(-1);
const sectionEnd = css.indexOf("/* ── Message Composer", sectionStart);
const mailboxMobileSection = css.slice(sectionStart, sectionEnd);
// Content should have max-height: none (not modal's calc)
expect(mailboxMobileSection).toContain(".mailbox-view .mailbox-content");
// Match descendant selector: .mailbox-view followed by space, then .mailbox-content
const contentRuleMatch = mailboxMobileSection.match(/\.mailbox-view\s+\.mailbox-content\s*\{[^}]*\}/);
expect(contentRuleMatch).toBeTruthy();
expect(contentRuleMatch![0]).toContain("max-height: none");
expect(contentRuleMatch![0]).toContain("overflow-y: auto");
expect(contentRuleMatch![0]).toContain("min-height: 0");
expect(contentRuleMatch![0]).toContain("overscroll-behavior: contain");
expect(contentRuleMatch![0]).toContain("-webkit-overflow-scrolling: touch");
// Content should have padding-bottom accounting for mobile nav
expect(contentRuleMatch![0]).toContain("padding-bottom");
});
it("keeps mailbox tab selectors aligned with shared rounded-button defaults", async () => {
const fs = await import("fs");
const path = await import("path");
const css = loadAllAppCss();
expect(css).toMatch(/\.mailbox-tab\s*\{[^}]*border-color:\s*var\(--border\);[^}]*background:\s*var\(--surface\);[^}]*\}/);
expect(css).not.toMatch(/\.mailbox-tab\s*\{[^}]*border:\s*none;[^}]*\}/);
expect(css).not.toMatch(/\.mailbox-tab\s*\{[^}]*background:\s*none;[^}]*\}/);
expect(css).not.toMatch(/\.mailbox-tab\s*\{[^}]*border-bottom:\s*2px\s+solid\s+transparent;[^}]*\}/);
expect(css).toMatch(/\.mailbox-agent-subtab\s*\{[^}]*border-color:\s*var\(--border\);[^}]*background:\s*var\(--surface\);[^}]*\}/);
expect(css).not.toMatch(/\.mailbox-agent-subtab\s*\{[^}]*border-radius:\s*0;[^}]*\}/);
expect(css).not.toMatch(/\.mailbox-agent-subtab\s*\{[^}]*border:\s*none;[^}]*\}/);
expect(css).not.toMatch(/\.mailbox-agent-subtab\s*\{[^}]*background:\s*transparent;[^}]*\}/);
});
it("includes keyboard-overlap viewport anchoring rules for mailbox containers", async () => {
const css = loadAllAppCss();
expect(css).toContain('.mailbox-view[style*="--keyboard-overlap"],');
expect(css).toContain('.mailbox-modal[style*="--keyboard-overlap"]');
expect(css).toContain("height: var(--vv-height, 100dvh);");
expect(css).toContain("transform: translateY(var(--vv-offset-top, 0px));");
});
it("preserves mobile mailbox scroll after SSE refreshes inbox data", async () => {
mockUseViewportMode.mockReturnValue("mobile");
const originalRequestAnimationFrame = window.requestAnimationFrame;
window.requestAnimationFrame = ((callback: FrameRequestCallback) => {
callback(0);
return 0;
}) as typeof window.requestAnimationFrame;
mockFetchInbox
.mockResolvedValueOnce(makeInboxResponse([mockMessage], 1))
.mockResolvedValueOnce(makeInboxResponse([{ ...mockReadMessage, id: "msg-sse", content: "Message refreshed by SSE" }], 0));
mockFetchUnreadCount.mockResolvedValue({ unreadCount: 0 });
try {
render(<MailboxView {...defaultProps} />);
await screen.findByText("Hello, this is a test message from the agent.");
const content = screen.getByTestId("mailbox-content");
content.scrollTop = 144;
const latest = sseSubscriptions.at(-1);
expect(latest).toBeDefined();
await act(async () => {
latest?.["message:received"]?.();
});
await waitFor(() => {
expect(screen.getByText("Message refreshed by SSE")).toBeDefined();
});
expect(content.scrollTop).toBe(144);
} finally {
window.requestAnimationFrame = originalRequestAnimationFrame;
}
});
it("renders structural elements that mobile CSS targets", async () => {
mockFetchInbox.mockResolvedValue({
messages: [mockMessage],
unreadCount: 1,
total: 1,
});
const { container } = render(<MailboxView {...defaultProps} />);
// Verify root element with data-testid
expect(screen.getByTestId("mailbox-view")).toBeDefined();
// FNXC:Navigation 2026-06-22-01:10: MailboxView migrated its bespoke
// .mailbox-header to the shared ViewHeader (.view-header) modeled after
// Command Center; assert the shared header element with the Mailbox title.
const header = container.querySelector(".view-header");
expect(header).toBeTruthy();
expect(header?.querySelector(".view-header__title")?.textContent).toContain("Mailbox");
// Verify tabs
const tabs = container.querySelector(".mailbox-tabs");
expect(tabs).toBeTruthy();
// Verify content
const content = container.querySelector(".mailbox-content");
expect(content).toBeTruthy();
});
it("highlights deep-linked mailbox message from URL", async () => {
const scrollIntoView = vi.fn();
Element.prototype.scrollIntoView = scrollIntoView;
window.history.replaceState({}, "", "?view=mailbox&mailbox-message=msg-001#message-msg-001");
mockFetchInbox.mockResolvedValue(makeInboxResponse([mockMessage], 1));
mockFetchConversation.mockResolvedValue([mockMessage]);
render(<MailboxView {...defaultProps} />);
const messageNode = await screen.findByTestId("mailbox-message-detail");
await waitFor(() => {
expect(messageNode).toHaveAttribute("id", "mailbox-detail-message-msg-001");
expect(messageNode).toHaveClass("mailbox-message-highlight");
});
expect(scrollIntoView).toHaveBeenCalled();
});
});
});