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")}
) : (
)}
{t("mail.compose.deleteDraft")}
>
)}
);
}
/**
* Compose body area — splits quoted history from editable content.
* Shows "..." toggle for quoted content in reply/forward mode.
*/
function ComposeBody({
activeDraft,
activeId,
editorRef,
onUpdate,
onFlush,
onClose,
onSend,
isGenerating,
sendToAgent,
setGenerateOpen,
showQuoted,
setShowQuoted,
signature,
onUploadImage,
}: {
activeDraft: ComposeState;
activeId: string;
editorRef: React.RefObject;
onUpdate: (id: string, partial: Partial) => void;
onFlush: (id: string) => Promise | undefined;
onClose: (id: string) => void;
onSend: () => void;
isGenerating: boolean;
sendToAgent: (opts: {
message: string;
context?: string;
submit?: boolean;
}) => void;
setGenerateOpen: (open: boolean) => void;
showQuoted: boolean;
setShowQuoted: (show: boolean) => void;
signature?: string;
onUploadImage: (file: File) => Promise;
}) {
const t = useT();
const [editableContent, quotedContent] = useMemo(
() => splitQuotedContent(activeDraft.body),
[activeDraft.body],
);
const [messageContent, appendedSignature] = useMemo(
() =>
activeDraft.mode === "reply"
? splitAppendedSignature(editableContent, signature)
: [editableContent, ""],
[activeDraft.mode, editableContent, signature],
);
// Store quoted content in a ref so the onChange handler always has the latest
const quotedRef = useRef(quotedContent);
quotedRef.current = quotedContent;
const appendedSignatureRef = useRef(appendedSignature);
appendedSignatureRef.current = appendedSignature;
const hasQuote = quotedContent.length > 0;
const editorContent = appendedSignature
? messageContent
: hasQuote
? editableContent
: activeDraft.body;
return (
{
if (e.target === e.currentTarget) {
editorRef.current?.getEditor()?.commands.focus("end");
}
}}
>
{
if (appendedSignatureRef.current) {
onUpdate(activeId, {
body: appendSignatureToBody(
md + quotedRef.current,
appendedSignatureRef.current,
),
});
} else if (hasQuote) {
onUpdate(activeId, { body: md + quotedRef.current });
} else {
onUpdate(activeId, { body: md });
}
}}
onGenerate={() => setGenerateOpen(true)}
onSend={onSend}
onClose={() => onClose(activeId)}
onFlush={() => onFlush(activeId)}
isGenerating={isGenerating}
draftId={activeId}
getCurrentDraftBody={(editor) =>
getCurrentDraftBodyFromEditor({
draft: activeDraft,
editor,
signature,
})
}
sendToAgent={sendToAgent}
onUploadImage={onUploadImage}
/>
{hasQuote && (
<>
{showQuoted && (
{quotedContent.trim()}
)}
>
)}
);
}