ProcessingStateDisplay.tsx28.9 KBView on GitHub
/**
 * ProcessingStateDisplay
 *
 * Simplified processing state display for agent canvas rows.
 * Shows clean, focused states: loading → analyzing → drafting → ready → sent
 * Click on ready state expands inline email composer using DraftComposer.
 */

import type {
  AgentConversationData,
  PendingAnalysis,
  PendingDraft,
  SubagentReasoningState,
} from '@/modules/agentCanvas/types/agent-columns';
import {
  AlertCircle,
  Ban,
  ChevronRight,
  FileText,
  Loader2,
  Maximize2,
  Pencil,
  Search,
  Trash2,
} from 'lucide-react';
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 { SearchResultsPreview } from '@/modules/cedar-os/src/components/renderers/SearchResultsPreview';
import { AnimatedCheckmark } from '@/modules/userTasks/components/AnimatedCheckmark';
import { hasAnimatedMessage, markMessageAnimated } from './agentAnimationCache';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { AnimatePresence, motion } from 'motion/react';
import { useTRPC } from '@/providers/query-provider';
import { useCedarStore } from '@/modules/store';
import { cn } from '@/lib/utils';

// =============================================================================
// Types
// =============================================================================

export type ProcessingState =
  | { type: 'idle' }
  | { type: 'loading'; message: string }
  | { type: 'analyzing'; query: string }
  | { type: 'drafting'; message?: string }
  | {
      type: 'ready';
      subject: string;
      recipient: string;
      email?: string;
      draftBody?: string;
      draftId?: string;
      threadId?: string;
    }
  | { type: 'analysis_ready'; conversationId: string; citationCount: number }
  | { type: 'completed'; action: 'task_created' | 'email_sent'; recipient?: string }
  | { type: 'skipped'; reason: string }
  | { type: 'error'; message: string };

// =============================================================================
// Helper: Map AgentConversationData to ProcessingState
// =============================================================================

export function mapAgentDataToProcessingState(
  agentData: AgentConversationData | undefined,
  pendingDraft: PendingDraft | undefined,
  pendingAnalysis?: PendingAnalysis | undefined,
): ProcessingState {
  const makeDraftReady = (draft: PendingDraft): ProcessingState => ({
    type: 'ready',
    subject: draft.subject || 'Draft ready',
    recipient: draft.to || 'recipient',
    email: draft.to,
    draftBody: draft.body,
    draftId: draft.draftId,
    threadId: draft.threadId,
  });

  if (!agentData || agentData.columns.length === 0) {
    // Fall back to pendingDraft when agentProcessing columns are absent or cleared
    if (pendingDraft) return makeDraftReady(pendingDraft);
    return { type: 'idle' };
  }

  const statusColumn = agentData.columns.find((col) => col.content.type === 'status');
  const actionColumn = agentData.columns.find((col) => col.content.type === 'action');

  if (statusColumn && statusColumn.content.type === 'status') {
    const { state, message } = statusColumn.content;

    switch (state) {
      case 'scanning':
        return { type: 'analyzing', query: message || 'Analyzing conversation' };

      case 'processing':
        return { type: 'drafting', message: message || 'Preparing draft' };

      case 'complete': {
        if (actionColumn && actionColumn.content.type === 'action') {
          const actionContent = actionColumn.content;

          if (actionContent.actionType === 'viewDraft') {
            // Entry was already cleared by the user — treat as idle
            if (!pendingDraft) return { type: 'idle' };
            return makeDraftReady(pendingDraft);
          }

          if (actionContent.actionType === 'viewAnalysis' && pendingAnalysis) {
            return {
              type: 'analysis_ready',
              conversationId: pendingAnalysis.conversationId,
              citationCount: pendingAnalysis.citations.length,
            };
          }
        }

        // No action column — fall back to pendingDraft if still present
        if (pendingDraft) return makeDraftReady(pendingDraft);
        return { type: 'idle' };
      }

      case 'error':
        return { type: 'error', message: message || 'An error occurred' };

      case 'skipped':
        return { type: 'skipped', reason: message || 'No draft needed' };

      case 'waiting':
      case 'idle':
      default:
        if (message) return { type: 'loading', message };
        return { type: 'idle' };
    }
  }

  return { type: 'idle' };
}

