cohorta/src/components/ui/ProgressModal.tsx
michael 03bb650ec6 Replace inline progress bars with modal progress dialogs for better visibility
- Created new reusable ProgressModal component with animated progress bar
- Converted all inline GenerationProgressBar usages to modal dialogs:
  - AIRecruiter.tsx: Persona generation
  - FocusGroupModerator.tsx: Discussion guide generation
  - FocusGroupSession.tsx: Key themes extraction
  - SyntheticUsers.tsx: Persona summary generation
  - PersonaModificationModal.tsx: Persona modification
- Modal features: auto-dismiss after completion, non-dismissible during operation,
  cancel support, progress animation from 0-90% over 54 seconds
- Fixed broken theme generation state calls in FocusGroupSession.tsx

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 11:03:31 -06:00

343 lines
9.9 KiB
TypeScript

import React, { useState, useEffect, useRef } from 'react';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Progress } from '@/components/ui/progress';
import { Button } from '@/components/ui/button';
import { CheckCircle, AlertCircle, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
type AnimationPhase = 'progressing' | 'waiting' | 'completing' | 'completed' | 'hiding';
interface ProgressModalProps {
isOpen: boolean;
onClose: () => void;
// State from useCancellableGeneration hook
isActive: boolean;
isComplete: boolean;
hasError: boolean;
isCancelling: boolean;
taskId: string | null;
// Customization
title: string;
description: string;
icon?: React.ReactNode;
errorMessage?: string;
// Callbacks
onCancel?: () => void;
onComplete?: () => void;
// Optional operation-specific details
currentItem?: string;
completedCount?: number;
totalCount?: number;
}
export const ProgressModal: React.FC<ProgressModalProps> = ({
isOpen,
onClose,
isActive,
isComplete,
hasError,
isCancelling,
taskId,
title,
description,
icon,
errorMessage,
onCancel,
onComplete,
currentItem,
completedCount,
totalCount
}) => {
const [progress, setProgress] = useState(0);
const [phase, setPhase] = useState<AnimationPhase>('progressing');
const [internalComplete, setInternalComplete] = useState(false);
const timerRef = useRef<NodeJS.Timeout | null>(null);
const phaseTimerRef = useRef<NodeJS.Timeout | null>(null);
const animationStartedRef = useRef(false);
const progressRef = useRef(0);
const stateRef = useRef({ isComplete, hasError, isCancelling });
stateRef.current = { isComplete, hasError, isCancelling };
// Clear all timers helper
const clearAllTimers = () => {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
if (phaseTimerRef.current) {
clearTimeout(phaseTimerRef.current);
phaseTimerRef.current = null;
}
};
// Reset state when modal opens
useEffect(() => {
if (isOpen && isActive) {
clearAllTimers();
setProgress(0);
progressRef.current = 0;
setPhase('progressing');
setInternalComplete(false);
animationStartedRef.current = false;
}
}, [isOpen, isActive]);
// Animate to completion from current progress
const animateToCompletion = (fromProgress: number) => {
clearAllTimers();
setPhase('completing');
// Animate quickly to 100% over 500ms
const remainingProgress = 100 - fromProgress;
const completionInterval = 50;
const totalSteps = 500 / completionInterval;
const progressPerStep = remainingProgress / totalSteps;
let step = 0;
timerRef.current = setInterval(() => {
step++;
const newProgress = fromProgress + (progressPerStep * step);
if (newProgress >= 100 || step >= totalSteps) {
setProgress(100);
setPhase('completed');
setInternalComplete(true);
clearAllTimers();
// Auto-dismiss after 2.5 seconds
phaseTimerRef.current = setTimeout(() => {
setPhase('hiding');
setTimeout(() => {
onComplete?.();
onClose();
}, 300);
}, 2500);
} else {
setProgress(newProgress);
}
}, completionInterval);
};
// Main effect to handle progress animation
useEffect(() => {
// Only start animation once when conditions are met
if (isOpen && isActive && phase === 'progressing' && !animationStartedRef.current) {
animationStartedRef.current = true;
// Start progress animation to 90% over 54 seconds
const progressPerUpdate = 90 / 540;
timerRef.current = setInterval(() => {
// Check if we should stop
if (stateRef.current.isComplete || stateRef.current.hasError || stateRef.current.isCancelling) {
clearAllTimers();
return;
}
progressRef.current += progressPerUpdate;
if (progressRef.current >= 90) {
setProgress(90);
progressRef.current = 90;
setPhase('waiting');
clearAllTimers();
} else {
setProgress(progressRef.current);
}
}, 100);
}
// Cleanup only on unmount or when modal closes
return () => {
if (!isOpen) {
clearAllTimers();
animationStartedRef.current = false;
}
};
}, [isOpen, isActive, phase]);
// Handle completion
useEffect(() => {
if (isComplete && !internalComplete && (phase === 'progressing' || phase === 'waiting')) {
animateToCompletion(progressRef.current);
}
}, [isComplete, phase, internalComplete]);
// Handle cancellation
useEffect(() => {
if (isCancelling && (phase === 'progressing' || phase === 'waiting')) {
clearAllTimers();
}
}, [isCancelling, phase]);
// Handle cancellation completed (modal closes)
useEffect(() => {
if (!isCancelling && !isActive && isOpen && !isComplete && !hasError) {
// Cancellation completed - close after brief delay
setTimeout(() => {
onClose();
}, 1000);
}
}, [isCancelling, isActive, isOpen, isComplete, hasError, onClose]);
// Cleanup on unmount
useEffect(() => {
return () => {
clearAllTimers();
};
}, []);
const handleCancel = () => {
if (onCancel && !isCancelling && taskId && !taskId.startsWith('temp-')) {
onCancel();
}
};
// Prevent closing during operation
const handleOpenChange = (open: boolean) => {
if (!open && (internalComplete || hasError)) {
onClose();
}
// Don't allow closing during active operation
};
const getStatusLabel = () => {
if (isCancelling) return 'Cancelling...';
if (hasError) return 'Operation failed';
if (internalComplete) return 'Completed successfully!';
if (phase === 'waiting') return 'Finalizing...';
if (currentItem) return currentItem;
return 'Processing...';
};
const getIcon = () => {
if (hasError) return <AlertCircle className="h-5 w-5 text-red-500" />;
if (internalComplete) return <CheckCircle className="h-5 w-5 text-green-500" />;
if (icon) return icon;
return <Loader2 className="h-5 w-5 text-blue-500 animate-spin" />;
};
const canCancel = !internalComplete && !hasError && taskId && !taskId.startsWith('temp-') && !isCancelling;
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogContent
className="sm:max-w-md"
onPointerDownOutside={(e) => {
// Prevent closing by clicking outside during operation
if (!internalComplete && !hasError) {
e.preventDefault();
}
}}
onEscapeKeyDown={(e) => {
// Prevent closing with Escape during operation
if (!internalComplete && !hasError) {
e.preventDefault();
}
}}
>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{getIcon()}
{title}
</DialogTitle>
<DialogDescription>
{description}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Progress Bar */}
<div className="space-y-2">
<Progress
value={progress}
className={cn(
"w-full transition-all duration-200",
hasError && "opacity-75",
isCancelling && "opacity-60",
internalComplete && "[&>div]:bg-green-500"
)}
/>
<div className="flex justify-between text-sm text-muted-foreground">
<span>{Math.round(progress)}%</span>
{totalCount !== undefined && completedCount !== undefined && (
<span>{completedCount}/{totalCount}</span>
)}
</div>
</div>
{/* Current Status */}
<div className="space-y-1">
<div className={cn(
"text-sm font-medium",
hasError && "text-red-600",
isCancelling && "text-orange-600",
internalComplete && "text-green-600"
)}>
{getStatusLabel()}
</div>
</div>
{/* Error Message */}
{hasError && errorMessage && (
<div className="p-3 bg-red-50 border border-red-200 rounded-md">
<div className="text-sm text-red-800">
{errorMessage}
</div>
</div>
)}
{/* Success Message */}
{internalComplete && !hasError && (
<div className="p-3 bg-green-50 border border-green-200 rounded-md">
<div className="text-sm text-green-800">
Operation completed successfully!
</div>
</div>
)}
{/* Action Buttons */}
<div className="flex justify-between">
<div>
{/* Cancel Button */}
{canCancel && (
<Button
variant="outline"
onClick={handleCancel}
disabled={isCancelling}
className="text-muted-foreground hover:text-destructive hover:border-destructive"
>
{isCancelling ? 'Cancelling...' : 'Cancel'}
</Button>
)}
{isCancelling && (
<div className="text-sm text-orange-600">
Cancelling operation...
</div>
)}
</div>
<div>
{/* Close Button - only show when complete or error */}
{(internalComplete || hasError) && (
<Button
variant="outline"
onClick={onClose}
>
Close
</Button>
)}
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
};
export default ProgressModal;