use-optimistic-actions.ts40.5 KBView on GitHub

Introduced 1 production defect in 180 days, median 8 days to fix.

import type { ThreadDestination } from '@/modules/threads/thread/utils/thread-actions';
import { moveThreadsTo } from '@/modules/threads/thread/utils/thread-actions';
import { usePredictivePrefetch } from '@/hooks/use-predictive-prefetch';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTRPC } from '@/providers/query-provider';
import { htmlToPlainText } from '@/lib/html-utils';
import { useCedarStore } from '@/modules/store';
import type { ParsedMessage, UndoAction } from '@/modules/threads/threadList/store/threadSlice';
import { hasPendingSend, performUndo } from '@/modules/drafting/hooks/use-undo-send';
import { isClientOnlyThreadId } from '@/modules/drafting/store/draftSlice';
import {
  guardArchivedThreads,
  releaseArchivedThreads,
} from '@/modules/threads/rendering/archived-thread-guard';
import { useCallback } from 'react';
import posthog from 'posthog-js';
import { toast } from 'sonner';

function canRedoAction(action: UndoAction): boolean {
  switch (action.type) {
    case 'markAsRead':
    case 'markAsUnread':
    case 'toggleStar':
    case 'toggleImportant':
    case 'toggleLabel':
    case 'move':
    case 'delete':
    case 'markDone':
      return true;
    case 'setRemind':
    case 'undoSend':
      return false;
  }
}

