Files
fusion/packages/dashboard/app/components/__tests__/ComposeChatPanel.test.tsx
gsxdsm e30f6d851f FN-8293: add native structure drag-and-draft mail composition
Mail composers can attach native structures by drag-and-drop and generate report narratives with AI.

- Add a shared native-structure drag protocol and attachable previews across Mail source views.
- Add a disposable compose-chat panel that drafts narratives around attached embeds.
- Cover drag behavior and compose-chat session lifecycle, and document the Mail interaction.

Files changed:
 .changeset/fn-8293-mail-drag-compose-chat.md       |   7 ++
 docs/dashboard-guide.md                            |   3 +
 .../dashboard/app/components/ComposeChatPanel.css  |  33 ++++++
 .../dashboard/app/components/ComposeChatPanel.tsx  | 117 +++++++++++++++++++++
 packages/dashboard/app/components/EvalsView.tsx    |   3 +-
 packages/dashboard/app/components/GoalsView.tsx    |   6 ++
 packages/dashboard/app/components/InsightsView.tsx |   8 +-
 .../dashboard/app/components/MessageComposer.css   |  22 ++++
 .../dashboard/app/components/MessageComposer.tsx   |  92 ++++++++++++++--
 .../dashboard/app/components/MissionManager.tsx    |  11 +-
 .../components/__tests__/ComposeChatPanel.test.tsx | 102 ++++++++++++++++++
 .../components/__tests__/MessageComposer.test.tsx  |  25 +++++
 .../utils/__tests__/nativeStructureDrag.test.ts    |  56 ++++++++++
 .../dashboard/app/utils/nativeStructureDrag.ts     |  54 ++++++++++
 14 files changed, 525 insertions(+), 14 deletions(-)

Fusion-Task-Id: FN-8293
Fusion-Task-Lineage: 9864e76c-2a0d-41f6-b7f2-de8dd63710b5
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-18 20:30:42 -07:00

103 lines
4.4 KiB
TypeScript

import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ComposeChatPanel } from "../ComposeChatPanel";
const createSession = vi.fn();
const archiveSession = vi.fn();
const selectSession = vi.fn();
const sendMessage = vi.fn();
const chat = {
activeSession: null as { id: string } | null,
archiveSession,
createSession,
isStreaming: false,
selectSession,
messages: [{ id: "assistant", role: "assistant", content: "Drafted narrative", sessionId: "scratch", createdAt: "now" }],
sendMessage,
streamingText: "",
};
vi.mock("../../hooks/useChat", () => ({
FN_AGENT_ID: "__fusion__",
useChat: () => chat,
}));
describe("ComposeChatPanel", () => {
beforeEach(() => {
vi.clearAllMocks();
chat.activeSession = null;
createSession.mockImplementation(async () => {
chat.activeSession = { id: "scratch" };
return { id: "scratch" };
});
archiveSession.mockResolvedValue(undefined);
selectSession.mockReset();
});
it("creates an FN scratch session before sending embed-aware context and archives it on close", async () => {
const onClose = vi.fn();
const props = { embeds: [{ kind: "mission" as const, id: "M-1", label: "Launch" }], draftBody: "", onUseDraft: vi.fn(), onClose };
const { rerender, unmount } = render(<ComposeChatPanel {...props} />);
fireEvent.click(screen.getByRole("button", { name: "Draft" }));
await waitFor(() => expect(createSession).toHaveBeenCalledWith({ agentId: "__fusion__", title: "compose-mail-scratch" }));
// Model useChat's state-driven active-session render after createSession resolves.
rerender(<ComposeChatPanel {...props} />);
await waitFor(() => expect(sendMessage).toHaveBeenCalledWith(expect.stringContaining("mission: Launch (M-1)")));
fireEvent.click(screen.getByRole("button", { name: "Draft" }));
await waitFor(() => expect(sendMessage).toHaveBeenCalledTimes(2));
fireEvent.click(screen.getByRole("button", { name: "Close" }));
expect(onClose).toHaveBeenCalled();
unmount();
expect(archiveSession).toHaveBeenCalledTimes(1);
expect(archiveSession).toHaveBeenCalledWith("scratch");
});
it("waits for the initial scratch session selection and prevents double session creation", async () => {
let resolveSession!: (session: { id: string }) => void;
createSession.mockImplementation(() => new Promise((resolve) => {
resolveSession = (session) => {
chat.activeSession = session;
resolve(session);
};
}));
const props = { embeds: [] as never[], draftBody: "", onUseDraft: vi.fn(), onClose: vi.fn() };
const { rerender } = render(<ComposeChatPanel {...props} />);
const draft = screen.getByRole("button", { name: "Draft" });
fireEvent.click(draft);
fireEvent.click(draft);
expect(createSession).toHaveBeenCalledTimes(1);
expect(sendMessage).not.toHaveBeenCalled();
resolveSession({ id: "scratch" });
rerender(<ComposeChatPanel {...props} />);
await waitFor(() => expect(sendMessage).toHaveBeenCalledTimes(1));
});
it("restores the prior active chat session after closing the scratch session", async () => {
const prior = { id: "user-session", title: "Existing chat" };
chat.activeSession = prior;
createSession.mockImplementation(async () => {
chat.activeSession = { id: "scratch" };
return { id: "scratch" };
});
const { rerender } = render(<ComposeChatPanel embeds={[]} draftBody="" onUseDraft={vi.fn()} onClose={vi.fn()} />);
fireEvent.click(screen.getByRole("button", { name: "Draft" }));
await waitFor(() => expect(createSession).toHaveBeenCalledTimes(1));
rerender(<ComposeChatPanel embeds={[]} draftBody="" onUseDraft={vi.fn()} onClose={vi.fn()} />);
await waitFor(() => expect(sendMessage).toHaveBeenCalledTimes(1));
fireEvent.click(screen.getByRole("button", { name: "Close" }));
await waitFor(() => expect(archiveSession).toHaveBeenCalledWith("scratch"));
expect(selectSession).toHaveBeenCalledWith("user-session", prior);
});
it("returns generated text through Use draft", () => {
const onUseDraft = vi.fn();
render(<ComposeChatPanel embeds={[]} draftBody="Existing text" onUseDraft={onUseDraft} onClose={vi.fn()} />);
fireEvent.click(screen.getByRole("button", { name: "Use draft" }));
expect(onUseDraft).toHaveBeenCalledWith("Drafted narrative");
});
});