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

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

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

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

409 lines
16 KiB
TypeScript

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 ─────────────────────────────────────────────────────────────────
export interface NativeStructureCandidate {
ref: NativeStructureRef;
label: string;
}
interface MessageComposerProps {
/** Pre-fill recipient (e.g. when replying) */
recipient?: { id: string; type: ParticipantType } | null;
/** Reply context for linked replies */
replyContext?: { messageId: string; preview?: string } | null;
/** List of agents for recipient selection */
agents?: Agent[];
/** Project ID for multi-project */
projectId?: string;
/** Called when message is successfully sent */
onSend: () => void;
/** Called when user cancels */
onCancel: () => void;
/** Toast notification callback */
addToast?: (msg: string, type?: "success" | "error") => void;
/** Loading state for agents (shows placeholder) */
isLoadingAgents?: boolean;
/** Project-scoped structures the mail parent makes available for attachment. */
nativeStructureCandidates?: NativeStructureCandidate[];
}
const MAX_CONTENT_LENGTH = 2000;
// ── Component ─────────────────────────────────────────────────────────────
export function MessageComposer({
recipient,
replyContext,
agents = [],
projectId,
onSend,
onCancel,
addToast,
isLoadingAgents = false,
nativeStructureCandidates = [],
}: MessageComposerProps) {
const { t } = useTranslation("app");
const [toId, setToId] = useState(recipient?.id ?? "");
const [toType, setToType] = useState<ParticipantType>(recipient?.type ?? "agent");
const [content, setContent] = useState("");
const [wakeRecipient, setWakeRecipient] = useState(false);
const [nativeStructures, setNativeStructures] = useState<NativeStructureEmbed[]>([]);
const [isSending, setIsSending] = useState(false);
const [error, setError] = useState<string | null>(null);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
// Aligned with ChatView's 640px cap so pasted multi-paragraph messages stay
// visible without internal scroll.
const { ref: autosizeRef } = useAutosizeTextarea({
value: content,
minHeight: 68,
maxHeight: 640,
});
const setTextareaRef = useCallback((node: HTMLTextAreaElement | null) => {
textareaRef.current = node;
autosizeRef(node);
}, [autosizeRef]);
const selectedAgent = useMemo(() => agents.find((agent) => agent.id === toId), [agents, toId]);
const prefilledRecipientAgent = useMemo(
() => (recipient ? agents.find((agent) => agent.id === recipient.id) : undefined),
[agents, recipient],
);
const recipientIsAgent = toType === "agent";
const recipientAlwaysImmediate = recipientIsAgent && selectedAgent?.runtimeConfig?.messageResponseMode === "immediate";
const wakeImmediately = recipientIsAgent && (wakeRecipient || recipientAlwaysImmediate);
const isValid = toId.trim() !== "" && content.trim().length > 0 && content.length <= MAX_CONTENT_LENGTH;
const handleSend = useCallback(async () => {
if (!isValid || isSending) return;
setIsSending(true);
setError(null);
try {
const messageType: MessageType = toType === "agent" ? "user-to-agent" : "system";
const metadata = {
...(replyContext ? { replyTo: { messageId: replyContext.messageId } } : {}),
...(nativeStructures.length > 0 ? { nativeStructures } : {}),
};
const hasMetadata = Object.keys(metadata).length > 0;
const sendWakeImmediately = wakeImmediately;
await sendMessage(
{
toId: toId.trim(),
toType,
content: content.trim(),
type: messageType,
...(hasMetadata ? { metadata } : {}),
...(sendWakeImmediately ? { wakeImmediately: true } : {}),
},
projectId,
);
onSend();
} catch (err) {
const msg = getErrorMessage(err) || "Failed to send message";
setError(msg);
addToast?.(msg, "error");
} finally {
setIsSending(false);
}
}, [isValid, isSending, toId, toType, content, wakeImmediately, replyContext, nativeStructures, projectId, onSend, addToast]);
const handleAgentSelect = useCallback((agentId: string) => {
setToId(agentId);
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) 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") {
return;
}
textareaRef.current.scrollIntoView({ block: "center", behavior: "auto" });
}, []);
useEffect(() => {
if (!replyContext) {
return;
}
textareaRef.current?.focus();
}, [replyContext]);
useEffect(() => {
if (typeof window === "undefined" || window.visualViewport == null) {
return;
}
window.visualViewport.addEventListener("resize", scrollTextareaIntoView);
return () => {
window.visualViewport?.removeEventListener("resize", scrollTextareaIntoView);
};
}, [scrollTextareaIntoView]);
return (
<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
className="btn-icon"
onClick={onCancel}
aria-label={t("actions.cancel", "Cancel")}
data-testid="message-composer-cancel"
>
<X size={16} />
</button>
</div>
<div className="message-composer-body">
{/* Recipient selection */}
{!recipient && (
<div className="message-composer-field">
<label className="message-composer-label" htmlFor="message-recipient">
{t("composer.toLabel", "To:")}
</label>
<select
id="message-recipient"
className="message-composer-select"
value={toId}
onChange={(e) => handleAgentSelect(e.target.value)}
disabled={isLoadingAgents || agents.length === 0}
data-testid="message-composer-recipient"
>
<option value="">
{isLoadingAgents ? t("composer.loadingAgents", "Loading agents…") : agents.length === 0 ? t("composer.noAgentsAvailable", "No agents available") : t("composer.selectAgent", "Select agent…")}
</option>
{agents.map((agent) => (
<option key={agent.id} value={agent.id}>
{agent.name || agent.id}
</option>
))}
</select>
</div>
)}
{/* Recipient display (when pre-filled from reply) */}
{recipient && (
<div className="message-composer-field">
<span className="message-composer-label">{t("composer.toLabel", "To:")}</span>
<span className="message-composer-recipient-fixed">
<Bot size={14} />
{prefilledRecipientAgent?.name || recipient.id}
</span>
</div>
)}
{replyContext && (
<div className="message-composer-field" data-testid="message-composer-reply-context">
<span className="message-composer-label">{t("composer.replyingToLabel", "Replying to:")}</span>
<span className="message-composer-recipient-fixed">
{replyContext.preview?.trim() ? replyContext.preview : t("composer.messageId", "Message {{id}}", { id: replyContext.messageId })}
</span>
</div>
)}
{/* Content */}
<div className="message-composer-field message-composer-field--content">
<label className="message-composer-label" htmlFor="message-content">
{t("composer.messageLabel", "Message:")}
</label>
<textarea
id="message-content"
ref={setTextareaRef}
className="message-composer-textarea"
placeholder={t("composer.messagePlaceholder", "Type your message…")}
value={content}
onChange={(e) => setContent(e.target.value)}
onFocus={scrollTextareaIntoView}
maxLength={MAX_CONTENT_LENGTH}
data-testid="message-composer-content"
/>
<div className="message-composer-charcount" data-testid="message-composer-charcount">
<span className={content.length > MAX_CONTENT_LENGTH ? "over-limit" : ""}>
{content.length}/{MAX_CONTENT_LENGTH}
</span>
</div>
</div>
{/*
FNXC:NativeStructureEmbed 2026-07-20-12:00:
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>
<div className="message-composer-structure-controls">
<select
id="message-native-structure"
className="message-composer-select"
value=""
disabled={nativeStructureCandidates.length === 0}
onChange={(event) => attachNativeStructure(event.target.value)}
data-testid="message-composer-attach-structure"
>
<option value="">{nativeStructureCandidates.length === 0 ? "No structures available" : "Select structure…"}</option>
{nativeStructureCandidates.map((candidate, index) => (
<option key={`${candidate.ref.kind}:${candidate.ref.id}`} value={index}>{candidate.ref.kind}: {candidate.label}</option>
))}
</select>
{nativeStructures.length > 0 && (
<ul className="message-composer-structure-list" data-testid="message-composer-attached-structures">
{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>
)}
</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">
<label className="message-composer-wake-label">
<input
type="checkbox"
checked={wakeImmediately}
disabled={recipientAlwaysImmediate}
onChange={(e) => setWakeRecipient(e.target.checked)}
data-testid="message-composer-wake"
/>
<span>
{t("composer.wakeAgentCheckbox", "Wake agent immediately")}
<span className="message-composer-wake-hint" data-testid="message-composer-wake-hint">
{recipientAlwaysImmediate
? t("composer.wakeAlwaysImmediate", "(agent is already set to immediate response mode)")
: t("composer.wakeOneOff", "(one-off override for this message only)")}
</span>
</span>
</label>
</div>
)}
{/* Error */}
{error && (
<div className="message-composer-error" data-testid="message-composer-error">
<AlertCircle size={14} />
<span>{error}</span>
</div>
)}
</div>
<div className="message-composer-footer">
<button
className="btn btn-sm btn-secondary"
onClick={onCancel}
data-testid="message-composer-cancel-btn"
>
{t("actions.cancel", "Cancel")}
</button>
<button
className="btn btn-sm btn-primary"
onClick={handleSend}
disabled={!isValid || isSending}
data-testid="message-composer-send"
>
{isSending ? (
<>
<Loader2 size={14} className="spin" />
<span>{t("composer.sendingButton", "Sending…")}</span>
</>
) : (
<>
<Send size={14} />
<span>{t("actions.send", "Send")}</span>
</>
)}
</button>
</div>
</div>
);
}