CanvasReviewMode.tsx13.6 KBView on GitHub
/**
 * CanvasReviewMode Component
 *
 * Canvas review mode for reviewing pending AI-generated drafts.
 * Features keyboard navigation (←→ arrows) and actions (Enter=accept, Delete=reject).
 *
 * Uses ReviewModeThreadDisplay which locally combines thread messages with the pending draft,
 * avoiding race conditions with ThreadDataSync by never mutating the store's threadData.
 */

import { ReviewModeThreadDisplay } from '@/modules/agentCanvas/components/ReviewModeThreadDisplay';
import type { PendingDraft } from '@/modules/agentCanvas/types/agent-columns';
import { useState, useEffect, useCallback, useRef } from 'react';
import { ReviewModeBottomBar } from './ReviewModeBottomBar';
import { useQueryClient } from '@tanstack/react-query';
import { useHotkeysContext } from 'react-hotkeys-hook';
import { motion, AnimatePresence } from 'motion/react';
import { useTRPC } from '@/providers/query-provider';
import { useCedarStore } from '@/modules/store';
import { useShallow } from 'zustand/react/shallow';
import { Mail } from 'lucide-react';

// Stable empty references to prevent unnecessary re-renders in selectors
const EMPTY_DRAFT_ARRAY: PendingDraft[] = [];

/**
 * Helper to derive threadId from a conversation's email events.
 * Used when the pending draft doesn't have a threadId directly set.
 */
