- 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>
343 lines
9.9 KiB
TypeScript
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;
|