import { useAgentChatGenerating } from "@agent-native/core/client/agent-chat"; import { useT } from "@agent-native/core/client/i18n"; import { appendSignatureToBody, splitAppendedSignature, } from "@shared/signature"; import type { ComposeState } from "@shared/types"; import { IconX, IconMinus, IconArrowsMaximize, IconArrowsMinimize, IconBold, IconItalic, IconLink, IconPaperclip, IconChevronDown, IconDots, IconLoader2, IconTrash, IconPlus, } from "@tabler/icons-react"; import { useState, useEffect, useRef, useMemo, useCallback } from "react"; import type { CSSProperties } from "react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import { useAccountFilter } from "@/hooks/use-account-filter"; import { useAliases } from "@/hooks/use-aliases"; import { useUpdateQueuedDraft } from "@/hooks/use-draft-queue"; import { useSendEmail, useAddOptimisticReply } from "@/hooks/use-emails"; import { useSettings } from "@/hooks/use-emails"; import { useIsMobile } from "@/hooks/use-mobile"; import { useScheduleEmail } from "@/hooks/use-scheduled-jobs"; import { canUseAgentGenerate } from "@/lib/agent-generate"; import { expandAliasTokens } from "@/lib/alias-utils"; import { openFilePicker, uploadFile, uploadFiles } from "@/lib/upload"; import { cn } from "@/lib/utils"; import { AttachmentStrip } from "./AttachmentStrip"; import { getCurrentDraftBodyFromEditor, splitQuotedContent, } from "./compose-draft-context"; import { ComposeEditor, type ComposeEditorHandle } from "./ComposeEditor"; import { RecipientInput, computeRecipientMove, type RecipientField, } from "./RecipientInput"; import { SendLaterButton } from "./SendLaterButton"; const LAST_SEND_ACCOUNT_KEY = "mail:lastSendAccount"; function FromAccountSelector({ accounts, value, onChange, }: { accounts: Array<{ email: string; displayName?: string }>; value: string | undefined; onChange: (email: string) => void; }) { // On mount, if no account is set, apply the sticky default const resolvedValue = value || (accounts.some( (a) => a.email === localStorage.getItem(LAST_SEND_ACCOUNT_KEY), ) ? localStorage.getItem(LAST_SEND_ACCOUNT_KEY)! : accounts[0]?.email) || ""; // Sync the sticky default into the draft if it wasn't set useEffect(() => { if (!value && resolvedValue) { onChange(resolvedValue); } }, []); // eslint-disable-line react-hooks/exhaustive-deps return (
From
); } interface ComposeModalProps { drafts: ComposeState[]; activeId: string | null; activeDraft: ComposeState | null; initialExpanded?: boolean; onSetActiveId: (id: string) => void; onUpdate: (id: string, partial: Partial) => void; onClose: (id: string) => void; onCloseAll: () => void; onDiscard: (id: string) => void; onNewDraft: () => void; onFlush: (id: string) => Promise | undefined; onReopen: (state: Omit) => void; onInitialExpandedConsumed?: () => void; } export function ComposeModal({ drafts, activeId, activeDraft, initialExpanded = false, onSetActiveId, onUpdate, onClose, onCloseAll, onDiscard, onNewDraft, onFlush, onReopen, onInitialExpandedConsumed, }: ComposeModalProps) { const t = useT(); const isMobile = useIsMobile(); const [minimized, setMinimized] = useState(false); const [isExpanded, setIsExpanded] = useState(false); const [generateOpen, setGenerateOpen] = useState(false); const [generatePrompt, setGeneratePrompt] = useState(""); const [showCcBcc, setShowCcBcc] = useState(false); const [showQuoted, setShowQuoted] = useState(false); // Observe agent sidebar width so compose window stays to its left const [sidebarRight, setSidebarRight] = useState(16); // default 16px (right-4) useEffect(() => { function measure() { const panel = document.querySelector(".agent-sidebar-panel"); // Also account for the resize handle (6px) const panelWidth = panel ? panel.getBoundingClientRect().width + 6 : 0; setSidebarRight(panelWidth > 0 ? panelWidth + 16 : 16); } measure(); const observer = new MutationObserver(measure); // Watch for sidebar appearing/disappearing and style changes (resize) observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ["style", "class"], }); window.addEventListener("resize", measure); return () => { observer.disconnect(); window.removeEventListener("resize", measure); }; }, []); const [isGenerating, sendToAgent] = useAgentChatGenerating(); const sendEmail = useSendEmail(); const addOptimisticReply = useAddOptimisticReply(); const updateQueuedDraft = useUpdateQueuedDraft(); const scheduleEmail = useScheduleEmail(); const { data: aliases = [] } = useAliases(); const { data: settings } = useSettings(); const { allAccounts } = useAccountFilter(); const editorRef = useRef(null); const promptRef = useRef(null); const sendingIdsRef = useRef>(new Set()); // Reset CC/BCC visibility and quote expansion when switching tabs useEffect(() => { setShowCcBcc(false); setShowQuoted(false); }, [activeId]); // Focus editor when reply/forward opens useEffect(() => { if (activeDraft?.mode && activeDraft.mode !== "compose") { setTimeout(() => editorRef.current?.getEditor()?.commands.focus(), 100); } }, [activeDraft?.mode, activeId]); useEffect(() => { if (!initialExpanded || !activeDraft) return; setMinimized(false); setIsExpanded(true); onInitialExpandedConsumed?.(); }, [activeDraft?.id, initialExpanded, onInitialExpandedConsumed]); const handleSend = async () => { if (!activeDraft || !activeId) return; if (sendingIdsRef.current.has(activeId)) return; if (!activeDraft.to.trim()) { toast.error(t("mail.toasts.pleaseAddRecipient")); return; } sendingIdsRef.current.add(activeId); const sendingId = activeId; // Snapshot draft data for potential undo const draftSnapshot = { ...activeDraft }; // Close composer immediately onDiscard(activeId); // Show optimistic reply in the thread immediately (for replies) const undoOptimistic = draftSnapshot.replyToId ? addOptimisticReply({ to: expandAliasTokens(draftSnapshot.to, aliases), cc: expandAliasTokens(draftSnapshot.cc ?? "", aliases) || undefined, subject: draftSnapshot.subject, body: draftSnapshot.body, replyToId: draftSnapshot.replyToId, replyToThreadId: draftSnapshot.replyToThreadId, accountEmail: draftSnapshot.accountEmail, attachments: draftSnapshot.attachments, }) : undefined; let cancelled = false; const handleUndo = () => { if (cancelled) return; cancelled = true; sendingIdsRef.current.delete(sendingId); clearTimeout(sendTimer); clearTimeout(transitionTimer); toast.dismiss(toastId); undoOptimistic?.(); // Reopen composer with the saved draft const { id: _id, ...reopenData } = draftSnapshot; onReopen(reopenData); }; // Show "Sending..." toast with undo const toastId = toast("Sending...", { action: { label: "UNDO", onClick: handleUndo }, duration: Infinity, }); // After 1.5s, transition to "Message sent." const transitionTimer = setTimeout(() => { if (cancelled) return; toast("Message sent.", { id: toastId, action: { label: "UNDO", onClick: handleUndo }, duration: Infinity, }); }, 1500); // After 5s, actually send the email const sendTimer = setTimeout(() => { if (cancelled) return; sendingIdsRef.current.delete(sendingId); toast.dismiss(toastId); sendEmail.mutate( { to: expandAliasTokens(draftSnapshot.to, aliases), cc: expandAliasTokens(draftSnapshot.cc ?? "", aliases) || undefined, bcc: expandAliasTokens(draftSnapshot.bcc ?? "", aliases) || undefined, subject: draftSnapshot.subject, body: draftSnapshot.body, replyToId: draftSnapshot.replyToId, replyToThreadId: draftSnapshot.replyToThreadId, accountEmail: draftSnapshot.accountEmail, attachments: draftSnapshot.attachments, }, { onSuccess: (result) => { if (draftSnapshot.queuedDraftId) { updateQueuedDraft.mutate({ id: draftSnapshot.queuedDraftId, status: "sent", sentMessageId: result?.id, }); } }, onError: () => { toast.error(t("mail.toasts.failedToSendEmail")); // Reopen composer on failure const { id: _id, ...reopenData } = draftSnapshot; onReopen(reopenData); }, }, ); }, 5000); }; const handleSendLater = async (runAt: number) => { if (!activeDraft || !activeId) return; if (!activeDraft.to.trim()) { toast.error(t("mail.toasts.pleaseAddRecipient")); return; } const draftSnapshot = { ...activeDraft }; try { await scheduleEmail.mutateAsync({ to: expandAliasTokens(draftSnapshot.to, aliases), cc: expandAliasTokens(draftSnapshot.cc ?? "", aliases) || undefined, bcc: expandAliasTokens(draftSnapshot.bcc ?? "", aliases) || undefined, subject: draftSnapshot.subject, body: draftSnapshot.body, replyToId: draftSnapshot.replyToId, threadId: draftSnapshot.replyToThreadId, accountEmail: draftSnapshot.accountEmail, attachments: draftSnapshot.attachments, runAt, }); // Job created successfully — now discard the draft onDiscard(activeId); const scheduledDate = new Date(runAt).toLocaleString("en-US", { weekday: "short", month: "short", day: "numeric", hour: "numeric", minute: "2-digit", }); toast(`Scheduled for ${scheduledDate}`); } catch { toast.error(t("mail.toasts.failedToScheduleEmailDraftKeptOpen")); } }; const composeRef = useRef(null); const composeAnimationRef = useRef(null); const animateComposeLayout = useCallback((updateLayout: () => void) => { const compose = composeRef.current; const before = compose?.getBoundingClientRect(); updateLayout(); if ( !compose || !before || window.matchMedia("(prefers-reduced-motion: reduce)").matches ) { return; } requestAnimationFrame(() => { const after = compose.getBoundingClientRect(); if (after.width === 0 || after.height === 0) return; const translateX = before.left - after.left; const translateY = before.top - after.top; const scaleX = before.width / after.width; const scaleY = before.height / after.height; composeAnimationRef.current?.cancel(); composeAnimationRef.current = compose.animate( [ { transform: `translate(${translateX}px, ${translateY}px) scale(${scaleX}, ${scaleY})`, transformOrigin: "top left", }, { transform: "none", transformOrigin: "top left" }, ], { duration: 200, easing: "cubic-bezier(0.23, 1, 0.32, 1)", }, ); }); }, []); useEffect( () => () => { composeAnimationRef.current?.cancel(); }, [], ); const handleKeyDown = (e: React.KeyboardEvent) => { // Only handle shortcuts for events originating within the compose window // (prevents agent chat Cmd+Enter from triggering email send) if (!composeRef.current?.contains(e.target as Node)) return; if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { e.preventDefault(); handleSend(); } if (e.key === "Escape") { e.preventDefault(); if (activeId) onClose(activeId); } }; const handleGenerate = async () => { if (!generatePrompt.trim() || !activeId || !activeDraft) return; if (!(await canUseAgentGenerate())) { toast.error(t("mail.toasts.aiEngineRequired")); window.dispatchEvent(new CustomEvent("agent-panel:open")); return; } await onFlush(activeId); const promptDraft = { ...activeDraft, body: getCurrentDraftBodyFromEditor({ draft: activeDraft, editor: editorRef.current?.getEditor(), signature: settings?.signature, }), }; const context = [ promptDraft.to && `To: ${promptDraft.to}`, promptDraft.cc && `Cc: ${promptDraft.cc}`, promptDraft.subject && `Subject: ${promptDraft.subject}`, settings?.writingStyle?.trim() && `User writing style:\n${settings.writingStyle.trim()}`, settings?.signature?.trim() ? `Configured signature:\n${settings.signature.trim()}` : "Configured signature: (none)", promptDraft.body && `Current draft:\n${promptDraft.body}`, ] .filter(Boolean) .join("\n"); const draftContext = context || "(empty draft)"; sendToAgent({ message: generatePrompt.trim(), context: `The user is composing an email in Agent-Native Mail. Use the draft snapshot below as the source of truth, then update the existing draft by calling manage-draft with action "update", id "${activeId}", and the revised Markdown body. Do not only reply with the revised content; the Mail draft must be updated through the tool. Preserve recipients and subject unless the user explicitly asks to change them.\n\nDrafting rules:\n- Use the configured signature exactly when one is present, and do not duplicate it if it is already in the draft.\n- If no configured signature is present, do not invent or derive a sign-off from the user's name or email address.\n- Use Markdown only. Keep the copy natural, specific, and free of generic AI email filler unless the user asks for a formal template.\n\n${draftContext}`, submit: true, }); setGeneratePrompt(""); setGenerateOpen(false); }; // Move a recipient chip between To/Cc/Bcc (drag-and-drop). The compose draft // owns all three fields, so it can remove from the source and add to the // target atomically. const moveRecipient = ( value: string, from: RecipientField, to: RecipientField, ) => { if (!activeId || !activeDraft || from === to) return; const moved = computeRecipientMove( activeDraft[from] ?? "", activeDraft[to] ?? "", value, ); const partial: Partial = {}; partial[from] = moved.from; partial[to] = moved.to; onUpdate(activeId, partial); }; const handleAttachFiles = async (files: File[]) => { if (!activeId || !activeDraft || files.length === 0) return; try { const attachments = await uploadFiles(files); const existing = activeDraft.attachments ?? []; onUpdate(activeId, { attachments: [...existing, ...attachments] }); } catch { toast.error(t("mail.toasts.failedToAttachFile")); } }; const handleUploadImage = async (file: File) => { try { const result = await uploadFile(file); return result.url; } catch (err) { toast.error(t("mail.toasts.failedToUploadImage")); throw err; } }; const handleAttach = async () => { const file = await openFilePicker("*/*"); if (!file) return; await handleAttachFiles([file]); }; const handleDragOver = (e: React.DragEvent) => { if (!Array.from(e.dataTransfer.types).includes("Files")) return; e.preventDefault(); e.stopPropagation(); e.dataTransfer.dropEffect = "copy"; }; const handleDrop = (e: React.DragEvent) => { const files = Array.from(e.dataTransfer.files ?? []); if (files.length === 0) return; // All-image drops landing inside the editor are left alone here so // ComposeEditor's own handleDrop (bubble phase) can insert them inline; // everything else (non-image or mixed drops) still goes to attachments. const target = e.target as HTMLElement; const droppedOnEditor = target.closest(".compose-editor") != null; if ( droppedOnEditor && files.every((file) => file.type.startsWith("image/")) ) { return; } e.preventDefault(); e.stopPropagation(); void handleAttachFiles(files); }; const handleRemoveAttachment = (attachmentId: string) => { if (!activeId || !activeDraft) return; const existing = activeDraft.attachments ?? []; onUpdate(activeId, { attachments: existing.filter((a) => a.id !== attachmentId), }); }; const title = activeDraft ? activeDraft.queuedDraftId ? "Queued draft" : activeDraft.mode === "reply" ? "Reply" : activeDraft.mode === "forward" ? "Forward" : "New message" : "New message"; const composeStyle = { right: isMobile ? 0 : sidebarRight, "--compose-right": `${isMobile ? 0 : sidebarRight}px`, } as CSSProperties & Record<"--compose-right", string>; return (
{/* Title bar with inline tabs */}
{/* Left side: tabs (or single title) */}
{drafts.length <= 1 ? ( /* Single draft: just show the title */ {title} ) : ( /* Multiple drafts: show tabs */ drafts.map((draft) => { const isActive = draft.id === activeId; const label = draft.subject?.trim() || (draft.queuedDraftId ? "Queued draft" : draft.mode === "reply" ? "Reply" : draft.mode === "forward" ? "Forward" : "New message"); return ( ); }) )} {/* + button: always visible, right after title/tabs */} {t("mail.compose.newDraft")}
{/* Right side: minimize & close */}
{minimized ? t("mail.compose.restore") : t("mail.compose.minimize")} {!minimized && ( {isExpanded ? t("mail.compose.restoreSize") : t("mail.compose.fullScreen")} )}
{activeDraft && !minimized && ( <> {/* Header fields */}
{allAccounts.length > 1 && ( onUpdate(activeId!, { accountEmail: email }) } /> )}
To onUpdate(activeId!, { to: val })} autoFocus={activeDraft.mode === "compose"} field="to" onMoveRecipient={moveRecipient} />
{showCcBcc && ( <>
Cc onUpdate(activeId!, { cc: val })} field="cc" onMoveRecipient={moveRecipient} />
Bcc onUpdate(activeId!, { bcc: val })} field="bcc" onMoveRecipient={moveRecipient} />
)}
onUpdate(activeId!, { subject: e.target.value }) } placeholder={t("mail.compose.subject")} className="flex-1 bg-transparent py-2 text-sm outline-none placeholder:text-muted-foreground" />
{/* Body */} {/* Attachments */} {activeDraft.attachments && activeDraft.attachments.length > 0 && ( )} {/* Toolbar */}
{t("mail.compose.bold")} {t("mail.compose.italic")} {t("mail.compose.insertLink")} {t("mail.compose.attachFile")}
{isGenerating ? (
{t("mail.compose.generating")}
) : (