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