import { useCallback, useEffect, useRef, useState } from "react"; import { useComposerDictation } from "../hooks/useComposerDictation"; import { MicButton } from "./MicButton"; 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 requestRef = useRef(null); const dictation = useComposerDictation({ textareaRef: requestRef, value: request, onChange: setRequest, projectId }); 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 (