AnalysisReviewMode.tsx16.7 KBView on GitHub /**
* AnalysisReviewMode Component
*
* Read-only modal for reviewing per-conversation analysis results with interactive citations.
* Modeled after CanvasReviewMode but simpler: no accept/reject, just navigation and reading.
*
* Features:
* - Keyboard navigation (←→ arrows) between conversations
* - Rendered analysis text with [N] citation markers as interactive CitationPopover pills
* - Expandable "Thought for Xs" reasoning section
* - Escape to close
*/
import { remarkCitationPlugin } from '@/modules/cedar-os/src/cedar-os-components/chatMessages/remarkCitationPlugin';
import { MarkdownRenderer } from '@/modules/cedar-os/src/cedar-os-components/chatMessages/MarkdownRenderer';
import type { PendingAnalysis } from '@/modules/agentCanvas/types/agent-columns';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { ArrowLeft, ChevronLeft, ChevronRight, ChevronDown, FileText, Search } from 'lucide-react';
import { usePrefetchEventsForCitations } from '@/components/ui/citations/usePrefetchCitations';
import { useState, useEffect, useCallback, useRef, useMemo, memo } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import { useHotkeysContext } from 'react-hotkeys-hook';
import { Button } from '@/components/ui/button';
import { useCedarStore } from '@/modules/store';
import { useShallow } from 'zustand/react/shallow';
import { Kbd } from '@/components/ui/kbd';
// Stable empty reference to prevent re-renders when there are no analyses
const EMPTY_STRING_ARRAY: string[] = [];
// -----------------------------------------------------------------------------
// Constants
// -----------------------------------------------------------------------------
/** Strip trailing <citations> XML block from content (same cleanup as CitedTextRenderer) */
const CITATIONS_SECTION_REGEX = /\n+Citations\s*\n[\s\S]*$/i;
// -----------------------------------------------------------------------------
// Helper: Format reasoning duration
// -----------------------------------------------------------------------------
function formatReasoningDuration(startedAt?: string, endedAt?: string): string | null {
if (!startedAt || !endedAt) return null;
const durationMs = new Date(endedAt).getTime() - new Date(startedAt).getTime();
if (durationMs < 0) return null;
const totalSeconds = Math.round(durationMs / 1000);
if (totalSeconds < 60) return `${totalSeconds}s`;
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
}
// -----------------------------------------------------------------------------
// AnalysisContent: renders a single analysis with citations + reasoning
// -----------------------------------------------------------------------------
interface AnalysisContentProps {
analysis: PendingAnalysis;
conversationId: string;
}
const AnalysisContent = memo(function AnalysisContent({ analysis, conversationId }: AnalysisContentProps) {
const [isReasoningExpanded, setIsReasoningExpanded] = useState(false);
// Prefetch all cited event content so the modal opens instantly on pill click
usePrefetchEventsForCitations(analysis.citations);
// Get conversation name for the header — narrow selector to just this conversation
const conversationData = useCedarStore((state) => state.conversations[conversationId]);
const company = conversationData?.data?.company;
const companyName = company?.name || 'Unknown';
const companyLogoUrl = company?.logoUrl;
const companyInitial = companyName?.[0]?.toUpperCase() || '?';
const conversationName = conversationData?.data?.conversation?.name || companyName;
// Get subagent reasoning for this conversation from the active canvas
const reasoning = useCedarStore((state) => {
const canvasId = state.activeCanvasId;
return canvasId
? state.canvasesById[canvasId]?.data?.conversationEntries?.[conversationId]?.reasoning
: undefined;
});
// Clean content (strip trailing citations XML section)
const cleanedContent = useMemo(() => {
if (!analysis.content) return '';
return analysis.content.replace(CITATIONS_SECTION_REGEX, '').trim();
}, [analysis.content]);
const reasoningDuration = formatReasoningDuration(reasoning?.startedAt, reasoning?.endedAt);
return (
<div className="flex h-full flex-col">
{/* Header */}
<div className="border-b px-8 py-4 space-y-2.5">
{/* Row 1: Logo + Company + Conversation name */}
<div className="flex items-center gap-3">
<Avatar className="h-8 w-8">
<AvatarImage src={companyLogoUrl || undefined} className="object-contain" />
<AvatarFallback className="rounded-full bg-primary/10 text-xs font-semibold text-primary">
{companyInitial}
</AvatarFallback>
</Avatar>
<div className="flex items-center gap-2 min-w-0">
<span className="text-lg font-semibold truncate">{companyName}</span>
{conversationName !== companyName && (
<>
<span className="text-muted-foreground/50">·</span>
<span className="text-lg truncate">{conversationName}</span>
</>
)}
{analysis.isStreaming && (
<span className="text-xs text-orange-500 animate-pulse shrink-0">Streaming...</span>
)}
</div>
</div>
{/* Row 2: Search query + sources */}
{analysis.searchQuery && (
<div className="flex items-center gap-2">
<Search className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<span className="text-sm text-muted-foreground truncate">{analysis.searchQuery}</span>
{analysis.citations.length > 0 && (
<span className="text-[11px] text-muted-foreground border border-border rounded-full px-2 py-0.5 shrink-0">
{analysis.citations.length} source{analysis.citations.length !== 1 ? 's' : ''}
</span>
)}
</div>
)}
</div>
{/* Scrollable content area */}
<div className="flex-1 overflow-y-auto px-8 py-4">
{/* Expandable reasoning section */}
{reasoning?.blocks?.length && !reasoning.isStreaming && (
<div className="mb-4">
<button
onClick={() => setIsReasoningExpanded(!isReasoningExpanded)}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
>
<motion.div
animate={{ rotate: isReasoningExpanded ? 180 : 0 }}
transition={{ duration: 0.15 }}
>
<ChevronDown className="h-3 w-3" />
</motion.div>
<span>Thought</span>
{reasoningDuration && (
<span className="text-muted-foreground/60">for {reasoningDuration}</span>
)}
</button>
<AnimatePresence>
{isReasoningExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="mt-2 rounded-md bg-muted/30 border border-border/50 p-3 max-h-[200px] overflow-y-auto">
{reasoning.blocks.map((block, idx) =>
block.type === 'text' ? (
<pre key=[redacted] className="text-xs text-muted-foreground whitespace-pre-wrap font-mono leading-relaxed">
{block.content}
</pre>
) : null,
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)}
{/* Analysis content with citations */}
{cleanedContent ? (
<div>
<div className="prose prose-sm dark:prose-invert max-w-none">
<MarkdownRenderer
content={cleanedContent}
citations={analysis.citations}
inline={false}
additionalRemarkPlugins={[remarkCitationPlugin]}
/>
</div>
{!analysis.isStreaming && (
<div className="mt-8 flex items-center gap-3 text-muted-foreground/40">
<div className="h-px flex-1 bg-border/50" />
<span className="text-[10px] tracking-widest uppercase select-none">end</span>
<div className="h-px flex-1 bg-border/50" />
</div>
)}
</div>
) : (
<div className="flex items-center justify-center py-12">
<p className="text-sm text-muted-foreground">No analysis content available</p>
</div>
)}
</div>
</div>
);
});
// -----------------------------------------------------------------------------
// AnalysisReviewMode: Main modal component
// -----------------------------------------------------------------------------
export interface AnalysisReviewModeProps {
onExit: () => void;
}
export function AnalysisReviewMode({ onExit }: AnalysisReviewModeProps) {
// Read review state from canvasSlice (active canvas)
const activeCanvasId = useCedarStore((state) => state.activeCanvasId);
// Compute analysis queue directly from state (useShallow prevents re-renders when array contents are equal)
const conversationIds = useCedarStore(
useShallow((state) => {
const canvasId = state.activeCanvasId;
if (!canvasId) return EMPTY_STRING_ARRAY;
const entries = state.canvasesById[canvasId]?.data?.conversationEntries;
if (!entries) return EMPTY_STRING_ARRAY;
return Object.entries(entries)
.filter(([, e]) => e.pendingAnalysis && !e.pendingAnalysis.isStreaming)
.map(([id]) => id);
}),
);
const currentIndex = useCedarStore((state) => {
const canvasId = state.activeCanvasId;
return canvasId ? (state.canvasUIState[canvasId]?.analysisReview?.currentIndex ?? 0) : 0;
});
const nextAnalysis = useCedarStore((state) => state.nextAnalysis);
const prevAnalysis = useCedarStore((state) => state.prevAnalysis);
// Derive current conversation ID
const currentConversationId = conversationIds[currentIndex];
// Only subscribe to the CURRENT analysis — other analyses streaming won't trigger re-renders
const currentAnalysis = useCedarStore((state) => {
const canvasId = state.activeCanvasId;
return canvasId && currentConversationId
? state.canvasesById[canvasId]?.data?.conversationEntries?.[currentConversationId]?.pendingAnalysis
: undefined;
});
// Direction tracking for slide animation
const previousIndexRef = useRef<number>(currentIndex);
const hasNavigatedRef = useRef<boolean>(false);
const direction = !hasNavigatedRef.current
? 'right'
: currentIndex > previousIndexRef.current
? 'right'
: 'left';
// Update previous index after navigation
useEffect(() => {
if (hasNavigatedRef.current) {
previousIndexRef.current = currentIndex;
}
}, [currentIndex]);
const handlePrevious = useCallback(() => {
if (!activeCanvasId) return;
hasNavigatedRef.current = true;
previousIndexRef.current = currentIndex;
prevAnalysis(activeCanvasId);
}, [activeCanvasId, currentIndex, prevAnalysis]);
const handleNext = useCallback(() => {
if (!activeCanvasId) return;
hasNavigatedRef.current = true;
previousIndexRef.current = currentIndex;
nextAnalysis(activeCanvasId);
}, [activeCanvasId, currentIndex, nextAnalysis]);
// Enable review-mode scope
const { enableScope, disableScope } = useHotkeysContext();
useEffect(() => {
enableScope('analysis-review-mode');
return () => {
disableScope('analysis-review-mode');
};
}, [enableScope, disableScope]);
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Don't trigger if user is typing in an input/textarea
const target = e.target as HTMLElement;
if (
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.isContentEditable
) {
return;
}
if (e.key === 'ArrowLeft') {
e.preventDefault();
e.stopPropagation();
handlePrevious();
} else if (e.key === 'ArrowRight') {
e.preventDefault();
e.stopPropagation();
handleNext();
} else if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
onExit();
}
};
window.addEventListener('keydown', handleKeyDown, { capture: true });
return () => window.removeEventListener('keydown', handleKeyDown, { capture: true });
}, [handlePrevious, handleNext, onExit]);
// Auto-exit if no analyses remain
useEffect(() => {
if (conversationIds.length === 0) {
onExit();
}
}, [conversationIds.length, onExit]);
// Empty state
if (!currentAnalysis || !currentConversationId) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<FileText className="mx-auto mb-4 h-12 w-12 text-muted-foreground" />
<p className="text-muted-foreground">No analyses to review</p>
</div>
</div>
);
}
const slideVariants = {
enter: (dir: 'left' | 'right') => ({
x: dir === 'right' ? '100%' : '-100%',
}),
center: { x: 0 },
exit: (dir: 'left' | 'right') => ({
x: dir === 'right' ? '-100%' : '100%',
}),
};
return (
<div className="flex h-full flex-col overflow-hidden">
{/* Back button */}
<div className="px-4 pt-3 pb-1">
<Button
variant="ghost"
size="sm"
onClick={onExit}
className="gap-2 text-muted-foreground hover:text-foreground cursor-pointer"
>
<ArrowLeft className="h-4 w-4" />
Back
</Button>
</div>
{/* Content area with slide animation */}
<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"
>
<AnalysisContent
analysis={currentAnalysis}
conversationId={currentConversationId}
/>
</motion.div>
</AnimatePresence>
</div>
{/* Bottom bar */}
<AnalysisReviewBottomBar
currentIndex={currentIndex}
total={conversationIds.length}
onPrevious={handlePrevious}
onNext={handleNext}
/>
</div>
);
}
// -----------------------------------------------------------------------------
// AnalysisReviewBottomBar: simpler than ReviewModeBottomBar (no accept/reject)
// -----------------------------------------------------------------------------
interface AnalysisReviewBottomBarProps {
currentIndex: number;
total: number;
onPrevious: () => void;
onNext: () => void;
}
function AnalysisReviewBottomBar({
currentIndex,
total,
onPrevious,
onNext,
}: AnalysisReviewBottomBarProps) {
return (
<div className="flex items-center bg-raised px-10 py-4">
{/* Left: Navigation */}
<div className="flex items-center gap-4">
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={onPrevious}
disabled={total <= 1}
className="h-8 px-2"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={onNext}
disabled={total <= 1}
className="h-8 px-2"
>
<ChevronRight className="h-4 w-4" />
</Button>
<span className="mx-2 text-xs text-muted-foreground">
{currentIndex + 1} of {total}
</span>
</div>
<div className="h-4 w-px bg-border" />
{/* Keyboard hints */}
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5">
<Kbd>←→</Kbd>
<span>navigate</span>
</div>
<div className="flex items-center gap-1.5">
<Kbd>esc</Kbd>
<span>close</span>
</div>
</div>
</div>
</div>
);
}