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>
This commit is contained in:
gsxdsm
2026-07-18 20:30:42 -07:00
parent 0dbe67c851
commit e30f6d851f
14 changed files with 525 additions and 14 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Add drag-to-attach native structures and AI narrative drafting to Mail.
category: feature
dev: Uses a shared native structure drag protocol and disposable compose-chat sessions.

View File

@@ -705,6 +705,9 @@ Quick Chat is an optional fast, project-scoped assistant surface for conversatio
Mailbox view shows inbox/outbox communication threads and unread state. When an ephemeral worker is configured for follow-up validation, its task proposals include a **Create task** action; created proposals link directly to the resulting task.
- Mail composers can attach a native mission, milestone, goal, persisted insight, or eval result by dragging it from its owning view, or through the keyboard/mobile **Attach structure** picker. The shared `nativeStructureDrag` payload is copied into the same first-class mail embed metadata as picker attachments.
- **Draft with AI** opens a compact compose-chat scratch session that uses attached structures as context. **Use draft** replaces an empty message body; replacing typed text requires confirmation, and attached embeds remain in place.
- Inbox renders one row per message (no sender-based collapsing)
- clicking a message in the Mail tab opens the task detail pane with full message content and conversation context
- reply rows in the mailbox modal can expand inline to show the replied-to message context for easier thread reading

View File

@@ -0,0 +1,33 @@
.compose-chat-panel {
background: var(--bg-secondary);
border: solid var(--border);
border-radius: var(--radius-md);
display: grid;
gap: var(--space-sm);
padding: var(--space-md);
}
.compose-chat-panel__input {
min-height: calc(var(--space-xl) * 3);
resize: vertical;
}
.compose-chat-panel__output {
background: var(--bg-primary);
border-radius: var(--radius-sm);
min-height: calc(var(--space-xl) * 3);
padding: var(--space-sm);
white-space: pre-wrap;
}
.compose-chat-panel__actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-sm);
}
@media (max-width: 768px) {
.compose-chat-panel__actions > .btn {
flex: 1 1 auto;
}
}

View File

