diff --git a/.changeset/fn-8293-mail-drag-compose-chat.md b/.changeset/fn-8293-mail-drag-compose-chat.md new file mode 100644 index 0000000000..ce8c509383 --- /dev/null +++ b/.changeset/fn-8293-mail-drag-compose-chat.md @@ -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. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index d98560d1d2..b4e2d697d8 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -705,6 +705,9 @@ Quick Chat is an optional fast, project-scoped assistant surface for conversatio Mailbox view shows inbox/outbox communication threads and unread state. When an ephemeral worker is configured for follow-up validation, its task proposals include a **Create task** action; created proposals link directly to the resulting task. +- Mail composers can attach a native mission, milestone, goal, persisted insight, or eval result by dragging it from its owning view, or through the keyboard/mobile **Attach structure** picker. The shared `nativeStructureDrag` payload is copied into the same first-class mail embed metadata as picker attachments. +- **Draft with AI** opens a compact compose-chat scratch session that uses attached structures as context. **Use draft** replaces an empty message body; replacing typed text requires confirmation, and attached embeds remain in place. + - Inbox renders one row per message (no sender-based collapsing) - clicking a message in the Mail tab opens the task detail pane with full message content and conversation context - reply rows in the mailbox modal can expand inline to show the replied-to message context for easier thread reading diff --git a/packages/dashboard/app/components/ComposeChatPanel.css b/packages/dashboard/app/components/ComposeChatPanel.css new file mode 100644 index 0000000000..fc0774cd45 --- /dev/null +++ b/packages/dashboard/app/components/ComposeChatPanel.css @@ -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; + } +} diff --git a/packages/dashboard/app/components/ComposeChatPanel.tsx b/packages/dashboard/app/components/ComposeChatPanel.tsx new file mode 100644 index 0000000000..a58aa30993 --- /dev/null +++ b/packages/dashboard/app/components/ComposeChatPanel.tsx @@ -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(null); + const scratchSessionId = useRef(null); + const priorSession = useRef(null); + const pendingPrompt = useRef(null); + const creatingSession = useRef(false); + const closed = useRef(false); + const archivedSessionId = useRef(null); + const restoredSessionId = useRef(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 ( +
+ +