import { agentNativePath } from "@agent-native/core/client/api-path"; import { appApiPath } from "@agent-native/core/client/api-path"; import { appendSignatureToBody } from "@shared/signature"; import type { ComposeState, UserSettings } from "@shared/types"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { nanoid } from "nanoid"; import { useState, useRef, useCallback, useEffect } from "react"; import { TAB_ID } from "@/lib/tab-id"; export const FOCUS_COMPOSE_DRAFT_EVENT = "mail:focus-compose-draft"; const REMOVED_DRAFT_TOMBSTONE_TTL = 60_000; async function apiFetch(url: string, options?: RequestInit): Promise { const res = await fetch( url.startsWith("/api/") ? appApiPath(url) : agentNativePath(url), { headers: { "Content-Type": "application/json", "X-Request-Source": TAB_ID, }, ...options, }, ); if (!res.ok) { if (res.status === 404) return undefined as T; throw new Error(`Request failed (${res.status})`); } return res.json(); } /** Check if a compose draft has any meaningful content worth saving */ function hasDraftContent(draft: ComposeState): boolean { return !!( draft.to?.trim() || draft.cc?.trim() || draft.bcc?.trim() || draft.subject?.trim() || draft.body?.trim() ); } function pruneRemovedDraftIds(removed: Record) { const now = Date.now(); for (const [id, removedAt] of Object.entries(removed)) { if (now - removedAt > REMOVED_DRAFT_TOMBSTONE_TTL) { delete removed[id]; } } } export function filterRemovedDrafts( drafts: T[], removed: Record, ): T[] { pruneRemovedDraftIds(removed); return drafts.filter((draft) => removed[draft.id] === undefined); } /** Save a compose draft to persistent storage (emails with isDraft=true). * Returns the draftId so callers can track it for subsequent updates. */ async function saveDraftToEmails( draft: ComposeState, ): Promise { const result = await apiFetch<{ draftId?: string }>("/api/emails/draft", { method: "POST", body: JSON.stringify({ to: draft.to, cc: draft.cc, bcc: draft.bcc, subject: draft.subject, body: draft.body, draftId: draft.savedDraftId, replyToId: draft.replyToId, replyToThreadId: draft.replyToThreadId, accountEmail: draft.accountEmail, attachments: draft.attachments, }), }); return result?.draftId; } export async function saveDraftToEmailsBestEffort( draft: ComposeState, ): Promise { try { return await saveDraftToEmails(draft); } catch { return undefined; } } export function useComposeState() { const qc = useQueryClient(); const [activeId, setActiveId] = useState(null); const dirtyRef = useRef>({}); const versionRef = useRef>({}); const debounceRef = useRef>>({}); const gmailSaveRef = useRef>>( {}, ); const knownDraftIdsRef = useRef | null>(null); const removedDraftIdsRef = useRef>({}); // Fetch all drafts — short staleTime so agent-written drafts appear quickly const query = useQuery({ queryKey: ["compose-drafts"], queryFn: async () => { const result = await apiFetch( "/_agent-native/application-state/compose", ); const serverDrafts = filterRemovedDrafts( result ?? [], removedDraftIdsRef.current, ); const localDrafts = filterRemovedDrafts( qc.getQueryData(["compose-drafts"]) ?? [], removedDraftIdsRef.current, ); if (!localDrafts.length) return serverDrafts; const merged = serverDrafts.map((serverDraft) => { const localDraft = localDrafts.find((d) => d.id === serverDraft.id); return localDraft && dirtyRef.current[serverDraft.id] ? localDraft : serverDraft; }); for (const localDraft of localDrafts) { if ( dirtyRef.current[localDraft.id] && removedDraftIdsRef.current[localDraft.id] === undefined && !merged.some((d) => d.id === localDraft.id) ) { merged.push(localDraft); } } return merged; }, staleTime: 1_000, // request-storm-allow: one focus refresh reconciles bounded compose drafts across tabs. refetchOnWindowFocus: true, }); const drafts = query.data ?? []; useEffect(() => { const handleFocusDraft = (event: Event) => { const id = (event as CustomEvent<{ id?: unknown }>).detail?.id; if (typeof id === "string" && id.trim()) setActiveId(id); }; window.addEventListener(FOCUS_COMPOSE_DRAFT_EVENT, handleFocusDraft); return () => window.removeEventListener(FOCUS_COMPOSE_DRAFT_EVENT, handleFocusDraft); }, []); useEffect(() => { if (!query.isSuccess) return; const previousIds = knownDraftIdsRef.current; const currentIds = new Set(drafts.map((draft) => draft.id)); knownDraftIdsRef.current = currentIds; if (!previousIds) return; const newActiveId = newestUnseenPopoutDraftId(previousIds, drafts); if (newActiveId) setActiveId(newActiveId); }, [drafts, query.isSuccess]); // Resolve activeId: use current if valid, else last draft, else null const resolvedActiveId = activeId && drafts.some((d) => d.id === activeId) ? activeId : drafts.length > 0 ? drafts[drafts.length - 1].id : null; const activeDraft = drafts.find((d) => d.id === resolvedActiveId) ?? null; const putMutation = useMutation({ mutationFn: (state: ComposeState) => apiFetch(`/_agent-native/application-state/compose/${state.id}`, { method: "PUT", body: JSON.stringify(state), }), }); const deleteMutation = useMutation({ mutationFn: (id: string) => apiFetch(`/_agent-native/application-state/compose/${id}`, { method: "DELETE", }), }); const deleteAllMutation = useMutation({ mutationFn: () => apiFetch("/_agent-native/application-state/compose", { method: "DELETE", }), }); /** Open a new draft tab. Returns the new draft's id. */ const open = useCallback( (state: Omit) => { const id = nanoid(10); const settings = qc.getQueryData(["settings"]); const shouldAppendSignature = !state.savedDraftId && !state.queuedDraftId; const draft: ComposeState = { ...state, body: shouldAppendSignature ? appendSignatureToBody(state.body, settings?.signature) : state.body, id, }; delete removedDraftIdsRef.current[id]; // Optimistically add to cache qc.setQueryData(["compose-drafts"], (old) => [ ...(old ?? []), draft, ]); setActiveId(id); // Persist to server putMutation.mutate(draft); return id; }, [qc, putMutation], ); /** Auto-save a draft to Gmail/persistent storage, storing the returned draftId. */ const autoSaveToGmail = useCallback( (id: string) => { const current = ( qc.getQueryData(["compose-drafts"]) ?? [] ).find((d) => d.id === id); if (!current || !hasDraftContent(current)) return; void saveDraftToEmailsBestEffort(current).then((draftId) => { if (draftId && draftId !== current.savedDraftId) { // Store the Gmail draft ID back so subsequent saves update rather than create qc.setQueryData(["compose-drafts"], (old) => (old ?? []).map((d) => d.id === id ? { ...d, savedDraftId: draftId } : d, ), ); // Also persist the savedDraftId to application-state const updated = ( qc.getQueryData(["compose-drafts"]) ?? [] ).find((d) => d.id === id); if (updated) { putMutation.mutate({ ...updated, savedDraftId: draftId }); } } }); }, [qc, putMutation], ); /** Update a specific draft (debounced 300ms for app-state, 3s for Gmail). */ const update = useCallback( (id: string, partial: Partial) => { dirtyRef.current[id] = true; const version = (versionRef.current[id] ?? 0) + 1; versionRef.current[id] = version; // Optimistic cache update qc.setQueryData(["compose-drafts"], (old) => (old ?? []).map((d) => (d.id === id ? { ...d, ...partial } : d)), ); // Debounced write to application-state (300ms) if (debounceRef.current[id]) clearTimeout(debounceRef.current[id]); debounceRef.current[id] = setTimeout(() => { const current = ( qc.getQueryData(["compose-drafts"]) ?? [] ).find((d) => d.id === id); if (current) { putMutation.mutate(current, { onSettled: () => { if (versionRef.current[id] === version) { dirtyRef.current[id] = false; } }, }); } }, 300); // Debounced auto-save to Gmail (3s) if (gmailSaveRef.current[id]) clearTimeout(gmailSaveRef.current[id]); gmailSaveRef.current[id] = setTimeout(() => { autoSaveToGmail(id); }, 3_000); }, [qc, putMutation, autoSaveToGmail], ); /** Close a single draft tab — auto-saves to Drafts if it has content. */ const close = useCallback( (id: string) => { removedDraftIdsRef.current[id] = Date.now(); void qc.cancelQueries({ queryKey: ["compose-drafts"] }); // Clear debounce timers if (debounceRef.current[id]) clearTimeout(debounceRef.current[id]); if (gmailSaveRef.current[id]) clearTimeout(gmailSaveRef.current[id]); delete dirtyRef.current[id]; delete versionRef.current[id]; delete debounceRef.current[id]; delete gmailSaveRef.current[id]; // Get the draft before removing it const currentDrafts = qc.getQueryData(["compose-drafts"]) ?? []; const draft = currentDrafts.find((d) => d.id === id); const idx = currentDrafts.findIndex((d) => d.id === id); const remaining = currentDrafts.filter((d) => d.id !== id); // Auto-save to persistent drafts if there's any content if (draft && hasDraftContent(draft)) { void saveDraftToEmailsBestEffort(draft).then((draftId) => { if (draftId) qc.invalidateQueries({ queryKey: ["emails"] }); }); } if (id === resolvedActiveId) { const nextDraft = remaining[Math.min(idx, remaining.length - 1)]; setActiveId(nextDraft?.id ?? null); } // Remove from cache qc.setQueryData(["compose-drafts"], remaining); // Delete compose file deleteMutation.mutate(id); }, [qc, deleteMutation, resolvedActiveId], ); /** Discard a single draft — closes WITHOUT saving to Drafts. * If a Gmail draft was already created by auto-save, delete it. */ const discard = useCallback( (id: string) => { removedDraftIdsRef.current[id] = Date.now(); void qc.cancelQueries({ queryKey: ["compose-drafts"] }); if (debounceRef.current[id]) clearTimeout(debounceRef.current[id]); if (gmailSaveRef.current[id]) clearTimeout(gmailSaveRef.current[id]); delete dirtyRef.current[id]; delete versionRef.current[id]; delete debounceRef.current[id]; delete gmailSaveRef.current[id]; const currentDrafts = qc.getQueryData(["compose-drafts"]) ?? []; const draft = currentDrafts.find((d) => d.id === id); const idx = currentDrafts.findIndex((d) => d.id === id); const remaining = currentDrafts.filter((d) => d.id !== id); // Delete the Gmail draft if one was auto-saved if (draft?.savedDraftId) { fetch(appApiPath(`/api/emails/draft/${draft.savedDraftId}`), { method: "DELETE", }).then(() => { qc.invalidateQueries({ queryKey: ["emails"] }); }); } if (id === resolvedActiveId) { const nextDraft = remaining[Math.min(idx, remaining.length - 1)]; setActiveId(nextDraft?.id ?? null); } qc.setQueryData(["compose-drafts"], remaining); deleteMutation.mutate(id); }, [qc, deleteMutation, resolvedActiveId], ); /** Close all drafts — auto-saves any with content. */ const closeAll = useCallback(() => { const currentDrafts = qc.getQueryData(["compose-drafts"]) ?? []; const removedAt = Date.now(); for (const draft of currentDrafts) { removedDraftIdsRef.current[draft.id] = removedAt; } void qc.cancelQueries({ queryKey: ["compose-drafts"] }); // Save all drafts with content for (const draft of currentDrafts) { if (hasDraftContent(draft)) { void saveDraftToEmailsBestEffort(draft).then((draftId) => { if (draftId) qc.invalidateQueries({ queryKey: ["emails"] }); }); } } for (const timer of Object.values(debounceRef.current)) clearTimeout(timer); for (const timer of Object.values(gmailSaveRef.current)) clearTimeout(timer); debounceRef.current = {}; gmailSaveRef.current = {}; dirtyRef.current = {}; versionRef.current = {}; setActiveId(null); qc.setQueryData(["compose-drafts"], []); deleteAllMutation.mutate(); }, [qc, deleteAllMutation]); /** Flush a specific draft immediately (for Generate button). */ const flush = useCallback( (id: string) => { if (debounceRef.current[id]) clearTimeout(debounceRef.current[id]); if (gmailSaveRef.current[id]) clearTimeout(gmailSaveRef.current[id]); const current = ( qc.getQueryData(["compose-drafts"]) ?? [] ).find((d) => d.id === id); if (current) { dirtyRef.current[id] = false; versionRef.current[id] = versionRef.current[id] ?? 0; // Also trigger Gmail save immediately if (hasDraftContent(current)) autoSaveToGmail(id); return putMutation.mutateAsync(current); } }, [qc, putMutation, autoSaveToGmail], ); return { drafts, activeId: resolvedActiveId, activeDraft, isLoading: query.isLoading, open, update, close, closeAll, discard, setActiveId, flush, }; } export function newestUnseenPopoutDraftId( previousIds: ReadonlySet, drafts: ComposeState[], ) { for (let i = drafts.length - 1; i >= 0; i -= 1) { const draft = drafts[i]; if (!draft.inline && !previousIds.has(draft.id)) return draft.id; } return null; }