@@ -0,0 +1,117 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { NativeStructureEmbed } from "@fusion/core";
import { FN_AGENT_ID, useChat } from "../hooks/useChat";
import "./ComposeChatPanel.css";
interface ComposeChatPanelProps {
projectId?: string;
embeds: NativeStructureEmbed[];
draftBody: string;
onUseDraft: (draft: string) => void;
onClose: () => void;
}
/**
* FNXC:NativeStructureEmbed 2026-07-22-10:30:
* This compact helper deliberately reuses useChat and a FN_AGENT_ID scratch session rather than
* creating another streaming engine. Its session title prefix makes the disposable compose context
* auditable, and closing archives only that session. Applying output replaces empty text directly;
* typed text is replaced only after the composer confirms, while embeds remain untouched.
*/
export function ComposeChatPanel({ projectId, embeds, draftBody, onUseDraft, onClose }: ComposeChatPanelProps) {
const chat = useChat(projectId);
const [request, setRequest] = useState("Draft a clear report or approval message around the attached structure.");
const [isCreating, setIsCreating] = useState(false);
const [hasPendingPrompt, setHasPendingPrompt] = useState(false);
const [activeScratchSessionId, setActiveScratchSessionId] = useState<string | null>(null);
const scratchSessionId = useRef<string | null>(null);
const priorSession = useRef<typeof chat.activeSession>(null);
const pendingPrompt = useRef<string | null>(null);
const creatingSession = useRef(false);
const closed = useRef(false);
const archivedSessionId = useRef<string | null>(null);
const restoredSessionId = useRef<string | null>(null);
const archiveScratchSession = useCallback((id = scratchSessionId.current) => {
if (!id || archivedSessionId.current === id) return;
archivedSessionId.current = id;
void chat.archiveSession(id)
.catch(() => undefined)
.finally(() => {
const previous = priorSession.current;
if (!previous || restoredSessionId.current === previous.id) return;
restoredSessionId.current = previous.id;
chat.selectSession(previous.id, previous);
});
}, [chat.archiveSession, chat.selectSession]);
useEffect(() => () => {
closed.current = true;
archiveScratchSession();
}, [archiveScratchSession]);
useEffect(() => {
const prompt = pendingPrompt.current;
if (!prompt || chat.activeSession?.id !== scratchSessionId.current) return;
// FNXC:NativeStructureEmbed 2026-07-22-11:00:
// createSession selects state asynchronously. Queue the first prompt until the scratch session
// is active so useChat's closure cannot drop it against the previous/no active session.
pendingPrompt.current = null;
setHasPendingPrompt(false);
chat.sendMessage(prompt);
}, [activeScratchSessionId, chat.activeSession?.id, chat.sendMessage, hasPendingPrompt]);
const send = useCallback(async () => {
const context = embeds.length
? embeds.map((embed) => `- ${embed.kind}: ${embed.label ?? embed.id} (${embed.id})`).join("\n")
: "- No structures attached yet.";
pendingPrompt.current = `${request}\n\nAttached native structures (keep these as first-class embeds, do not describe them as missing):\n${context}\n\nCurrent draft body:\n${draftBody || "(empty)"}`;
setHasPendingPrompt(true);
if (scratchSessionId.current || creatingSession.current) return;
// FNXC:NativeStructureEmbed 2026-07-22-12:00:
// useChat selects the scratch session globally. Keep the user's prior selection so scratch
// cleanup restores it after archiving rather than leaving ChatView on an archived session.
priorSession.current = chat.activeSession;
creatingSession.current = true;
setIsCreating(true);
try {
const session = await chat.createSession({ agentId: FN_AGENT_ID, title: "compose-mail-scratch" });
if (closed.current) {
archiveScratchSession(session.id);
return;
}
scratchSessionId.current = session.id;
// Force the queued-send effect after createSession has selected its stateful active session.
setActiveScratchSessionId(session.id);
} catch {
pendingPrompt.current = null;
setHasPendingPrompt(false);
} finally {
creatingSession.current = false;
if (!closed.current) setIsCreating(false);
}
}, [archiveScratchSession, chat.activeSession, chat.createSession, draftBody, embeds, request]);
const close = useCallback(() => {
closed.current = true;
archiveScratchSession();
onClose();
}, [archiveScratchSession, onClose]);
const latestDraft = chat.streamingText || [...chat.messages].reverse().find((message) => message.role === "assistant")?.content || "";
return (
<section id="compose-chat-panel" className="compose-chat-panel" aria-label="Compose chat narrative helper" data-testid="compose-chat-panel">
<label className="message-composer-label" htmlFor="compose-chat-request">Draft narrative</label>
<textarea id="compose-chat-request" className="input compose-chat-panel__input" value={request} onChange={(event) => setRequest(event.target.value)} />
<div className="compose-chat-panel__output" aria-live="polite">{latestDraft || "Ask the assistant to draft the narrative around your attached structures."}</div>
<div className="compose-chat-panel__actions">
<button className="btn btn-sm btn-primary" type="button" onClick={() => void send()} disabled={chat.isStreaming || isCreating || hasPendingPrompt || !request.trim()}>Draft</button>
<button className="btn btn-sm btn-secondary" type="button" onClick={() => latestDraft && onUseDraft(latestDraft)} disabled={!latestDraft}>Use draft</button>
<button className="btn btn-sm btn-secondary" type="button" onClick={close}>Close</button>
</div>
</section>
);
}

View File