// =============================================================================
// Typewriter Text with Animated Dots
// =============================================================================

interface TypewriterTextProps {
  text: string;
  className?: string;
  cursorClassName?: string;
  typingSpeed?: number;
  dotCount?: number;
  dotSpeed?: number;
  /** Unique key to track if this message has been animated (e.g., "convId:message") */
  cacheKey?: string;
}

function TypewriterText({
  text,
  className,
  cursorClassName = 'text-blue-500',
  typingSpeed = 40,
  dotCount = 3,
  dotSpeed = 400,
  cacheKey,
}: TypewriterTextProps) {
  // Check if this message was already animated (for scroll-based remounts)
  const wasAlreadyAnimated = cacheKey ? hasAnimatedMessage(cacheKey) : false;

  const [displayText, setDisplayText] = useState(wasAlreadyAnimated ? text : '');
  const [dotPhase, setDotPhase] = useState(0);
  const [isTypingComplete, setIsTypingComplete] = useState(wasAlreadyAnimated);

  // Type out the main text (skip if already animated)
  useEffect(() => {
    if (wasAlreadyAnimated) return;

    if (displayText.length < text.length) {
      let rafId: number;
      let lastTime = performance.now();

      const animate = (currentTime: number) => {
        const elapsed = currentTime - lastTime;

        if (elapsed >= typingSpeed) {
          setDisplayText(text.slice(0, displayText.length + 1));
          lastTime = currentTime;
        } else {
          rafId = requestAnimationFrame(animate);
        }
      };

      rafId = requestAnimationFrame(animate);
      return () => cancelAnimationFrame(rafId);
    } else {
      setIsTypingComplete(true);
      // Mark this message as animated for future remounts
      if (cacheKey) {
        markMessageAnimated(cacheKey);
      }
    }
  }, [displayText, text, typingSpeed, wasAlreadyAnimated, cacheKey]);

  // Animate the dots after typing is complete
  useEffect(() => {
    if (!isTypingComplete) return;

    const interval = setInterval(() => {
      setDotPhase((prev) => (prev + 1) % (dotCount + 1));
    }, dotSpeed);

    return () => clearInterval(interval);
  }, [isTypingComplete, dotCount, dotSpeed]);

  const dots = isTypingComplete ? '.'.repeat(dotPhase) : '';

  return (
    <span className={className}>
      {displayText}
      {dots}
      <motion.span
        className={cn(cursorClassName, 'font-medium')}
        animate={{ opacity: [1, 1, 0, 0] }}
        transition={{
          repeat: Infinity,
          duration: 0.6,
          times: [0, 0.5, 0.5, 1],
          ease: 'linear',
        }}
      >
        |
      </motion.span>
    </span>
  );
}

// =============================================================================
// Expanded Reasoning Content (shown below the row when expanded)
// =============================================================================

interface ExpandedReasoningContentProps {
  reasoning: SubagentReasoningState | undefined;
  isExpanded: boolean;
}

function ExpandedReasoningContent({ reasoning, isExpanded }: ExpandedReasoningContentProps) {
  const scrollRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (isExpanded && reasoning?.isStreaming && scrollRef.current) {
      scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
    }
  }, [reasoning?.blocks, isExpanded, reasoning?.isStreaming]);

  if (!reasoning || !reasoning.blocks?.length) {
    return null;
  }

  return (
    <AnimatePresence>
      {isExpanded && (
        <motion.div
          initial={{ height: 0, opacity: 0 }}
          animate={{ height: 'auto', opacity: 1 }}
          exit={{ height: 0, opacity: 0 }}
          transition={{ duration: 0.2 }}
          className="overflow-hidden -ml-3"
        >
          <div
            ref={scrollRef}
            className="mt-2 rounded-md bg-muted/30 border border-border/50 p-2 max-h-[200px] overflow-y-auto"
          >
            {reasoning.blocks.map((block, idx) =>
              block.type === 'text' ? (
                <pre
                  key=[redacted]
                  className="text-[10px] text-muted-foreground whitespace-pre-wrap font-mono leading-relaxed"
                >
                  {block.content}
                </pre>
              ) : block.type === 'search-results' ? (
                <SearchResultsPreview key=[redacted] results={block.results} />
              ) : null,
            )}
          </div>
        </motion.div>
      )}
    </AnimatePresence>
  );
}