/** Extract the current mail folder from the URL (e.g. /mail/inbox → "inbox") */
function getCurrentFolder(): string {
  const match = window.location.pathname.match(/\/mail\/([^/?#]+)/);
  return match?.[1] ?? 'inbox';
}

export function useOptimisticActions() {
  const trpc = useTRPC();
  const queryClient = useQueryClient();
  const { afterArchiveOrDelete } = usePredictivePrefetch();

  // Get Zustand state mutation functions
  const zustandToggleStar = useCedarStore((state) => state.toggleStar);
  const zustandMarkAsRead = useCedarStore((state) => state.markAsRead);
  const zustandToggleImportant = useCedarStore((state) => state.toggleImportant);
  const zustandToggleLabel = useCedarStore((state) => state.toggleLabel);
  const zustandRemoveFromList = useCedarStore((state) => state.removeFromList);
  const removeDraftFromThread = useCedarStore((state) => state.removeDraftFromThread);
  const addDraftTombstones = useCedarStore((state) => state.addDraftTombstones);
  const clearDraftTombstones = useCedarStore((state) => state.clearDraftTombstones);
  const setThreadData = useCedarStore((state) => state.setThreadData);
  const pushUndo = useCedarStore((state) => state.pushUndo);
  const pushCommand = useCedarStore((state) => state.pushCommand);

  const selectThreadId = useCedarStore((state) => state.selectThreadId);
  const navigateToNextThread = useCedarStore((state) => state.navigateToNextThread);

  // Get server mutation functions
  const { mutateAsync: markAsReadServer } = useMutation(trpc.mail.markAsRead.mutationOptions());
  const { mutateAsync: markAsUnreadServer } = useMutation(trpc.mail.markAsUnread.mutationOptions());
  const { mutateAsync: toggleStarServer } = useMutation(trpc.mail.toggleStar.mutationOptions());
  const { mutateAsync: toggleImportantServer } = useMutation(
    trpc.mail.toggleImportant.mutationOptions(),
  );
  const { mutateAsync: bulkDeleteThread } = useMutation(trpc.mail.bulkDelete.mutationOptions());
  const { mutateAsync: markDoneServer } = useMutation(trpc.mail.markDone.mutationOptions());
  const { mutateAsync: markActiveServer } = useMutation(trpc.mail.markActive.mutationOptions());
  const { mutateAsync: setRemindServer } = useMutation(trpc.mail.setRemind.mutationOptions());
  const { mutateAsync: cancelRemindServer } = useMutation(
    trpc.mail.cancelRemind.mutationOptions(),
  );
  const { mutateAsync: modifyLabelsServer } = useMutation(trpc.mail.modifyLabels.mutationOptions());
  const { mutateAsync: deleteDraft } = useMutation(trpc.drafts.delete.mutationOptions());

  // Cancel any in-flight listThreads fetches before mutating, so stale responses
  // can't land and overwrite optimistic state.
  const cancelListThreadsQueries = useCallback(
    () => queryClient.cancelQueries({ queryKey=[redacted] }),
    [queryClient, trpc.mail.listThreads],
  );

  const optimisticMarkAsRead = useCallback(
    async (threadIds: string[], silent = false) => {
      if (!threadIds.length) return;

      zustandMarkAsRead(threadIds, true);
      if (!silent) {
        pushUndo({ type: 'markAsRead', threadIds });
      }

      try {
        await cancelListThreadsQueries();
        await markAsReadServer({ ids: threadIds });

        threadIds.forEach((threadId) => {
          queryClient.invalidateQueries({
            queryKey=[redacted] id: threadId }),
          });
        });

        posthog.capture('email_marked_read');
      } catch (error) {
        zustandMarkAsRead(threadIds, false);
        console.error('Error marking as read:', error);
      }
    },
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [zustandMarkAsRead, cancelListThreadsQueries, queryClient],
  );

  const optimisticMarkAsUnread = useCallback(
    async (threadIds: string[]) => {
      if (!threadIds.length) return;

      zustandMarkAsRead(threadIds, false);
      pushUndo({ type: 'markAsUnread', threadIds });

      try {
        await cancelListThreadsQueries();
        await markAsUnreadServer({ ids: threadIds });

        threadIds.forEach((threadId) => {
          queryClient.invalidateQueries({
            queryKey=[redacted] id: threadId }),
          });
        });

        posthog.capture('email_marked_unread');
      } catch (error) {
        zustandMarkAsRead(threadIds, true);
        console.error('Error marking as unread:', error);
      }
    },
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [zustandMarkAsRead, cancelListThreadsQueries, queryClient],
  );

  const optimisticToggleStar = useCallback(
    async (threadIds: string[], starred: boolean) => {
      if (!threadIds.length) return;

      zustandToggleStar(threadIds, starred);
      pushUndo({ type: 'toggleStar', threadIds, starred });

      try {
        await cancelListThreadsQueries();
        await toggleStarServer({ ids: threadIds });

        threadIds.forEach((threadId) => {
          queryClient.invalidateQueries({
            queryKey=[redacted] id: threadId }),
          });
        });

        posthog.capture(starred ? 'email_starred' : 'email_unstarred');
      } catch (error) {
        zustandToggleStar(threadIds, !starred);
        console.error('Error toggling star:', error);
      }
    },
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [zustandToggleStar, cancelListThreadsQueries, queryClient],
  );

  // Helper to optimistically remove threads from TanStack Query cache
  // IMPORTANT: Must use infiniteQueryKey() for infinite queries, not queryKey()
  const removeThreadsFromQueryCache = useCallback(
    (threadIds: string[]) => {
      const threadIdSet = new Set(threadIds);

      queryClient.setQueriesData(
        { queryKey=[redacted] },
        (
          oldData: { pages: { threads: { id: string }[] }[]; pageParams: unknown[] } | undefined,
        ) => {
          if (!oldData) return oldData;

          return {
            ...oldData,
            pages: oldData.pages.map((page) => ({
              ...page,
              threads: page.threads.filter((thread) => !threadIdSet.has(thread.id)),
            })),
          };
        },
      );
    },
    [queryClient, trpc.mail.listThreads],
  );

  // Helper to optimistically restore threads into TanStack Query cache.
  // Reconstructs the ThreadSummary from Zustand threadData and inserts it into
  // the correct sorted position (newest-first by latestReceivedOn) in each page.
  const restoreThreadsToQueryCache = useCallback(
    async (threadIds: string[]) => {
      const allThreadData = useCedarStore.getState().threadData;

      // Build ThreadSummary objects from Zustand data
      const summaries = threadIds.map((threadId) => {
        const data = allThreadData[threadId];
        if (!data) return { id: threadId, historyId: null };
        const latest = data.latest ?? data.messages[data.messages.length - 1];
        return {
          id: threadId,
          historyId: null,
          $raw: {
            subject: latest?.subject,
            sender: latest?.sender,
            snippet: latest?.snippet,
            labels: data.labels,
            latestReceivedOn: latest?.receivedOn,
            messageCount: data.messages.length,
            hasDraft: data.messages.some((m) => m.isDraft),
          },
        };
      });

      // Cancel any in-flight listThreads fetches so they don't land and overwrite the restore
      await queryClient.cancelQueries({ queryKey=[redacted] });

      queryClient.setQueriesData(
        { queryKey=[redacted] },
        (oldData: { pages: { threads: { id: string; $raw?: { latestReceivedOn?: string } }[] }[]; pageParams: unknown[] } | undefined) => {
          if (!oldData) return oldData;

          // Insert into the first page at the correct sorted position.
          // The list is newest-first; scan until we find an entry older than this thread.
          const newPages = oldData.pages.map((page, pageIndex) => {
            if (pageIndex !== 0) return page;

            const updatedThreads = [...page.threads];
            for (const summary of summaries) {
              if (updatedThreads.some((t) => t.id === summary.id)) continue;

              const threadTime = summary.$raw?.latestReceivedOn ?? '';
              let insertAt = updatedThreads.length;
              for (let i = 0; i < updatedThreads.length; i++) {
                const entryTime = updatedThreads[i].$raw?.latestReceivedOn ?? '';
                if (threadTime > entryTime) {
                  insertAt = i;
                  break;
                }
              }
              updatedThreads.splice(insertAt, 0, summary);
            }

            return { ...page, threads: updatedThreads };
          });

          return { ...oldData, pages: newPages };
        },
      );
    },
    [queryClient, trpc.mail.listThreads],
  );

  const zustandRestoreToList = useCedarStore((state) => state.restoreToList);

  // Optimistically adjust `getSplitCounts` badges for a markDone-style action:
  // each affected inbox's count drops by however many of `threadIds` were in
  // its cached list, and `done.count` rises by the unique thread count. The
  // server-side `getSplitCounts` refetch (triggered by invalidate after the
  // mutation succeeds) reconciles with Gmail's `resultSizeEstimate` after.
  // Returns a rollback that restores every mutated cache entry on failure.
  const decrementInboxCountsForMarkDone = useCallback(
    (threadIds: string[]): { rollback: () => void } => {
      const threadIdSet = new Set(threadIds);

      // (queryHash | compiledQuery) → how many of threadIds are in that
      // listThreads cache entry. A single thread can live in multiple inbox
      // tabs simultaneously (e.g. default + important), so each tab's count
      // decrements independently.
      const matchedByKey = new Map<string, number>();
      const listEntries = queryClient.getQueriesData<{
        pages: { threads: { id: string }[] }[];
      }>({ queryKey=[redacted] });
      for (const [queryKey, data] of listEntries) {
        if (!data) continue;
        const input = (queryKey as unknown as [unknown, { input?: { queryHash?: string; compiledQuery?: string } }])[1]?.input;
        const key=[redacted] ?? input?.compiledQuery;
        if (!key) continue;
        const matched = new Set<string>();
        for (const page of data.pages) {
          for (const t of page.threads) if (threadIdSet.has(t.id)) matched.add(t.id);
        }
        if (matched.size === 0) continue;
        // Multiple cache entries can exist for the same queryHash with
        // different page sizes / cursors — take the max (any one entry is a
        // snapshot of the same underlying inbox).
        matchedByKey.set(key, Math.max(matchedByKey.get(key) ?? 0, matched.size));
      }

      type SplitCountsData = {
        byId: Record<string, { count: number; isExact: boolean }>;
        done: { count: number; isExact: boolean };
      };
      const snapshots: Array<{ queryKey=[redacted] unknown[]; prev: SplitCountsData }> = [];

      // setQueriesData's updater doesn't expose the queryKey in v5, so
      // enumerate matched entries first and update each by key.
      const countEntries = queryClient.getQueriesData<SplitCountsData>({
        queryKey=[redacted],
      });
      for (const [queryKey, oldData] of countEntries) {
        if (!oldData) continue;
        const input = (queryKey as unknown as [unknown, { input?: { splits?: { inboxId: string; queryHash?: string; compiledQuery?: string }[] } }])[1]?.input;
        const splits = input?.splits ?? [];

        const byId = { ...oldData.byId };
        let mutated = false;
        for (const split of splits) {
          const key=[redacted] ?? split.compiledQuery;
          if (!key) continue;
          const matched = matchedByKey.get(key);
          if (!matched) continue;
          const current = byId[split.inboxId];
          if (!current) continue;
          byId[split.inboxId] = {
            ...current,
            count: Math.max(0, current.count - matched),
          };
          mutated = true;
        }

        // Done bumps by the number of unique threads marked done, regardless
        // of which (or how many) inbox tabs they sat in.
        const done = {
          ...oldData.done,
          count: oldData.done.count + threadIds.length,
        };
        if (!mutated && threadIds.length === 0) continue;
        snapshots.push({ queryKey, prev: oldData });
        queryClient.setQueryData<SplitCountsData>(queryKey, { ...oldData, byId, done });
      }

      return {
        rollback: () => {
          for (const { queryKey, prev } of snapshots) {
            queryClient.setQueryData(queryKey, prev);
          }
        },
      };
    },
    [queryClient, trpc.mail.listThreads, trpc.mail.getSplitCounts],
  );

  const optimisticMoveThreadsTo = useCallback(
    async (threadIds: string[], currentFolder: string, destination: ThreadDestination) => {
      if (!threadIds.length || !destination) return;

      const currentThreadId = useCedarStore.getState().selectedThreadId;
      if (currentThreadId && threadIds.includes(currentThreadId)) {
        navigateToNextThread();
        // If still on the deleted thread (e.g. it was the last in the list), clear selection
        if (useCedarStore.getState().selectedThreadId === currentThreadId) {
          selectThreadId(null);
        }
      }

      zustandRemoveFromList(threadIds);
      removeThreadsFromQueryCache(threadIds);
      pushUndo({ type: 'move', threadIds, fromFolder: currentFolder, toFolder: destination });

      try {
        await cancelListThreadsQueries();
        await moveThreadsTo({
          threadIds,
          currentFolder,
          destination,
        });

        removeThreadsFromQueryCache(threadIds);

        posthog.capture('email_moved');

        // Predictively prefetch inbox during idle time
        afterArchiveOrDelete();
      } catch (error) {
        await queryClient.invalidateQueries({
          queryKey=[redacted],
        });

        console.error('Error moving thread:', error);
      }
    },
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [
      zustandRemoveFromList,
      removeThreadsFromQueryCache,
      cancelListThreadsQueries,
      navigateToNextThread,
      selectThreadId,
      queryClient,
      afterArchiveOrDelete,
    ],
  );

  const optimisticDeleteThreads = useCallback(
    async (threadIds: string[]) => {
      if (!threadIds.length) return;

      const fromFolder = getCurrentFolder();

      const currentThreadId = useCedarStore.getState().selectedThreadId;
      if (currentThreadId && threadIds.includes(currentThreadId)) {
        navigateToNextThread();
        // If still on the deleted thread (e.g. it was the last in the list), clear selection
        if (useCedarStore.getState().selectedThreadId === currentThreadId) {
          selectThreadId(null);
        }
      }

      zustandRemoveFromList(threadIds);
      removeThreadsFromQueryCache(threadIds);
      pushUndo({ type: 'delete', threadIds, fromFolder });

      try {
        await cancelListThreadsQueries();
        await bulkDeleteThread({ ids: threadIds });

        removeThreadsFromQueryCache(threadIds);

        posthog.capture('email_moved');

        // Predictively prefetch inbox during idle time
        afterArchiveOrDelete();
      } catch (error) {
        await queryClient.invalidateQueries({
          queryKey=[redacted],
        });

        console.error('Error deleting thread:', error);
      }
    },
    [
      zustandRemoveFromList,
      removeThreadsFromQueryCache,
      cancelListThreadsQueries,
      navigateToNextThread,
      selectThreadId,
      queryClient,
      trpc.mail.listThreads,
      bulkDeleteThread,
      afterArchiveOrDelete,
    ],
  );

  const optimisticMarkDone = useCallback(
    async (threadIds: string[]) => {
      if (!threadIds.length) return;

      const currentThreadId = useCedarStore.getState().selectedThreadId;
      if (currentThreadId && threadIds.includes(currentThreadId)) {
        navigateToNextThread();
        if (useCedarStore.getState().selectedThreadId === currentThreadId) {
          selectThreadId(null);
        }
      }

      // Must run BEFORE removeThreadsFromQueryCache: this helper reads the
      // listThreads cache to figure out which inbox(es) each thread sits in.
      const countsRollback = decrementInboxCountsForMarkDone(threadIds);
      zustandRemoveFromList(threadIds);
      removeThreadsFromQueryCache(threadIds);
      // Hold the removal as an invariant: any later write that puts these ids
      // back — a fetch response landing after this mutation settles, an
      // IndexedDB restore, a prefetch — is repaired. See archived-thread-guard.
      guardArchivedThreads(threadIds);
      pushUndo({ type: 'markDone', threadIds });

      try {
        await cancelListThreadsQueries();
        await Promise.all(threadIds.map((threadId) => markDoneServer({ threadId })));

        // Re-assert the removal now that the server has committed the archive.
        // `cancelListThreadsQueries` only kills fetches in flight at that
        // instant; anything that STARTS during the ~230-380ms round-trip (the
        // 5-minute interval, window focus, a side-inbox prefetch, fetchNextPage)
        // gets a page that legitimately still contains these threads, because
        // the archive hadn't reached `crm_thread_labels` yet. When that response
        // lands it replaces the cache and the threads are back in the inbox
        // until the next page-1 refresh. `optimisticMoveThreadsTo` and
        // `optimisticDeleteThreads` already do this second pass; mark-done was
        // the one that didn't. See apps/server/src/docs/bug-mark-done-threads-reappear.md.
        removeThreadsFromQueryCache(threadIds);

        await queryClient.invalidateQueries({
          queryKey=[redacted],
        });
        posthog.capture('email_marked_done');
      } catch (error) {
        countsRollback.rollback();
        // Release before restoring, or the guard fights the rollback.
        releaseArchivedThreads(threadIds);
        await restoreThreadsToQueryCache(threadIds);
        zustandRestoreToList(threadIds);
        toast.error('Failed to mark as done');
        console.error('Error marking as done:', error);
      }
    },
    [
      zustandRemoveFromList,
      zustandRestoreToList,
      removeThreadsFromQueryCache,
      restoreThreadsToQueryCache,
      cancelListThreadsQueries,
      decrementInboxCountsForMarkDone,
      navigateToNextThread,
      selectThreadId,
      pushUndo,
      markDoneServer,
      queryClient,
      trpc.mail.listThreads,
      trpc.mail.getSplitCounts,
    ],
  );

  const optimisticToggleImportant = useCallback(
    async (threadIds: string[], isImportant: boolean) => {
      if (!threadIds.length) return;

      zustandToggleImportant(threadIds, isImportant);
      pushUndo({ type: 'toggleImportant', threadIds, important: isImportant });

      try {
        await cancelListThreadsQueries();
        await toggleImportantServer({ ids: threadIds });

        threadIds.forEach((threadId) => {
          queryClient.invalidateQueries({
            queryKey=[redacted] id: threadId }),
          });
        });

        posthog.capture(isImportant ? 'email_marked_important' : 'email_unmarked_important');
      } catch (error) {
        zustandToggleImportant(threadIds, !isImportant);
        console.error('Error toggling important:', error);
      }
    },
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [zustandToggleImportant, cancelListThreadsQueries, queryClient],
  );

  const optimisticToggleLabel = useCallback(
    async (threadIds: string[], labelId: string, labelName: string, add: boolean) => {
      if (!threadIds.length || !labelId) return;

      zustandToggleLabel(threadIds, labelId, labelName, add);
      pushUndo({ type: 'toggleLabel', threadIds, labelId, labelName, added: add });

      try {
        await cancelListThreadsQueries();
        await modifyLabelsServer({
          threadId: threadIds,
          addLabels: add ? [labelId] : [],
          removeLabels: add ? [] : [labelId],
        });

        threadIds.forEach((threadId) => {
          queryClient.invalidateQueries({
            queryKey=[redacted] id: threadId }),
          });
        });

        posthog.capture(add ? 'email_label_added' : 'email_label_removed');
      } catch (error) {
        zustandToggleLabel(threadIds, labelId, labelName, !add);
        console.error('Error toggling label:', error);
      }
    },
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [zustandToggleLabel, cancelListThreadsQueries, queryClient],
  );

  const optimisticSetRemind = useCallback(
    async (threadIds: string[], remindAt: Date) => {
      if (!threadIds.length) return;

      const currentThreadId = useCedarStore.getState().selectedThreadId;
      if (currentThreadId && threadIds.includes(currentThreadId)) {
        navigateToNextThread();
        if (useCedarStore.getState().selectedThreadId === currentThreadId) {
          selectThreadId(null);
        }
      }

      zustandRemoveFromList(threadIds);
      removeThreadsFromQueryCache(threadIds);
      pushUndo({ type: 'setRemind', threadIds });

      try {
        await cancelListThreadsQueries();
        const results = await Promise.all(
          threadIds.map((threadId) =>
            setRemindServer({ threadId, remindAt: remindAt.toISOString() }),
          ),
        );

        if (results.every((r) => r.success)) {
          await queryClient.invalidateQueries({
            queryKey=[redacted],
          });
          posthog.capture('email_remind_set');
          return;
        }
        throw new Error('One or more reminders failed to set');
      } catch (error) {
        await restoreThreadsToQueryCache(threadIds);
        zustandRestoreToList(threadIds);
        toast.error('Failed to set reminder');
        console.error('Error setting reminder:', error);
      }
    },
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [
      navigateToNextThread,
      selectThreadId,
      zustandRemoveFromList,
      removeThreadsFromQueryCache,
      cancelListThreadsQueries,
      setRemindServer,
      queryClient,
      trpc.mail.getSplitCounts,
      restoreThreadsToQueryCache,
      zustandRestoreToList,
    ],
  );

  const optimisticCancelRemind = useCallback(
    async (threadIds: string[]) => {
      if (!threadIds.length) return;

      try {
        await Promise.all(threadIds.map((threadId) => cancelRemindServer({ threadId })));
        posthog.capture('email_remind_cancelled');
      } catch (error) {
        console.error('Error cancelling reminder:', error);
      }
    },
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [cancelRemindServer],
  );

  const optimisticDeleteDraft = useCallback(
    async (rowId: string) => {
      if (!rowId) return;

      // `rowId` arrives from the thread-list draft row. It may be:
      //   (a) a thread id — the common case for Gmail drafts shown as draft
      //       threads in the list (Draft component passes `message.id` which is
      //       the threadId of the draft row);
      //   (b) the draft message id / draftId / draftSessionId — for synthetic
      //       client-side drafts whose row id equals the draft's own identifier.
      // We need both lookups: (a) first because it's the dominant case and the
      // rowId never matches a draft message's id/draftId/draftSessionId.
      const allThreadData = useCedarStore.getState().threadData;
      let parentThreadId: string | undefined;
      let draftMessage: ParsedMessage | undefined;
      const threadFromRowId = allThreadData[rowId];
      if (threadFromRowId) {
        const draft = threadFromRowId.messages.findLast((msg) => msg.isDraft);
        if (draft) {
          parentThreadId = rowId;
          draftMessage = draft;
        }
      }
      if (!draftMessage) {
        for (const [threadId, threadData] of Object.entries(allThreadData)) {
          const match = threadData.messages.find(
            (msg) =>
              msg.isDraft &&
              (msg.id === rowId || msg.draftId === rowId || msg.draftSessionId === rowId),
          );
          if (match) {
            parentThreadId = threadId;
            draftMessage = match;
            break;
          }
        }
      }

      // Neither lookup finds anything when the row has only list-preview data:
      // `batchPopulateThreadMetadata` fills `messages` with placeholders carrying
      // `id: ''` and `isDraft: false`, so there is no draft to match on and no id
      // to match against. Left unhandled, `parentThreadId` stays undefined, the
      // server guard below (`draftIdToSend || emailHeaderMessageId ||
      // parentThreadId`) is all-falsy, and `drafts.delete` is never called at all —
      // the row vanishes locally and returns on the next listThreads refetch.
      // Fall back to the row's own thread id: `deleteDraft` on the server resolves
      // the draft from the provider, then from the stored snapshot, when handed
      // nothing but a threadId. A client-only compose session has no provider
      // thread behind it, so it stays local-only as before.
      if (!parentThreadId && !isClientOnlyThreadId(rowId)) {
        parentThreadId = rowId;
      }

      const identityCandidates = [
        draftMessage?.draftId,
        draftMessage?.draftSessionId,
        draftMessage?.id,
        rowId,
      ].filter((v): v is string => Boolean(v));

      // Snapshots so we can fully roll back if the server call fails.
      const preDeleteThread = parentThreadId
        ? useCedarStore.getState().getThreadData(parentThreadId)
        : undefined;
      const preDeleteCacheSnapshot = parentThreadId
        ? queryClient.getQueryData(trpc.mail.get.queryKey({ id: parentThreadId }))
        : undefined;

      zustandRemoveFromList([rowId]);

      // A thread whose only message IS the draft — every agent follow-up draft is
      // one — stops being a row the moment the draft goes. `zustandRemoveFromList`
      // drops it from the Zustand list, but the inbox renders `mail.listThreads`,
      // so leaving that cache alone keeps the row: it opens onto a thread whose one
      // message is now tombstoned, i.e. a blank body under a subject line. Patch the
      // list the same way every other row-removing action here does.
      const becomesEmpty = Boolean(
        parentThreadId && preDeleteThread && !preDeleteThread.messages.some((msg) => !msg.isDraft),
      );
      if (becomesEmpty && parentThreadId) {
        removeThreadsFromQueryCache([parentThreadId]);
      }

      if (parentThreadId && identityCandidates.length > 0) {
        removeDraftFromThread({ threadId: parentThreadId, identities: identityCandidates });
        // Tombstone the identities so any in-flight or near-future mail.get refetch
        // that still echoes the draft (Gmail not yet synced, server-side pendingDraft
        // not yet cleared) is suppressed by applyDraftTombstones on the next merge.
        addDraftTombstones({ threadId: parentThreadId, identities: identityCandidates });

        const idSet = new Set(identityCandidates);
        queryClient.setQueryData(trpc.mail.get.queryKey({ id: parentThreadId }), (old) => {
          if (!old) return old;
          const oldMessages = (old as { messages: ParsedMessage[] }).messages;
          const nextMessages = oldMessages.filter((msg) => {
            if (!msg.isDraft) return true;
            if (msg.draftId && idSet.has(msg.draftId)) return false;
            if (msg.draftSessionId && idSet.has(msg.draftSessionId)) return false;
            if (idSet.has(msg.id)) return false;
            return true;
          });
          const latestNonDraft = nextMessages.findLast((msg) => !msg.isDraft);
          return {
            ...old,
            messages: nextMessages,
            latest: latestNonDraft || (old as { latest: ParsedMessage }).latest,
            totalReplies: Math.max(
              0,
              ((old as { totalReplies: number }).totalReplies || 0) -
                (oldMessages.length - nextMessages.length),
            ),
          };
        });
      }

      try {
        // Only hit the server when we have at least one identifier that could map
        // to a real provider draft. Pure synthetic drafts (no draftId, no
        // emailHeaderMessageId) live only in client/canvas state and don't need a
        // Gmail delete; the tombstone above is enough to keep them away.
        const draftIdToSend = draftMessage?.draftId ?? null;
        const emailHeaderMessageId = draftMessage?.emailHeaderMessageId ?? null;
        if (draftIdToSend || emailHeaderMessageId || parentThreadId) {
          await deleteDraft({
            draftId: draftIdToSend,
            emailHeaderMessageId,
            threadId: parentThreadId,
          });
        }

        const plainTextBody = htmlToPlainText(draftMessage?.processedHtml);
        posthog.capture('draft_deleted', {
          draft_id: draftIdToSend ?? rowId,
          has_subject: !!draftMessage?.subject,
          subject_length: draftMessage?.subject?.length || 0,
          has_body: !!plainTextBody,
          body_length: plainTextBody.length,
          recipient_count:
            (draftMessage?.to?.length || 0) +
            (draftMessage?.cc?.length || 0) +
            (draftMessage?.bcc?.length || 0),
          to_count: draftMessage?.to?.length || 0,
          has_cc: (draftMessage?.cc?.length || 0) > 0,
          has_bcc: (draftMessage?.bcc?.length || 0) > 0,
        });
      } catch (error) {
        console.error('Error deleting draft:', error);
        // Roll back: drop tombstones first so the restored draft isn't filtered
        // out by applyDraftTombstones on the next setThreadData, then restore
        // threadData and the React Query cache.
        if (parentThreadId && identityCandidates.length > 0) {
          clearDraftTombstones({ threadId: parentThreadId, identities: identityCandidates });
        }
        if (parentThreadId && preDeleteThread) {
          setThreadData(parentThreadId, preDeleteThread);
        }
        if (parentThreadId && preDeleteCacheSnapshot !== undefined) {
          queryClient.setQueryData(
            trpc.mail.get.queryKey({ id: parentThreadId }),
            preDeleteCacheSnapshot,
          );
        }
        // Put the row back. Runs after `setThreadData` above, because the restore
        // rebuilds the list entry out of Zustand's thread data.
        if (becomesEmpty && parentThreadId) {
          await restoreThreadsToQueryCache([parentThreadId]);
        }
      }
    },
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [
      zustandRemoveFromList,
      removeDraftFromThread,
      addDraftTombstones,
      clearDraftTombstones,
      setThreadData,
      removeThreadsFromQueryCache,
      restoreThreadsToQueryCache,
      queryClient,
    ],
  );

  /** Replay the inverse of a recorded undo action — no invalidateQueries; optimistic state stands */
  const executeUndo = useCallback(
    async (action: UndoAction) => {
      switch (action.type) {
        case 'markAsRead':
          zustandMarkAsRead(action.threadIds, false);
          await markAsUnreadServer({ ids: action.threadIds });
          break;

        case 'markAsUnread':
          zustandMarkAsRead(action.threadIds, true);
          await markAsReadServer({ ids: action.threadIds });
          break;

        case 'toggleStar':
          zustandToggleStar(action.threadIds, !action.starred);
          await toggleStarServer({ ids: action.threadIds });
          break;

        case 'toggleImportant':
          zustandToggleImportant(action.threadIds, !action.important);
          await toggleImportantServer({ ids: action.threadIds });
          break;

        case 'toggleLabel':
          zustandToggleLabel(action.threadIds, action.labelId, action.labelName, !action.added);
          await modifyLabelsServer({
            threadId: action.threadIds,
            addLabels: !action.added ? [action.labelId] : [],
            removeLabels: !action.added ? [] : [action.labelId],
          });
          break;

        case 'move':
          await restoreThreadsToQueryCache(action.threadIds);
          zustandRestoreToList(action.threadIds);
          await moveThreadsTo({
            threadIds: action.threadIds,
            currentFolder: action.toFolder,
            destination: action.fromFolder as ThreadDestination,
          });
          break;

        case 'delete':
          await restoreThreadsToQueryCache(action.threadIds);
          zustandRestoreToList(action.threadIds);
          await moveThreadsTo({
            threadIds: action.threadIds,
            currentFolder: 'bin',
            destination: 'inbox',
          });
          break;

        case 'markDone':
          // Undo puts them back in the inbox on purpose — stop watching first,
          // or the detector reports the restore as a resurrection.
          releaseArchivedThreads(action.threadIds);
          await restoreThreadsToQueryCache(action.threadIds);
          zustandRestoreToList(action.threadIds);
          await Promise.all(action.threadIds.map((threadId) => markActiveServer({ threadId })));
          await queryClient.invalidateQueries({
            queryKey=[redacted],
          });
          break;

        case 'setRemind':
          await restoreThreadsToQueryCache(action.threadIds);
          zustandRestoreToList(action.threadIds);
          await Promise.all(
            action.threadIds.map((threadId) => cancelRemindServer({ threadId })),
          );
          await queryClient.invalidateQueries({
            queryKey=[redacted],
          });
          break;

        case 'undoSend':
          if (hasPendingSend()) {
            await performUndo();
          }
          break;
      }
    },
    [
      zustandMarkAsRead,
      zustandToggleStar,
      zustandToggleImportant,
      zustandToggleLabel,
      zustandRestoreToList,
      restoreThreadsToQueryCache,
      markAsReadServer,
      markAsUnreadServer,
      toggleStarServer,
      toggleImportantServer,
      modifyLabelsServer,
      cancelRemindServer,
      markActiveServer,
      queryClient,
      trpc.mail.listThreads,
      trpc.mail.getSplitCounts,
    ],
  );

  /** Replay the original recorded action for redo */
  const executeRedo = useCallback(
    async (action: UndoAction) => {
      switch (action.type) {
        case 'markAsRead':
          zustandMarkAsRead(action.threadIds, true);
          await markAsReadServer({ ids: action.threadIds });
          break;

        case 'markAsUnread':
          zustandMarkAsRead(action.threadIds, false);
          await markAsUnreadServer({ ids: action.threadIds });
          break;

        case 'toggleStar':
          zustandToggleStar(action.threadIds, action.starred);
          await toggleStarServer({ ids: action.threadIds });
          break;

        case 'toggleImportant':
          zustandToggleImportant(action.threadIds, action.important);
          await toggleImportantServer({ ids: action.threadIds });
          break;

        case 'toggleLabel':
          zustandToggleLabel(action.threadIds, action.labelId, action.labelName, action.added);
          await modifyLabelsServer({
            threadId: action.threadIds,
            addLabels: action.added ? [action.labelId] : [],
            removeLabels: action.added ? [] : [action.labelId],
          });
          break;

        case 'move':
          zustandRemoveFromList(action.threadIds);
          removeThreadsFromQueryCache(action.threadIds);
          await moveThreadsTo({
            threadIds: action.threadIds,
            currentFolder: action.fromFolder,
            destination: action.toFolder as ThreadDestination,
          });
          break;

        case 'delete':
          zustandRemoveFromList(action.threadIds);
          removeThreadsFromQueryCache(action.threadIds);
          await bulkDeleteThread({ ids: action.threadIds });
          break;

        case 'markDone':
          zustandRemoveFromList(action.threadIds);
          removeThreadsFromQueryCache(action.threadIds);
          guardArchivedThreads(action.threadIds);
          await Promise.all(action.threadIds.map((threadId) => markDoneServer({ threadId })));
          // Same re-assert as optimisticMarkDone — a response computed during
          // the round-trip still lists these threads.
          removeThreadsFromQueryCache(action.threadIds);
          await queryClient.invalidateQueries({
            queryKey=[redacted],
          });
          break;

        case 'setRemind':
        case 'undoSend':
          throw new Error(`Redo is not supported for action type "${action.type}"`);
      }
    },
    [
      zustandMarkAsRead,
      zustandToggleStar,
      zustandToggleImportant,
      zustandToggleLabel,
      zustandRemoveFromList,
      removeThreadsFromQueryCache,
      markAsReadServer,
      markAsUnreadServer,
      toggleStarServer,
      toggleImportantServer,
      modifyLabelsServer,
      bulkDeleteThread,
      markDoneServer,
      queryClient,
      trpc.mail.listThreads,
      trpc.mail.getSplitCounts,
    ],
  );

  const undoLastAction = useCallback(async () => {
    const store = useCedarStore.getState();
    const entry = store.popUndo();
    if (!entry) return;

    try {
      await executeUndo(entry.action);
      if (canRedoAction(entry.action)) {
        useCedarStore.getState().pushRedo(entry.action);
      }
      if (entry.restoreThreadId) {
        useCedarStore.getState().selectThreadId(entry.restoreThreadId);
        useCedarStore.getState().setIsThreadOpen(true);
      }
      posthog.capture('undo_action', { type: entry.action.type });
    } catch (error) {
      console.error('Error undoing action:', error);
      pushCommand({
        kind: 'toast',
        id: `undo-error-${Date.now()}`,
        variant: 'error',
        text: 'Failed to undo — refreshing',
        autoDismissMs: 4000,
      });
      await queryClient.invalidateQueries({
        queryKey=[redacted],
      });
    }
  }, [executeUndo, pushCommand, queryClient, trpc.mail.listThreads]);

  const redoLastAction = useCallback(async () => {
    const store = useCedarStore.getState();
    const entry = store.popRedo();
    if (!entry) return;

    try {
      await executeRedo(entry.action);
      useCedarStore.getState().pushUndo(entry.action, { clearRedo: false });
      posthog.capture('redo_action', { type: entry.action.type });
    } catch (error) {
      console.error('Error redoing action:', error);
      pushCommand({
        kind: 'toast',
        id: `redo-error-${Date.now()}`,
        variant: 'error',
        text: 'Failed to redo — refreshing',
        autoDismissMs: 4000,
      });
      await queryClient.invalidateQueries({
        queryKey=[redacted],
      });
    }
  }, [executeRedo, pushCommand, queryClient, trpc.mail.listThreads]);

  return {
    optimisticMarkAsRead,
    optimisticMarkAsUnread,
    optimisticToggleStar,
    optimisticMoveThreadsTo,
    optimisticDeleteThreads,
    optimisticMarkDone,
    optimisticToggleImportant,
    optimisticToggleLabel,
    optimisticDeleteDraft,
    optimisticSetRemind,
    optimisticCancelRemind,
    undoLastAction,
    redoLastAction,
  };
}