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:
7
.changeset/fn-8293-mail-drag-compose-chat.md
Normal file
7
.changeset/fn-8293-mail-drag-compose-chat.md
Normal 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.
|
||||||
@@ -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
|
||||||
|
|||||||
33
packages/dashboard/app/components/ComposeChatPanel.css
Normal file
33
packages/dashboard/app/components/ComposeChatPanel.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
117
packages/dashboard/app/components/ComposeChatPanel.tsx
Normal file
117
packages/dashboard/app/components/ComposeChatPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
22
packages/dashboard/app/components/MessageComposer.css
Normal file
22
packages/dashboard/app/components/MessageComposer.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
54
packages/dashboard/app/utils/nativeStructureDrag.ts
Normal file
54
packages/dashboard/app/utils/nativeStructureDrag.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user