// =============================================================================
// Inline Analysis Content (rendered below the analysis badge when expanded)
// =============================================================================

const CITATIONS_SECTION_REGEX = /\n+Citations\s*\n[\s\S]*$/i;

interface InlineAnalysisContentProps {
  conversationId: string;
  isExpanded: boolean;
}

function InlineAnalysisContent({ conversationId, isExpanded }: InlineAnalysisContentProps) {
  const pendingAnalysis = useCedarStore((state) => {
    const canvasId = state.activeCanvasId;
    return canvasId
      ? state.canvasesById[canvasId]?.data?.conversationEntries?.[conversationId]?.pendingAnalysis
      : undefined;
  });
  const scrollRef = useRef<HTMLDivElement>(null);

  const cleanedContent = useMemo(() => {
    if (!pendingAnalysis?.content) return '';
    return pendingAnalysis.content.replace(CITATIONS_SECTION_REGEX, '').trim();
  }, [pendingAnalysis?.content]);

  if (!pendingAnalysis || !cleanedContent) {
    return null;
  }

  return (
    <AnimatePresence>
      {isExpanded && (
        <motion.div
          initial={{ height: 0, opacity: 0 }}
          animate={{ height: 'auto', opacity: 1 }}
          exit={{ height: 0, opacity: 0 }}
          transition={{ duration: 0.2 }}
          className="overflow-hidden -ml-1"
        >
          <div
            ref={scrollRef}
            className="mt-2 rounded-md  border border-border/50 p-3 max-h-[300px] overflow-y-auto"
          >
            <div className="prose prose-sm dark:prose-invert max-w-none">
              <MarkdownRenderer
                content={cleanedContent}
                citations={pendingAnalysis.citations}
                inline={false}
                additionalRemarkPlugins={[remarkCitationPlugin]}
              />
            </div>
            {!pendingAnalysis.isStreaming && (
              <div className="mt-6 mb-3 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>
        </motion.div>
      )}
    </AnimatePresence>
  );
}

// =============================================================================
// Processing State Display
// =============================================================================

// =============================================================================
// Ready State Row — thread.tsx layout with conversation avatar + name
// =============================================================================

interface ReadyStateRowProps {
  state: Extract<ProcessingState, { type: 'ready' }>;
  conversationId: string;
  isAccepting: boolean;
  onCreateDraft: () => void;
  onDelete: () => void;
}

/** Mirrors thread.tsx's cleanNameDisplay */
function cleanNameDisplay(name?: string): string {
  if (!name) return '';
  const match = name.match(/^[^\p{L}\p{N}.]*(.*?)[^\p{L}\p{N}.]*$/u);
  return match ? match[1] : name;
}

function ReadyStateRow({
  state,
  conversationId,
  isAccepting,
  onCreateDraft,
  onDelete,
}: ReadyStateRowProps) {
  const trpc = useTRPC();

  // Company avatar from conversation store
  const conversationData = useCedarStore((s) => s.conversations[conversationId]);
  const companyLogoUrl = conversationData?.data?.company?.logoUrl ?? undefined;

  // Thread data for participant names — same source as thread.tsx
  const threadId = state.threadId || undefined;
  const threadData = useCedarStore((s) => (threadId ? s.threadData[threadId] : undefined));

  // Load the thread if not already in the store so participant names are available
  useQuery({
    ...trpc.mail.get.queryOptions({ id: threadId! }),
    enabled: !!threadId && !threadData,
    staleTime: 5 * 60 * 1000,
  });

  // Derive display name exactly as thread.tsx does from recentParticipants
  const displayName = useMemo(() => {
    const participants = threadData?.recentParticipants;
    if (participants?.length) {
      return participants.map((p) => cleanNameDisplay(p.name) || p.email).join(', ');
    }
    // Fallback: latest message sender (same as thread.tsx fallback)
    const latest = threadData?.latest;
    if (latest) {
      return (
        cleanNameDisplay(latest.sender?.name) ||
        latest.sender?.email ||
        state.email ||
        state.recipient
      );
    }
    // Last resort: just the email address from the draft
    return state.email || state.recipient || 'recipient';
  }, [threadData, state.email, state.recipient]);

  const avatarInitial = displayName[0]?.toUpperCase() ?? '?';

  // Strip HTML from draft body for the snippet
  const snippet = state.draftBody
    ? state.draftBody
        .replace(/<[^>]*>/g, ' ')
        .replace(/\s+/g, ' ')
        .trim()
        .slice(0, 140)
    : '';

  return (
    <motion.div
      initial={{ opacity: 0, y: 4 }}
      animate={{ opacity: 1, y: 0 }}
      className="relative flex w-full items-center gap-4 py-1.5 md:py-2.5"
      onClick={(e) => e.stopPropagation()}
    >
      {/* Avatar — same slot as checkbox in thread.tsx */}
      <div className="relative flex h-6 w-6 shrink-0 items-center justify-center">
        <Avatar className="h-6 w-6">
          {companyLogoUrl && (
            <img
              src={companyLogoUrl}
              alt={displayName}
              className="h-full w-full rounded-full object-contain"
            />
          )}
          <AvatarFallback className="rounded-full bg-primary/10 text-xs font-semibold text-primary">
            {avatarInitial}
          </AvatarFallback>
        </Avatar>
      </div>

      {/* Main content row */}
      <div className="flex min-w-0 flex-1 items-center justify-between gap-2">
        <div className="flex min-w-0 flex-1 items-center gap-2">
          {/* Participant + "Draft" — same width as thread.tsx name column */}
          <span className="text-foreground flex w-36 shrink-0 items-center gap-1 text-sm font-semibold xl:w-44 2xl:w-52">
            <span className="min-w-0 truncate">{displayName},</span>
            <span className="shrink-0 text-[#d93025]">Draft</span>
          </span>

          {/* Unread dot placeholder — keeps subject aligned with thread list */}
          <span className="mr-0.5 flex size-2 shrink-0 rounded-full invisible" />

          {/* Subject */}
          <span className="text-foreground truncate text-sm font-medium">{state.subject}</span>

          {/* Snippet from draft body */}
          {snippet && (
            <>
              <span className="text-muted-foreground shrink-0 text-sm">-</span>
              <span className="text-muted-foreground min-w-0 flex-1 truncate text-sm">
                {snippet}
              </span>
            </>
          )}
        </div>

        {/* Actions */}
        <div className="flex shrink-0 items-center gap-1">
          <button
            onClick={onDelete}
            disabled={isAccepting}
            className="rounded p-1 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive disabled:opacity-40"
            aria-label="Delete draft"
            title="Delete draft"
          >
            <Trash2 className="h-3.5 w-3.5" />
          </button>
          <button
            onClick={onCreateDraft}
            disabled={isAccepting}
            className="rounded-md bg-foreground px-2.5 py-1 text-xs font-medium text-background transition-colors hover:bg-foreground/90 disabled:opacity-50"
          >
            {isAccepting ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Create draft'}
          </button>
        </div>
      </div>
    </motion.div>
  );
}

interface ProcessingStateDisplayProps {
  state: ProcessingState;
  conversationId: string;
  onClickReady?: () => void;
  onOpenDraft?: (e: React.MouseEvent) => void;
  onDiscard?: () => void;
  onDraftChange?: (subject: string, body: string) => void;
  onOpenAnalysis?: () => void;
}

export function ProcessingStateDisplay({
  state,
  conversationId,
  onOpenAnalysis,
}: ProcessingStateDisplayProps) {
  const [isReasoningExpanded, setIsReasoningExpanded] = useState(false);
  const [isAnalysisExpanded, setIsAnalysisExpanded] = useState(false);
  const [isAccepting, setIsAccepting] = useState(false);

  const trpc = useTRPC();
  const queryClient = useQueryClient();
  const activeCanvasId = useCedarStore((state) => state.activeCanvasId);
  const clearConversationEntry = useCedarStore((state) => state.clearConversationEntry);
  const injectDraftIntoThread = useCedarStore((state) => state.injectDraftIntoThread);

  const subagentReasoning = useCedarStore((state) => {
    const canvasId = state.activeCanvasId;
    return canvasId
      ? state.canvasesById[canvasId]?.data?.conversationEntries?.[conversationId]?.reasoning
      : undefined;
  });

  useEffect(() => {
    if (subagentReasoning?.isStreaming) {
      setIsReasoningExpanded(true);
    } else if (subagentReasoning && !subagentReasoning.isStreaming) {
      setIsReasoningExpanded(false);
    }
  }, [subagentReasoning?.isStreaming]);

  const pendingAnalysis = useCedarStore((state) => {
    const canvasId = state.activeCanvasId;
    return canvasId
      ? state.canvasesById[canvasId]?.data?.conversationEntries?.[conversationId]?.pendingAnalysis
      : undefined;
  });

  useEffect(() => {
    if (pendingAnalysis && !pendingAnalysis.isStreaming) {
      setIsAnalysisExpanded(true);
    }
  }, [pendingAnalysis?.isStreaming]);

  const pendingDraft = useCedarStore((state) => {
    const canvasId = state.activeCanvasId;
    return canvasId
      ? state.canvasesById[canvasId]?.data?.conversationEntries?.[conversationId]?.pendingDraft
      : undefined;
  });

  const createTaskMutation = useMutation({
    mutationFn: trpc.userTasks.createStandaloneTask.mutationOptions().mutationFn,
  });

  // Fetch threadId from conversation events as fallback
  const fetchThreadId = useCallback(async (): Promise<string | null> => {
    try {
      const data = await queryClient.fetchQuery({
        ...trpc.crm.getConversation.queryOptions({ id: conversationId }),
        staleTime: 30000,
      });
      const events = data?.conversation?.events || [];
      const found = events
        .filter((e) => e.emailEvent?.threadId)
        .sort((a, b) => new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime())[0];
      return (found?.emailEvent?.threadId as string | undefined) ?? null;
    } catch {
      return null;
    }
  }, [conversationId, queryClient, trpc.crm.getConversation]);

  // "Create Draft" — inject locally + open thread + delete canvas entry + fire task in background
  const handleCreateDraft = useCallback(async () => {
    if (state.type !== 'ready' || !activeCanvasId) return;

    const threadId = state.threadId?.trim() || pendingDraft?.threadId?.trim() || null;
    const resolvedThreadId = threadId || (await fetchThreadId());

    if (!resolvedThreadId) return;

    setIsAccepting(true);
    try {
      injectDraftIntoThread(resolvedThreadId, {
        body: state.draftBody || pendingDraft?.body || '',
        subject: state.subject,
        to: state.email || pendingDraft?.to,
        additionalLabelNames: pendingDraft?.additionalLabelNames,
      });

      const store = useCedarStore.getState();
      store.openThread(resolvedThreadId);

      clearConversationEntry(activeCanvasId, conversationId);

      if (conversationId) {
        createTaskMutation.mutate({
          conversationId,
          description: state.subject || 'Draft email',
          taskType: 'manual',
          dueDate: new Date().toISOString(),
          taskActionData: { channel: 'email', threadId: resolvedThreadId },
        });
      }

      queryClient.invalidateQueries({ queryKey: ['tasks'] });
      queryClient.invalidateQueries({ queryKey: ['conversations'] });
    } finally {
      setIsAccepting(false);
    }
  }, [
    state,
    activeCanvasId,
    pendingDraft,
    fetchThreadId,
    injectDraftIntoThread,
    clearConversationEntry,
    conversationId,
    createTaskMutation,
    queryClient,
  ]);

  const handleDelete = useCallback(() => {
    if (!activeCanvasId) return;
    clearConversationEntry(activeCanvasId, conversationId);
  }, [activeCanvasId, clearConversationEntry, conversationId]);

  if (state.type === 'idle') return null;

  const getAnalyzingDisplayText = (query: string) => {
    const actionVerbPattern = /^(Reviewing|Loading|Fetching|Preparing|Processing|Reading)/i;
    return actionVerbPattern.test(query) ? query : `Analyzing: ${query}`;
  };

  return (
    <div className="w-[min(100%,calc(100vw-450px))] px-3">
      {/* Loading state */}
      {state.type === 'loading' && (
        <motion.div
          initial={{ opacity: 0, y: 4 }}
          animate={{ opacity: 1, y: 0 }}
          className="flex items-center gap-2 py-2.5 text-muted-foreground"
        >
          <Loader2 className="h-3.5 w-3.5 animate-spin text-orange-500" />
          <span className="text-xs">{state.message}</span>
        </motion.div>
      )}

      {/* Analyzing state */}
      {state.type === 'analyzing' && (
        <motion.div
          initial={{ opacity: 0, y: 4 }}
          animate={{ opacity: 1, y: 0 }}
          className="py-2.5"
        >
          <div
            className={cn(
              'flex items-start gap-2',
              subagentReasoning?.blocks?.length && 'cursor-pointer',
            )}
            onClick={(e) => {
              if (subagentReasoning?.blocks?.length) {
                e.stopPropagation();
                setIsReasoningExpanded(!isReasoningExpanded);
              }
            }}
          >
            <Search className="h-3.5 w-3.5 text-orange-500 flex-shrink-0 mt-0.5" />
            <div className="flex-1 min-w-0">
              <TypewriterText
                text={getAnalyzingDisplayText(state.query)}
                className="text-xs text-foreground break-words"
                cursorClassName="text-orange-500"
                typingSpeed={15}
                cacheKey=[redacted]
              />
            </div>
            {subagentReasoning?.blocks?.length && (
              <motion.div
                animate={{ rotate: isReasoningExpanded ? 90 : 0 }}
                transition={{ duration: 0.15 }}
              >
                <ChevronRight className="h-3 w-3 text-muted-foreground" />
              </motion.div>
            )}
          </div>
          <ExpandedReasoningContent
            reasoning={subagentReasoning}
            isExpanded={isReasoningExpanded}
          />
        </motion.div>
      )}

      {/* Drafting state */}
      {state.type === 'drafting' && (
        <motion.div
          initial={{ opacity: 0, y: 4 }}
          animate={{ opacity: 1, y: 0 }}
          className="py-2.5"
        >
          <div
            className={cn(
              'flex items-center gap-2',
              subagentReasoning?.blocks?.length && 'cursor-pointer',
            )}
            onClick={(e) => {
              if (subagentReasoning?.blocks?.length) {
                e.stopPropagation();
                setIsReasoningExpanded(!isReasoningExpanded);
              }
            }}
          >
            <motion.div
              animate={{ scale: [1, 1.1, 1] }}
              transition={{ repeat: Infinity, duration: 1.2 }}
            >
              <Pencil className="h-3.5 w-3.5 text-amber-500" />
            </motion.div>
            <div className="flex-1 min-w-0">
              <TypewriterText
                text={state.message || 'Preparing draft'}
                className="text-xs text-foreground"
                cursorClassName="text-amber-500"
                typingSpeed={15}
                cacheKey=[redacted]
              />
            </div>
            {subagentReasoning?.blocks?.length && (
              <motion.div
                animate={{ rotate: isReasoningExpanded ? 90 : 0 }}
                transition={{ duration: 0.15 }}
              >
                <ChevronRight className="h-3 w-3 text-muted-foreground" />
              </motion.div>
            )}
          </div>
          <ExpandedReasoningContent
            reasoning={subagentReasoning}
            isExpanded={isReasoningExpanded}
          />
        </motion.div>
      )}

      {/* Ready state — mirrors thread.tsx row layout exactly */}
      {state.type === 'ready' && (
        <ReadyStateRow
          state={state}
          conversationId={conversationId}
          isAccepting={isAccepting}
          onCreateDraft={() => void handleCreateDraft()}
          onDelete={handleDelete}
        />
      )}

      {/* Analysis ready state */}
      {state.type === 'analysis_ready' && (
        <>
          <motion.div
            initial={{ opacity: 0, y: 4 }}
            animate={{ opacity: 1, y: 0 }}
            className="group flex cursor-pointer items-center gap-2 py-2.5"
            onClick={(e) => {
              e.stopPropagation();
              setIsAnalysisExpanded(!isAnalysisExpanded);
            }}
          >
            <span className="inline-flex items-center gap-1 rounded-sm bg-blue-100 dark:bg-blue-900/30 px-1 py-0.5">
              <FileText className="h-3 w-3 shrink-0 text-blue-600 dark:text-blue-400" />
              <span className="text-xs font-medium text-blue-700 dark:text-blue-300">Analyzed</span>
              <motion.div
                animate={{ rotate: isAnalysisExpanded ? 90 : 0 }}
                transition={{ duration: 0.15 }}
              >
                <ChevronRight className="h-2.5 w-2.5 text-blue-600 dark:text-blue-400" />
              </motion.div>
            </span>
            <button
              onClick={(e) => {
                e.stopPropagation();
                onOpenAnalysis?.();
              }}
              className="ml-auto h-6 px-2 text-xs text-blue-600 dark:text-blue-400 rounded hover:bg-blue-500/10 flex items-center gap-1"
            >
              <Maximize2 style={{ width: 12, height: 12 }} />
              Full View
            </button>
          </motion.div>
          <InlineAnalysisContent
            conversationId={state.conversationId}
            isExpanded={isAnalysisExpanded}
          />
        </>
      )}

      {/* Completed state */}
      {state.type === 'completed' && (
        <motion.div
          initial={{ opacity: 0 }}
          animate={{ opacity: 1 }}
          className="flex items-center gap-1.5 py-2.5"
        >
          <AnimatedCheckmark
            isDone={true}
            circleClassName="text-green-600 dark:text-green-500"
            size="sm"
          />
          <span className="text-xs text-green-700 dark:text-green-400">
            Sent to {state.recipient || 'recipient'}
          </span>
        </motion.div>
      )}

      {/* Skipped state */}
      {state.type === 'skipped' && (
        <motion.div
          initial={{ opacity: 0 }}
          animate={{ opacity: 1 }}
          className="flex items-center gap-2 py-2.5"
        >
          <span className="inline-flex items-center gap-1 rounded-sm bg-muted px-1 py-0.5">
            <Ban className="h-2.5 w-2.5 shrink-0 text-muted-foreground" />
            <span className="text-xs font-medium text-muted-foreground">Skipped</span>
          </span>
          <span className="text-xs text-muted-foreground truncate">{state.reason}</span>
        </motion.div>
      )}

      {/* Error state */}
      {state.type === 'error' && (
        <motion.div
          initial={{ opacity: 0, y: 4 }}
          animate={{ opacity: 1, y: 0 }}
          className="flex items-center gap-2 py-2.5"
        >
          <AlertCircle className="h-3.5 w-3.5 text-red-500 flex-shrink-0" />
          <span className="text-xs text-red-600 dark:text-red-400 truncate">{state.message}</span>
        </motion.div>
      )}
    </div>
  );
}