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