@@ -7,6 +7,7 @@ import type { SectionId } from "./SettingsModal";
import { LoadingSpinner } from "./LoadingSpinner";
import { ViewHeader } from "./ViewHeader";
import "./EvalsView.css";
import { isNativeStructureDragEnabled, serializeNativeStructureRef } from "../utils/nativeStructureDrag";
interface EvalsViewProps {
projectId?: string;
@@ -98,7 +99,7 @@ export function EvalsView({ projectId, onOpenSettings, onOpenTaskDetail }: Evals
<ul className="evals-results" data-testid="evals-results">
{results.map((result) => (
<li key={result.id}>
<button className={`evals-result ${result.id === selectedEvalId ? "evals-result--active" : ""}`} type="button" onClick={() => setSelectedEvalId(result.id)}>
<button className={`evals-result ${result.id === selectedEvalId ? "evals-result--active" : ""}`} type="button" onClick={() => setSelectedEvalId(result.id)} draggable={isNativeStructureDragEnabled()} onDragStart={(event) => { /* FNXC:NativeStructureEmbed 2026-07-22-10:30: Eval rows use the shared composer payload, while touch devices retain native scrolling. */ serializeNativeStructureRef(event.dataTransfer, { kind: "eval-result", id: result.id, projectId }); }}>
<span className="evals-result-title">{result.taskTitle}</span>
<span className="evals-result-meta">{result.taskId} · {result.runId} · {result.overallScore ?? t("evals.naPlaceholder", "n/a")}</span>
</button>

View File

@@ -7,6 +7,7 @@ import remarkGfm from "remark-gfm";
import { draftGoalDescription, getRefineErrorMessage } from "../api";
import { ViewHeader } from "./ViewHeader";
import "./GoalsView.css";
import { isNativeStructureDragEnabled, serializeNativeStructureRef } from "../utils/nativeStructureDrag";
export interface GoalsViewProps {
initialGoals?: Goal[];
@@ -546,6 +547,11 @@ export function GoalsView({ initialGoals, anchorGoalId, projectId, onNavigateToM
id={`goal-card-${goal.id}`}
className={`card goals-card ${goal.status === "archived" ? "goals-card-archived" : ""} ${highlightedGoalId === goal.id ? "goals-card--anchored" : ""}`.trim()}
data-testid={`goal-card-${goal.id}`}
draggable={isNativeStructureDragEnabled()}
onDragStart={(event) => {
// FNXC:NativeStructureEmbed 2026-07-22-10:30: Goals emit the shared mail drag payload; touch devices keep scroll and use the composer picker.
serializeNativeStructureRef(event.dataTransfer, { kind: "goal", id: goal.id, projectId });
}}
>
{editGoalId === goal.id ? (
<div className="goals-card-main goals-card-edit">

View File

@@ -28,6 +28,7 @@ import {
} from "lucide-react";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { ViewHeader } from "./ViewHeader";
import { isNativeStructureDragEnabled, serializeNativeStructureRef } from "../utils/nativeStructureDrag";
import { fetchModels, updateGlobalSettings, type ModelInfo } from "../api";
import { useInsights, type InsightSection } from "../hooks/useInsights";
import { BACKLOG_HEALTH_TITLE_PREFIXES, isBacklogHealthInsight } from "./backlog-health-filter";
@@ -413,7 +414,12 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask, model
);
return (
<li key={insight.id} className={`insight-item${isArchived ? " insight-item--archived" : ""}`} data-insight-id={insight.id}>
<li key={insight.id} className={`insight-item${isArchived ? " insight-item--archived" : ""}`} data-insight-id={insight.id}
draggable={isNativeStructureDragEnabled()}
onDragStart={(event) => {
// FNXC:NativeStructureEmbed 2026-07-22-10:30: Persisted insights are resolvable research-finding refs for the mail composer.
serializeNativeStructureRef(event.dataTransfer, { kind: "research-finding", id: insight.id, projectId });
}}>
<div className="insight-item-header">
<h4 className="insight-item-title">{insight.title}</h4>
<div className="insight-item-actions">

View File

@@ -0,0 +1,22 @@
/* FNXC:NativeStructureEmbed 2026-07-22-10:30: A matching custom drag is visible without leaving an empty drop shell when ordinary compose is idle. */
.message-composer--native-structure-drag-over {
outline: solid var(--border-strong);
outline-offset: calc(var(--space-xs) * -1);
}
.message-composer-structure-list > li {
align-items: start;
display: flex;
flex-wrap: wrap;
gap: var(--space-sm);
}
.message-composer-structure-list .native-structure-preview {
flex: 1 1 auto;
}
@media (max-width: 768px) {
.message-composer-structure-list > li {
align-items: stretch;
}
}

View File

@@ -2,10 +2,16 @@ import { useState, useCallback, useMemo, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { useAutosizeTextarea } from "../hooks/useAutosizeTextarea";
import { X, Send, Loader2, Bot, AlertCircle } from "lucide-react";
import type { DragEvent } from "react";
import type { NativeStructureEmbed, NativeStructureRef, ParticipantType, MessageType } from "@fusion/core";
import { getErrorMessage } from "@fusion/core";
import { sendMessage } from "../api";
import type { Agent } from "../api";
import { NativeStructurePreview } from "./NativeStructurePreview";
import { ComposeChatPanel } from "./ComposeChatPanel";
import { openNativeStructure } from "./nativeStructureNavigation";
import { hasNativeStructureDrag, readNativeStructureRef } from "../utils/nativeStructureDrag";
import "./MessageComposer.css";
// ── Types ─────────────────────────────────────────────────────────────────
@@ -123,11 +129,46 @@ export function MessageComposer({
setToType("agent");
}, []);
const addNativeStructure = useCallback((ref: NativeStructureRef, label?: string) => {
setNativeStructures((current) => (
current.some((embed) => embed.kind === ref.kind && embed.id === ref.id)
? current
: [...current, { ...ref, ...(label ? { label } : {}) }]
));
}, []);
const attachNativeStructure = useCallback((candidateIndex: string) => {
const candidate = nativeStructureCandidates[Number(candidateIndex)];
if (!candidate) return;
setNativeStructures((current) => [...current, { ...candidate.ref, label: candidate.label }]);
}, [nativeStructureCandidates]);
if (candidate) addNativeStructure(candidate.ref, candidate.label);
}, [addNativeStructure, nativeStructureCandidates]);
const [isNativeStructureDragOver, setIsNativeStructureDragOver] = useState(false);
const [isComposeChatOpen, setIsComposeChatOpen] = useState(false);
/*
FNXC:NativeStructureEmbed 2026-07-22-10:30:
Desktop drags attach the same deduplicated embed state as the keyboard/mobile picker below.
Non-native payloads are deliberately not prevented so file and other browser drops retain their behavior.
*/
const handleDragOver = useCallback((event: DragEvent<HTMLDivElement>) => {
if (!hasNativeStructureDrag(event.dataTransfer)) return;
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
setIsNativeStructureDragOver(true);
}, []);
const handleDragLeave = useCallback((event: DragEvent<HTMLDivElement>) => {
if (event.currentTarget.contains(event.relatedTarget as Node | null)) return;
setIsNativeStructureDragOver(false);
}, []);
const handleDrop = useCallback((event: DragEvent<HTMLDivElement>) => {
if (!hasNativeStructureDrag(event.dataTransfer)) return;
event.preventDefault();
setIsNativeStructureDragOver(false);
const ref = readNativeStructureRef(event.dataTransfer);
if (ref) addNativeStructure(ref);
}, [addNativeStructure]);
const scrollTextareaIntoView = useCallback(() => {
if (typeof textareaRef.current?.scrollIntoView !== "function") {
@@ -156,7 +197,14 @@ export function MessageComposer({
}, [scrollTextareaIntoView]);
return (
<div className="message-composer" data-testid="message-composer">
<div
className={`message-composer${isNativeStructureDragOver ? " message-composer--native-structure-drag-over" : ""}`}
data-testid="message-composer"
aria-label="Message composer; drop a structure to attach it"
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<div className="message-composer-header">
<span>{replyContext ? t("composer.replyTitle", "Reply") : t("composer.newMessageTitle", "New Message")}</span>
<button
@@ -241,9 +289,9 @@ export function MessageComposer({
{/*
FNXC:NativeStructureEmbed 2026-07-20-12:00:
The composer receives project-scoped candidates from its mailbox parent and persists only
each reference plus label. Selection appends to the draft so reports can carry multiple
independently reviewable structures without serializing preview payloads.
The picker is the accessible keyboard/mobile fallback for the desktop drag protocol.
Both paths append only a reference plus captured label, dedupe by kind/id, and share the
persisted message metadata so reports carry first-class, independently reviewable embeds.
*/}
<div className="message-composer-field message-composer-field--structures">
<label className="message-composer-label" htmlFor="message-native-structure">Attach structure</label>
@@ -263,10 +311,14 @@ export function MessageComposer({
</select>
{nativeStructures.length > 0 && (
<ul className="message-composer-structure-list" data-testid="message-composer-attached-structures">
{nativeStructures.map((embed, index) => (
<li key={`${embed.kind}:${embed.id}:${index}`}>
<span>{embed.kind}: {embed.label ?? embed.id}</span>
<button className="btn btn-sm btn-secondary" type="button" onClick={() => setNativeStructures((current) => current.filter((_, currentIndex) => currentIndex !== index))} aria-label={`Remove ${embed.label ?? embed.id}`}>Remove</button>
{nativeStructures.map((embed) => (
<li key={`${embed.kind}:${embed.id}`}>
<NativeStructurePreview
ref={{ kind: embed.kind, id: embed.id, projectId: embed.projectId }}
capturedLabel={embed.label}
onOpen={openNativeStructure}
/>
<button className="btn btn-sm btn-secondary" type="button" onClick={() => setNativeStructures((current) => current.filter((currentEmbed) => currentEmbed.kind !== embed.kind || currentEmbed.id !== embed.id))} aria-label={`Remove ${embed.label ?? embed.id}`}>Remove</button>
</li>
))}
</ul>
@@ -274,6 +326,24 @@ export function MessageComposer({
</div>
</div>
<div className="message-composer-field">
<button className="btn btn-sm btn-secondary" type="button" onClick={() => setIsComposeChatOpen((open) => !open)} aria-expanded={isComposeChatOpen} aria-controls="compose-chat-panel">
{isComposeChatOpen ? "Hide compose chat" : "Draft with AI"}
</button>
{isComposeChatOpen && (
<ComposeChatPanel
projectId={projectId}
embeds={nativeStructures}
draftBody={content}
onUseDraft={(draft) => {
if (content.trim() && !window.confirm("Replace the message you have already typed with this draft?")) return;
setContent(draft);
}}
onClose={() => setIsComposeChatOpen(false)}
/>
)}
</div>
{/* Wake recipient toggle (agents only) */}
{recipientIsAgent && (
<div className="message-composer-field message-composer-field--wake">

View File

@@ -109,6 +109,7 @@ import {
import type { AutopilotState, MissionInterviewDraftSummary } from "./mission-types";
import { readCache, SWR_CACHE_KEYS, writeCache } from "../utils/swrCache";
import { getRelativeTimeBucket } from "../utils/relativeTimeAgo";
import { isNativeStructureDragEnabled, serializeNativeStructureRef } from "../utils/nativeStructureDrag";
const MISSION_SIDEBAR_DEFAULT_WIDTH = 300;
const MISSION_SIDEBAR_MIN_WIDTH = 220;
@@ -3026,7 +3027,10 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
return (
<div key={milestone.id} className="mission-milestone">
<div className="mission-milestone__header" onClick={() => toggleMilestoneExpanded(milestone.id)}>
<div className="mission-milestone__header" draggable={isNativeStructureDragEnabled()} onDragStart={(event) => {
// FNXC:NativeStructureEmbed 2026-07-22-10:30: Milestones use the same transferable ref as missions and mail drops.
serializeNativeStructureRef(event.dataTransfer, { kind: "milestone", id: milestone.id, projectId });
}} onClick={() => toggleMilestoneExpanded(milestone.id)}>
<button className="mission-milestone__expand">
{expandedMilestones.has(milestone.id) ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</button>
@@ -4438,6 +4442,11 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
tabIndex={0}
aria-label={t("missions.openMissionAriaLabel", "Open mission {{title}}", { title: m.title })}
aria-pressed={isSelected}
draggable={isNativeStructureDragEnabled()}
onDragStart={(event) => {
// FNXC:NativeStructureEmbed 2026-07-22-10:30: Mission cards emit the shared mail payload; coarse pointers rely on the accessible picker.
serializeNativeStructureRef(event.dataTransfer, { kind: "mission", id: m.id, projectId });
}}
onClick={() => handleSelectMission(mission)}
onKeyDown={(event) => {
if (event.currentTarget !== event.target) return;

View File

@@ -0,0 +1,102 @@
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");
});
});

View File

@@ -3,10 +3,16 @@ import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { MessageComposer } from "../MessageComposer";
import * as apiModule from "../../api";
import type { Agent } from "../../api";
import { NATIVE_STRUCTURE_DRAG_MIME } from "../../utils/nativeStructureDrag";
// Mock the API module
vi.mock("../../api", () => ({
sendMessage: vi.fn(),
fetchNativeStructurePreview: vi.fn().mockResolvedValue({ available: false, kind: "mission", id: "M-1", reason: "missing" }),
}));
vi.mock("../NativeStructurePreview", () => ({
NativeStructurePreview: ({ capturedLabel }: { capturedLabel?: string }) => <span>{capturedLabel ?? "Structure"}</span>,
}));
// Mock lucide-react icons
@@ -116,6 +122,25 @@ describe("MessageComposer", () => {
}), undefined));
});
it("attaches a native structure dropped on the composer once and ignores unrelated drops", () => {
render(<MessageComposer {...defaultProps} />);
const composer = screen.getByTestId("message-composer");
const nativeTransfer = {
types: [NATIVE_STRUCTURE_DRAG_MIME],
dropEffect: "none",
getData: (type: string) => type === NATIVE_STRUCTURE_DRAG_MIME ? JSON.stringify({ kind: "mission", id: "M-drop" }) : "",
} as unknown as DataTransfer;
fireEvent.dragOver(composer, { dataTransfer: nativeTransfer });
expect(composer).toHaveClass("message-composer--native-structure-drag-over");
fireEvent.drop(composer, { dataTransfer: nativeTransfer });
fireEvent.drop(composer, { dataTransfer: nativeTransfer });
expect(screen.getByTestId("message-composer-attached-structures").querySelectorAll("li")).toHaveLength(1);
const fileTransfer = { types: ["Files"], getData: () => "" } as unknown as DataTransfer;
fireEvent.dragOver(composer, { dataTransfer: fileTransfer });
expect(composer).not.toHaveClass("message-composer--native-structure-drag-over");
});
it("disables structural attachment selection when no candidates are available", () => {
render(<MessageComposer {...defaultProps} />);
expect(screen.getByTestId("message-composer-attach-structure")).toBeDisabled();

View File

@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import type { NativeStructureRef } from "@fusion/core";
import { hasNativeStructureDrag, NATIVE_STRUCTURE_DRAG_MIME, readNativeStructureRef, serializeNativeStructureRef } from "../nativeStructureDrag";
function transfer(): DataTransfer {
const data = new Map<string, string>();
return {
types: [] as unknown as DOMStringList,
effectAllowed: "none",
setData(type: string, value: string) {
data.set(type, value);
(this.types as unknown as string[]) = [...data.keys()];
},
getData(type: string) {
return data.get(type) ?? "";
},
} as unknown as DataTransfer;
}
const refs: NativeStructureRef[] = [
{ kind: "mission", id: "M-1" },
{ kind: "milestone", id: "MS-1" },
{ kind: "goal", id: "G-1" },
{ kind: "research-finding", id: "INS-1" },
{ kind: "eval-result", id: "E-1" },
];
describe("nativeStructureDrag", () => {
it.each(refs)("round-trips $kind references", (ref) => {
const dataTransfer = transfer();
serializeNativeStructureRef(dataTransfer, ref);
expect(readNativeStructureRef(dataTransfer)).toEqual(ref);
expect(dataTransfer.getData("text/plain")).toBe(`fusion://${ref.kind}/${ref.id}`);
expect(dataTransfer.effectAllowed).toBe("copy");
});
it("rejects missing or malformed payloads", () => {
const missing = transfer();
const malformed = transfer();
malformed.setData(NATIVE_STRUCTURE_DRAG_MIME, "not json");
const unsupported = transfer();
unsupported.setData(NATIVE_STRUCTURE_DRAG_MIME, JSON.stringify({ kind: "roadmap-item", id: "R-1" }));
expect(readNativeStructureRef(missing)).toBeNull();
expect(readNativeStructureRef(malformed)).toBeNull();
expect(readNativeStructureRef(unsupported)).toBeNull();
});
it("guards only its own custom MIME type", () => {
const nativeDrag = transfer();
nativeDrag.setData(NATIVE_STRUCTURE_DRAG_MIME, JSON.stringify(refs[0]));
const fileDrag = transfer();
fileDrag.setData("text/plain", "file");
expect(hasNativeStructureDrag(nativeDrag)).toBe(true);
expect(hasNativeStructureDrag(fileDrag)).toBe(false);
});
});

View File

@@ -0,0 +1,54 @@
import type { NativeStructureRef } from "@fusion/core";
export const NATIVE_STRUCTURE_DRAG_MIME = "application/x-fusion-native-structure";
const NATIVE_STRUCTURE_KINDS: readonly NativeStructureRef["kind"][] = [
"mission",
"milestone",
"goal",
"research-finding",
"eval-result",
];
/**
* FNXC:NativeStructureEmbed 2026-07-22-10:30:
* Mail and every native-structure owner exchange this one DataTransfer protocol rather than
* inventing per-view payloads. It intentionally accepts only the five persisted preview kinds:
* mission, milestone, goal, research-finding, and eval-result.
*/
export function serializeNativeStructureRef(dataTransfer: DataTransfer, ref: NativeStructureRef): void {
dataTransfer.setData(NATIVE_STRUCTURE_DRAG_MIME, JSON.stringify(ref));
dataTransfer.setData("text/plain", `fusion://${ref.kind}/${ref.id}`);
dataTransfer.effectAllowed = "copy";
}
/** Reads and validates an untrusted browser drag payload. */
export function readNativeStructureRef(dataTransfer: DataTransfer): NativeStructureRef | null {
try {
const parsed: unknown = JSON.parse(dataTransfer.getData(NATIVE_STRUCTURE_DRAG_MIME));
if (
typeof parsed !== "object" ||
parsed === null ||
!NATIVE_STRUCTURE_KINDS.includes((parsed as NativeStructureRef).kind) ||
typeof (parsed as NativeStructureRef).id !== "string" ||
!(parsed as NativeStructureRef).id.trim() ||
((parsed as NativeStructureRef).projectId !== undefined && typeof (parsed as NativeStructureRef).projectId !== "string")
) {
return null;
}
const { kind, id, projectId } = parsed as NativeStructureRef;
return projectId === undefined ? { kind, id } : { kind, id, projectId };
} catch {
return null;
}
}
/** True only for drags emitted by serializeNativeStructureRef. */
export function hasNativeStructureDrag(dataTransfer: DataTransfer): boolean {
return Array.from(dataTransfer.types).includes(NATIVE_STRUCTURE_DRAG_MIME);
}
/** Native HTML dragging on touch-primary devices competes with scrolling; use the composer picker there. */
export function isNativeStructureDragEnabled(): boolean {
return typeof window === "undefined" || !window.matchMedia("(pointer: coarse)").matches;
}