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>
);
}