Embed selected native project structures directly in mailbox messages and drafts. - Persist native structure embeds in message metadata and expose their shared types. - Add composer selection, mailbox rendering, previews, and dashboard navigation for linked structures. - Cover metadata persistence and mailbox embed behavior with core and dashboard tests. Files changed: .changeset/fn-8292-mail-native-structure-embeds.md | 7 ++ docs/dashboard-guide.md | 1 + .../message-metadata-native-structures.test.ts | 25 +++++++ .../__tests__/postgres/message-store.pg.test.ts | 21 ++++++ packages/core/src/index.ts | 2 +- packages/core/src/types.ts | 32 +++++++++ packages/core/src/types/messages.ts | 14 ++++ packages/dashboard/app/components/MailboxModal.css | 29 ++++++++ packages/dashboard/app/components/MailboxModal.tsx | 13 +++- .../components/MailboxNativeStructureEmbeds.tsx | 37 ++++++++++ packages/dashboard/app/components/MailboxView.tsx | 14 +++- .../dashboard/app/components/MessageComposer.tsx | 65 ++++++++++++++++-- .../app/components/NativeStructurePreview.tsx | 6 +- .../__tests__/MailboxModal.cache.test.tsx | 8 +-- .../app/components/__tests__/MailboxModal.test.tsx | 9 +++ .../MailboxNativeStructureEmbeds.test.tsx | 42 ++++++++++++ .../app/components/__tests__/MailboxView.test.tsx | 9 +++ .../components/__tests__/MessageComposer.test.tsx | 25 +++++++ .../app/components/dashboard/MainContent.tsx | 78 +++++++++++++++++++++- .../MainContent.mailbox-view-task.test.tsx | 46 ++++++++++++- 20 files changed, 459 insertions(+), 24 deletions(-) Fusion-Task-Id: FN-8292 Fusion-Task-Lineage: ae6cfb9c-a764-407c-bd45-cfd879ccdd3e Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
119 lines
4.9 KiB
TypeScript
119 lines
4.9 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { render, screen, waitFor } from "@testing-library/react";
|
|
import { MailboxModal } from "../MailboxModal";
|
|
import { SWR_CACHE_KEYS } from "../../utils/swrCache";
|
|
import * as apiModule from "../../api";
|
|
|
|
vi.mock("../../api", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("../../api")>();
|
|
return {
|
|
...actual,
|
|
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(),
|
|
fetchMessage: vi.fn(),
|
|
};
|
|
});
|
|
|
|
const mockFetchInbox = vi.mocked(apiModule.fetchInbox);
|
|
const mockFetchOutbox = vi.mocked(apiModule.fetchOutbox);
|
|
const mockFetchUnreadCount = vi.mocked(apiModule.fetchUnreadCount);
|
|
const mockFetchAllAgentMailbox = vi.mocked(apiModule.fetchAllAgentMailbox);
|
|
|
|
describe("MailboxModal cache hydration", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
localStorage.clear();
|
|
mockFetchInbox.mockResolvedValue({ messages: [], total: 0, unreadCount: 0 });
|
|
mockFetchOutbox.mockResolvedValue({ messages: [], total: 0 });
|
|
mockFetchUnreadCount.mockResolvedValue({ unreadCount: 0 });
|
|
mockFetchAllAgentMailbox.mockResolvedValue({ messages: [], total: 0, unreadCount: 0 });
|
|
});
|
|
|
|
it("shows cached inbox rows on first open", async () => {
|
|
localStorage.setItem(
|
|
`${SWR_CACHE_KEYS.MAILBOX_INBOX_PREFIX}p1`,
|
|
JSON.stringify({
|
|
savedAt: Date.now(),
|
|
data: {
|
|
messages: [{ id: "msg-cache", fromId: "agent-1", fromType: "agent", toId: "dashboard", toType: "user", content: "cached", type: "agent-to-user", read: false, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }],
|
|
total: 1,
|
|
unreadCount: 1,
|
|
},
|
|
}),
|
|
);
|
|
mockFetchInbox.mockImplementation(() => new Promise(() => {}));
|
|
|
|
render(<MailboxModal isOpen onClose={() => {}} projectId="p1" agents={[]} nativeStructureCandidates={[]} onOpenNativeStructure={() => {}} />);
|
|
|
|
expect(screen.getByTestId("mailbox-item-msg-cache")).toBeInTheDocument();
|
|
});
|
|
|
|
it("writes inbox cache on successful load", async () => {
|
|
mockFetchInbox.mockResolvedValueOnce({
|
|
messages: [{ id: "msg-1", fromId: "agent-1", fromType: "agent", toId: "dashboard", toType: "user", content: "live", type: "agent-to-user", read: false, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }],
|
|
total: 1,
|
|
unreadCount: 1,
|
|
});
|
|
|
|
render(<MailboxModal isOpen onClose={() => {}} projectId="p1" agents={[]} nativeStructureCandidates={[]} onOpenNativeStructure={() => {}} />);
|
|
|
|
await waitFor(() => {
|
|
const cachedRaw = localStorage.getItem(`${SWR_CACHE_KEYS.MAILBOX_INBOX_PREFIX}p1`);
|
|
expect(cachedRaw).not.toBeNull();
|
|
const envelope = JSON.parse(cachedRaw ?? "{}");
|
|
expect(envelope).toMatchObject({ savedAt: expect.any(Number) });
|
|
const cached = envelope.data;
|
|
expect(cached?.messages?.[0]?.id).toBe("msg-1");
|
|
expect(cached).not.toHaveProperty("conversationMessages");
|
|
});
|
|
});
|
|
|
|
it("caps inbox cache at 100 and isolates by project", async () => {
|
|
const oversized = Array.from({ length: 140 }, (_, index) => ({
|
|
id: `msg-${index}`,
|
|
fromId: "agent-1",
|
|
fromType: "agent" as const,
|
|
toId: "dashboard",
|
|
toType: "user" as const,
|
|
content: `message ${index}`,
|
|
type: "agent-to-user" as const,
|
|
read: false,
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
}));
|
|
mockFetchInbox.mockResolvedValueOnce({ messages: oversized, total: oversized.length, unreadCount: oversized.length });
|
|
|
|
const { rerender } = render(<MailboxModal isOpen onClose={() => {}} projectId="p1" agents={[]} nativeStructureCandidates={[]} onOpenNativeStructure={() => {}} />);
|
|
|
|
await waitFor(() => {
|
|
const envelope = JSON.parse(localStorage.getItem(`${SWR_CACHE_KEYS.MAILBOX_INBOX_PREFIX}p1`) ?? "{}");
|
|
expect(envelope).toMatchObject({ savedAt: expect.any(Number) });
|
|
const cached = envelope.data;
|
|
expect(cached?.messages).toHaveLength(100);
|
|
});
|
|
|
|
localStorage.setItem(
|
|
`${SWR_CACHE_KEYS.MAILBOX_INBOX_PREFIX}p2`,
|
|
JSON.stringify({
|
|
savedAt: Date.now(),
|
|
data: {
|
|
messages: [{ id: "msg-p2", fromId: "agent-2", fromType: "agent", toId: "dashboard", toType: "user", content: "p2", type: "agent-to-user", read: false, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }],
|
|
total: 1,
|
|
unreadCount: 1,
|
|
},
|
|
}),
|
|
);
|
|
mockFetchInbox.mockImplementation(() => new Promise(() => {}));
|
|
rerender(<MailboxModal isOpen onClose={() => {}} projectId="p2" agents={[]} nativeStructureCandidates={[]} onOpenNativeStructure={() => {}} />);
|
|
|
|
expect(screen.getByTestId("mailbox-item-msg-p2")).toBeInTheDocument();
|
|
});
|
|
});
|