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. 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) - 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 - 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 - 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 { LoadingSpinner } from "./LoadingSpinner";
import { ViewHeader } from "./ViewHeader"; import { ViewHeader } from "./ViewHeader";
import "./EvalsView.css"; import "./EvalsView.css";
import { isNativeStructureDragEnabled, serializeNativeStructureRef } from "../utils/nativeStructureDrag";
interface EvalsViewProps { interface EvalsViewProps {
projectId?: string; projectId?: string;
@@ -98,7 +99,7 @@ export function EvalsView({ projectId, onOpenSettings, onOpenTaskDetail }: Evals
<ul className="evals-results" data-testid="evals-results"> <ul className="evals-results" data-testid="evals-results">
{results.map((result) => ( {results.map((result) => (
<li key={result.id}> <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-title">{result.taskTitle}</span>
<span className="evals-result-meta">{result.taskId} · {result.runId} · {result.overallScore ?? t("evals.naPlaceholder", "n/a")}</span> <span className="evals-result-meta">{result.taskId} · {result.runId} · {result.overallScore ?? t("evals.naPlaceholder", "n/a")}</span>
</button> </button>

View File

@@ -7,6 +7,7 @@ import remarkGfm from "remark-gfm";
import { draftGoalDescription, getRefineErrorMessage } from "../api"; import { draftGoalDescription, getRefineErrorMessage } from "../api";
import { ViewHeader } from "./ViewHeader"; import { ViewHeader } from "./ViewHeader";
import "./GoalsView.css"; import "./GoalsView.css";
import { isNativeStructureDragEnabled, serializeNativeStructureRef } from "../utils/nativeStructureDrag";
export interface GoalsViewProps { export interface GoalsViewProps {
initialGoals?: Goal[]; initialGoals?: Goal[];
@@ -546,6 +547,11 @@ export function GoalsView({ initialGoals, anchorGoalId, projectId, onNavigateToM
id={`goal-card-${goal.id}`} id={`goal-card-${goal.id}`}
className={`card goals-card ${goal.status === "archived" ? "goals-card-archived" : ""} ${highlightedGoalId === goal.id ? "goals-card--anchored" : ""}`.trim()} className={`card goals-card ${goal.status === "archived" ? "goals-card-archived" : ""} ${highlightedGoalId === goal.id ? "goals-card--anchored" : ""}`.trim()}
data-testid={`goal-card-${goal.id}`} 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 ? ( {editGoalId === goal.id ? (
<div className="goals-card-main goals-card-edit"> <div className="goals-card-main goals-card-edit">

View File

@@ -28,6 +28,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { CustomModelDropdown } from "./CustomModelDropdown"; import { CustomModelDropdown } from "./CustomModelDropdown";
import { ViewHeader } from "./ViewHeader"; import { ViewHeader } from "./ViewHeader";
import { isNativeStructureDragEnabled, serializeNativeStructureRef } from "../utils/nativeStructureDrag";
import { fetchModels, updateGlobalSettings, type ModelInfo } from "../api"; import { fetchModels, updateGlobalSettings, type ModelInfo } from "../api";
import { useInsights, type InsightSection } from "../hooks/useInsights"; import { useInsights, type InsightSection } from "../hooks/useInsights";
import { BACKLOG_HEALTH_TITLE_PREFIXES, isBacklogHealthInsight } from "./backlog-health-filter"; import { BACKLOG_HEALTH_TITLE_PREFIXES, isBacklogHealthInsight } from "./backlog-health-filter";
@@ -413,7 +414,12 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask, model
); );
return ( 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"> <div className="insight-item-header">
<h4 className="insight-item-title">{insight.title}</h4> <h4 className="insight-item-title">{insight.title}</h4>
<div className="insight-item-actions"> <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 { useTranslation } from "react-i18next";
import { useAutosizeTextarea } from "../hooks/useAutosizeTextarea"; import { useAutosizeTextarea } from "../hooks/useAutosizeTextarea";
import { X, Send, Loader2, Bot, AlertCircle } from "lucide-react"; import { X, Send, Loader2, Bot, AlertCircle } from "lucide-react";
import type { DragEvent } from "react";
import type { NativeStructureEmbed, NativeStructureRef, ParticipantType, MessageType } from "@fusion/core"; import type { NativeStructureEmbed, NativeStructureRef, ParticipantType, MessageType } from "@fusion/core";
import { getErrorMessage } from "@fusion/core"; import { getErrorMessage } from "@fusion/core";
import { sendMessage } from "../api"; import { sendMessage } from "../api";
import type { Agent } 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 ───────────────────────────────────────────────────────────────── // ── Types ─────────────────────────────────────────────────────────────────
@@ -123,11 +129,46 @@ export function MessageComposer({
setToType("agent"); 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 attachNativeStructure = useCallback((candidateIndex: string) => {
const candidate = nativeStructureCandidates[Number(candidateIndex)]; const candidate = nativeStructureCandidates[Number(candidateIndex)];
if (!candidate) return; if (candidate) addNativeStructure(candidate.ref, candidate.label);
setNativeStructures((current) => [...current, { ...candidate.ref, label: candidate.label }]); }, [addNativeStructure, nativeStructureCandidates]);
}, [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(() => { const scrollTextareaIntoView = useCallback(() => {
if (typeof textareaRef.current?.scrollIntoView !== "function") { if (typeof textareaRef.current?.scrollIntoView !== "function") {
@@ -156,7 +197,14 @@ export function MessageComposer({
}, [scrollTextareaIntoView]); }, [scrollTextareaIntoView]);
return ( 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"> <div className="message-composer-header">
<span>{replyContext ? t("composer.replyTitle", "Reply") : t("composer.newMessageTitle", "New Message")}</span> <span>{replyContext ? t("composer.replyTitle", "Reply") : t("composer.newMessageTitle", "New Message")}</span>
<button <button
@@ -241,9 +289,9 @@ export function MessageComposer({
{/* {/*
FNXC:NativeStructureEmbed 2026-07-20-12:00: FNXC:NativeStructureEmbed 2026-07-20-12:00:
The composer receives project-scoped candidates from its mailbox parent and persists only The picker is the accessible keyboard/mobile fallback for the desktop drag protocol.
each reference plus label. Selection appends to the draft so reports can carry multiple Both paths append only a reference plus captured label, dedupe by kind/id, and share the
independently reviewable structures without serializing preview payloads. persisted message metadata so reports carry first-class, independently reviewable embeds.
*/} */}
<div className="message-composer-field message-composer-field--structures"> <div className="message-composer-field message-composer-field--structures">
<label className="message-composer-label" htmlFor="message-native-structure">Attach structure</label> <label className="message-composer-label" htmlFor="message-native-structure">Attach structure</label>
@@ -263,10 +311,14 @@ export function MessageComposer({
</select> </select>
{nativeStructures.length > 0 && ( {nativeStructures.length > 0 && (
<ul className="message-composer-structure-list" data-testid="message-composer-attached-structures"> <ul className="message-composer-structure-list" data-testid="message-composer-attached-structures">
{nativeStructures.map((embed, index) => ( {nativeStructures.map((embed) => (
<li key={`${embed.kind}:${embed.id}:${index}`}> <li key={`${embed.kind}:${embed.id}`}>
<span>{embed.kind}: {embed.label ?? embed.id}</span> <NativeStructurePreview
<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> 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> </li>
))} ))}
</ul> </ul>
@@ -274,6 +326,24 @@ export function MessageComposer({
</div> </div>
</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) */} {/* Wake recipient toggle (agents only) */}
{recipientIsAgent && ( {recipientIsAgent && (
<div className="message-composer-field message-composer-field--wake"> <div className="message-composer-field message-composer-field--wake">

View File

@@ -109,6 +109,7 @@ import {
import type { AutopilotState, MissionInterviewDraftSummary } from "./mission-types"; import type { AutopilotState, MissionInterviewDraftSummary } from "./mission-types";
import { readCache, SWR_CACHE_KEYS, writeCache } from "../utils/swrCache"; import { readCache, SWR_CACHE_KEYS, writeCache } from "../utils/swrCache";
import { getRelativeTimeBucket } from "../utils/relativeTimeAgo"; import { getRelativeTimeBucket } from "../utils/relativeTimeAgo";
import { isNativeStructureDragEnabled, serializeNativeStructureRef } from "../utils/nativeStructureDrag";
const MISSION_SIDEBAR_DEFAULT_WIDTH = 300; const MISSION_SIDEBAR_DEFAULT_WIDTH = 300;
const MISSION_SIDEBAR_MIN_WIDTH = 220; const MISSION_SIDEBAR_MIN_WIDTH = 220;
@@ -3026,7 +3027,10 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
return ( return (
<div key={milestone.id} className="mission-milestone"> <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"> <button className="mission-milestone__expand">
{expandedMilestones.has(milestone.id) ? <ChevronDown size={16} /> : <ChevronRight size={16} />} {expandedMilestones.has(milestone.id) ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</button> </button>
@@ -4438,6 +4442,11 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
tabIndex={0} tabIndex={0}
aria-label={t("missions.openMissionAriaLabel", "Open mission {{title}}", { title: m.title })} aria-label={t("missions.openMissionAriaLabel", "Open mission {{title}}", { title: m.title })}
aria-pressed={isSelected} 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)} onClick={() => handleSelectMission(mission)}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.currentTarget !== event.target) return; 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 { MessageComposer } from "../MessageComposer";
import * as apiModule from "../../api"; import * as apiModule from "../../api";
import type { Agent } from "../../api"; import type { Agent } from "../../api";
import { NATIVE_STRUCTURE_DRAG_MIME } from "../../utils/nativeStructureDrag";
// Mock the API module // Mock the API module
vi.mock("../../api", () => ({ vi.mock("../../api", () => ({
sendMessage: vi.fn(), 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 // Mock lucide-react icons
@@ -116,6 +122,25 @@ describe("MessageComposer", () => {
}), undefined)); }), 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", () => { it("disables structural attachment selection when no candidates are available", () => {
render(<MessageComposer {...defaultProps} />); render(<MessageComposer {...defaultProps} />);
expect(screen.getByTestId("message-composer-attach-structure")).toBeDisabled(); 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;
}