function deriveThreadIdFromConversation(
  draft: PendingDraft | undefined,
  getConversation: (
    conversationId: string,
  ) => ReturnType<typeof useCedarStore.getState>['getConversation'] extends (id: string) => infer R
    ? R
    : never,
): string | null {
  if (!draft?.conversationId) return null;

  const conversation = getConversation(draft.conversationId);
  if (!conversation) return null;

  // Find the latest email event to get the threadId (with null-safe access)
  // Use toSorted() to avoid mutating the store's events array
  const latestEmailEvent = conversation.data?.conversation?.events
    ?.filter((e) => e.emailEvent)
    ?.toSorted((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())[0];

  return latestEmailEvent?.emailEvent?.threadId ?? null;
}

export interface CanvasReviewModeProps {
  onExit: () => void;
  /** Optional initial conversation ID to start review mode at */
  initialConversationId?: string;
}

export function CanvasReviewMode({ onExit, initialConversationId }: CanvasReviewModeProps) {
  // Track by conversationId instead of index to maintain stability when new drafts are added
  const [currentConversationId, setCurrentConversationId] = useState<string | null>(
    initialConversationId || null,
  );

  // Use ref to track previous index to derive direction from actual change
  const previousIndexRef = useRef<number>(0);
  const hasNavigatedRef = useRef<boolean>(false);

  // Get active canvas ID for canvas-scoped operations
  const activeCanvasId = useCedarStore((state) => state.activeCanvasId);

  // Build pending drafts directly from state (useShallow for stable reference)
  const pendingDrafts = useCedarStore(
    useShallow((state) => {
      const canvasId = state.activeCanvasId;
      if (!canvasId) return EMPTY_DRAFT_ARRAY;
      const entries = state.canvasesById[canvasId]?.data?.conversationEntries;
      if (!entries) return EMPTY_DRAFT_ARRAY;
      return Object.values(entries)
        .filter((e) => e.pendingDraft?.reviewStatus === 'pending')
        .map((e) => e.pendingDraft) as PendingDraft[];
    }),
  );

  const markDraftSent = useCedarStore((state) => state.markDraftSent);
  const rejectDraft = useCedarStore((state) => state.rejectDraft);
  const updateConversationDraft = useCedarStore((state) => state.updateConversationDraft);
  const getConversation = useCedarStore((state) => state.getConversation);
  const setActiveConversationId = useCedarStore((state) => state.setActiveConversationId);

  // Query client for prefetching threads
  const queryClient = useQueryClient();
  const trpc = useTRPC();

  // Find current draft by conversationId (stable even when array changes)
  const currentDraft = currentConversationId
    ? pendingDrafts.find((d) => d.conversationId === currentConversationId)
    : pendingDrafts[0];

  // Calculate current index from conversationId
  const currentIndex = currentDraft
    ? pendingDrafts.findIndex((d) => d.conversationId === currentDraft.conversationId)
    : 0;

  // Derive direction from the actual change in index (only after first navigation!)
  const direction = !hasNavigatedRef.current
    ? 'right' // Default for first render (won't animate)
    : currentIndex > previousIndexRef.current
      ? 'right'
      : 'left';

  // Debug logging
  useEffect(() => {
    console.log('🎯 Navigation:', {
      currentIndex,
      previousIndex: previousIndexRef.current,
      direction,
      conversationId: currentConversationId,
      hasNavigated: hasNavigatedRef.current,
    });
  }, [currentIndex, direction, currentConversationId]);

  // Update previousIndex ref only after navigation (not on mount)
  useEffect(() => {
    // Don't update on first render, only after navigation
    if (hasNavigatedRef.current) {
      previousIndexRef.current = currentIndex;
    }
  }, [currentIndex]);

  // Sync activeConversationId when navigating between drafts in review mode
  useEffect(() => {
    const conversationId = currentDraft?.conversationId;
    if (conversationId) {
      setActiveConversationId(conversationId);
    }
  }, [currentDraft?.conversationId, setActiveConversationId]);

  // Initialize conversationId on first render or when current draft becomes invalid
  useEffect(() => {
    if (!currentConversationId && pendingDrafts.length > 0) {
      setCurrentConversationId(pendingDrafts[0].conversationId);
    } else if (
      currentConversationId &&
      !pendingDrafts.find((d) => d.conversationId === currentConversationId)
    ) {
      // Current draft was removed, select next available or first
      if (pendingDrafts.length > 0) {
        setCurrentConversationId(pendingDrafts[0].conversationId);
      }
    }
  }, [currentConversationId, pendingDrafts]);

  // Get threadId - either from the draft or derive from conversation's latest email event
  const threadId =
    currentDraft?.threadId || deriveThreadIdFromConversation(currentDraft, getConversation);

  // Prefetch adjacent threads for instant navigation (sliding window of 3)
  useEffect(() => {
    // Prefetch current + next 2 threads
    const windowSize = 3;
    const draftsToPreload = pendingDrafts.slice(
      currentIndex,
      Math.min(currentIndex + windowSize, pendingDrafts.length),
    );

    draftsToPreload.forEach((draft) => {
      const draftThreadId =
        draft.threadId || deriveThreadIdFromConversation(draft, getConversation);
      if (draftThreadId) {
        void queryClient.prefetchQuery(trpc.mail.get.queryOptions({ id: draftThreadId }));
      }
    });
  }, [currentIndex, pendingDrafts, queryClient, trpc, getConversation]);

  // Handler for draft content changes - syncs edits back to the pending draft store
  const handleDraftChange = useCallback(
    (updates: { body?: string; subject?: string }) => {
      if (!currentDraft || !activeCanvasId) return;
      updateConversationDraft(activeCanvasId, currentDraft.conversationId, updates);
    },
    [currentDraft, activeCanvasId, updateConversationDraft],
  );

  const handlePrevious = useCallback(() => {
    if (currentIndex > 0) {
      const prevDraft = pendingDrafts[currentIndex - 1];
      if (prevDraft) {
        hasNavigatedRef.current = true;
        previousIndexRef.current = currentIndex;
        setCurrentConversationId(prevDraft.conversationId);
      }
    }
  }, [currentIndex, pendingDrafts]);

  const handleNext = useCallback(() => {
    if (currentIndex < pendingDrafts.length - 1) {
      const nextDraft = pendingDrafts[currentIndex + 1];
      if (nextDraft) {
        hasNavigatedRef.current = true;
        previousIndexRef.current = currentIndex;
        setCurrentConversationId(nextDraft.conversationId);
      }
    }
  }, [currentIndex, pendingDrafts]);

  const handleAccept = useCallback(() => {
    if (!currentDraft) return;

    // Compute next draft BEFORE removal (find the draft at currentIndex+1, or stay at currentIndex if at end)
    const nextDraftBeforeRemoval =
      currentIndex < pendingDrafts.length - 1
        ? pendingDrafts[currentIndex + 1]
        : currentIndex > 0
          ? pendingDrafts[currentIndex - 1]
          : null;

    // Mark draft as sent and accepted as task
    if (activeCanvasId) markDraftSent(activeCanvasId, currentDraft.conversationId);

    // Move to pre-computed next draft or exit
    if (nextDraftBeforeRemoval) {
      setCurrentConversationId(nextDraftBeforeRemoval.conversationId);
    } else {
      // No more drafts, exit review mode
      onExit();
    }
  }, [currentDraft, pendingDrafts, currentIndex, markDraftSent, onExit]);

  const handleReject = useCallback(() => {
    if (!currentDraft) return;

    // Compute next draft BEFORE removal (find the draft at currentIndex+1, or stay at currentIndex if at end)
    const nextDraftBeforeRemoval =
      currentIndex < pendingDrafts.length - 1
        ? pendingDrafts[currentIndex + 1]
        : currentIndex > 0
          ? pendingDrafts[currentIndex - 1]
          : null;

    // Reject the draft
    if (activeCanvasId) rejectDraft(activeCanvasId, currentDraft.conversationId);

    // Move to pre-computed next draft or exit
    if (nextDraftBeforeRemoval) {
      setCurrentConversationId(nextDraftBeforeRemoval.conversationId);
    } else {
      // No more drafts, exit review mode
      onExit();
    }
  }, [currentDraft, pendingDrafts, currentIndex, rejectDraft, onExit]);

  // Enable review-mode scope to take priority over parent canvas scope
  const { enableScope, disableScope } = useHotkeysContext();
  useEffect(() => {
    enableScope('review-mode');
    return () => {
      disableScope('review-mode');
    };
  }, [enableScope, disableScope]);

  // Keyboard shortcuts with scoped handlers
  useEffect(() => {
    // Don't attach keyboard handlers if no draft exists
    if (!currentDraft) return;

    const handleKeyDown = (e: KeyboardEvent) => {
      // Don't trigger if user is typing in an input/textarea/editor
      const target = e.target as HTMLElement;
      if (
        target.tagName === 'INPUT' ||
        target.tagName === 'TEXTAREA' ||
        target.isContentEditable ||
        target.closest('.ProseMirror') // TipTap editor
      ) {
        return;
      }

      if (e.key === 'ArrowLeft') {
        e.preventDefault();
        e.stopPropagation();
        handlePrevious();
      } else if (e.key === 'ArrowRight') {
        e.preventDefault();
        e.stopPropagation();
        handleNext();
      } else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
        e.preventDefault();
        e.stopPropagation();
        handleAccept();
      } else if (e.key === 'Escape') {
        e.preventDefault();
        e.stopPropagation();
        onExit();
      }
    };

    window.addEventListener('keydown', handleKeyDown, { capture: true });
    return () => window.removeEventListener('keydown', handleKeyDown, { capture: true });
  }, [currentDraft, handlePrevious, handleNext, handleAccept, onExit]);

  // Auto-exit if no drafts remain
  useEffect(() => {
    if (pendingDrafts.length === 0) {
      onExit();
    }
  }, [pendingDrafts.length, onExit]);

  // Show empty state if no current draft
  if (!currentDraft) {
    return (
      <div className="flex h-full items-center justify-center">
        <div className="text-center">
          <Mail className="mx-auto mb-4 h-12 w-12 text-muted-foreground" />
          <p className="text-muted-foreground">No drafts to review</p>
        </div>
      </div>
    );
  }

  // Define variants that use the custom prop (this captures the direction at mount time!)
  const slideVariants = {
    enter: (direction: 'left' | 'right') => ({
      x: direction === 'right' ? '100%' : '-100%',
    }),
    center: {
      x: 0,
    },
    exit: (direction: 'left' | 'right') => ({
      x: direction === 'right' ? '-100%' : '100%',
    }),
  };

  return (
    <div className="flex h-full flex-col overflow-hidden">
      {/* Thread Display with horizontal carousel animation - "string pulling" effect */}
      <div className="relative flex-1 overflow-hidden">
        <AnimatePresence initial={false} custom={direction}>
          <motion.div
            key=[redacted]
            custom={direction}
            variants={slideVariants}
            initial={hasNavigatedRef.current ? 'enter' : 'center'}
            animate="center"
            exit="exit"
            transition={{
              duration: 0.5,
              ease: 'easeInOut',
            }}
            className="absolute inset-0 overflow-hidden"
          >
            {threadId ? (
              <ReviewModeThreadDisplay
                threadId={threadId}
                pendingDraft={currentDraft}
                onClose={onExit}
                onDraftChange={handleDraftChange}
              />
            ) : (
              <div className="flex h-full items-center justify-center">
                <div className="text-center">
                  <p className="text-muted-foreground">Thread data not available</p>
                  <p className="mt-2 text-xs text-muted-foreground">
                    Conversation ID: {currentDraft?.conversationId || 'Unknown'}
                  </p>
                </div>
              </div>
            )}
          </motion.div>
        </AnimatePresence>
      </div>

      {/* Bottom Bar */}
      <ReviewModeBottomBar
        currentIndex={currentIndex}
        totalDrafts={pendingDrafts.length}
        onPrevious={handlePrevious}
        onNext={handleNext}
        onAccept={handleAccept}
        onReject={handleReject}
      />
    </div>
  );
}