From 03bb650ec6359beb17306de6da4739d22c21bcd3 Mon Sep 17 00:00:00 2001 From: michael Date: Mon, 1 Dec 2025 11:03:31 -0600 Subject: [PATCH] Replace inline progress bars with modal progress dialogs for better visibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/components/AIRecruiter.tsx | 55 +-- src/components/FocusGroupModerator.tsx | 39 +- .../persona/PersonaModificationModal.tsx | 42 ++- src/components/ui/ProgressModal.tsx | 343 ++++++++++++++++++ src/pages/FocusGroupSession.tsx | 62 ++-- src/pages/SyntheticUsers.tsx | 44 +-- 6 files changed, 470 insertions(+), 115 deletions(-) create mode 100644 src/components/ui/ProgressModal.tsx diff --git a/src/components/AIRecruiter.tsx b/src/components/AIRecruiter.tsx index edf636fa..39ffd0db 100644 --- a/src/components/AIRecruiter.tsx +++ b/src/components/AIRecruiter.tsx @@ -11,7 +11,7 @@ import { generateSyntheticPersonas } from '@/utils/personaGenerator'; import { usePersonaStorage, GENERATED_PERSONAS_KEY } from '@/hooks/usePersonaStorage'; import { useCancellableGeneration } from '@/hooks/useCancellableGeneration'; import { getSocket } from '@/services/websocketServiceNew'; -import GenerationProgressBar from '@/components/ui/GenerationProgressBar'; +import ProgressModal from '@/components/ui/ProgressModal'; import { Persona } from "@/types/persona"; interface AIRecruiterProps { @@ -32,6 +32,8 @@ export default function AIRecruiter({ targetFolderId, targetFolderName }: AIRecr const [selectedPersonas, setSelectedPersonas] = useState([]); const [showReview, setShowReview] = useState(false); const [generationToastId, setGenerationToastId] = useState(null); + const [isProgressModalOpen, setIsProgressModalOpen] = useState(false); + const [progressModalDescription, setProgressModalDescription] = useState(''); // Check URL params for state restoration useEffect(() => { @@ -63,7 +65,7 @@ export default function AIRecruiter({ targetFolderId, targetFolderName }: AIRecr try { // Start generation without task ID - will be set when real task ID arrives via WebSocket generationControls.startGeneration(); - + // Validate count before proceeding const count = parseInt(values.personaCount); if (isNaN(count) || count < 1 || count > 10) { @@ -73,14 +75,17 @@ export default function AIRecruiter({ targetFolderId, targetFolderName }: AIRecr generationControls.resetGeneration(); return; } - + // Adjust the expected time based on the count - const estimatedTime = count <= 2 ? "30-60 seconds" : - count <= 4 ? "1-2 minutes" : + const estimatedTime = count <= 2 ? "30-60 seconds" : + count <= 4 ? "1-2 minutes" : count <= 6 ? "2-3 minutes" : "3-5 minutes"; - - + + // Open progress modal with description + setProgressModalDescription(`Creating ${count} synthetic persona${count !== 1 ? 's' : ''} based on your criteria. Estimated time: ${estimatedTime}.`); + setIsProgressModalOpen(true); + const toastId = toast.info("Generating AI personas", { description: `Creating ${count} synthetic personas based on your brief. This may take ${estimatedTime}. Please be patient.`, duration: 10000 @@ -354,25 +359,23 @@ export default function AIRecruiter({ targetFolderId, targetFolderName }: AIRecr

AI Persona Recruiter

- {(generationState.isGenerating || generationState.isCancelling) && ( -
- { - console.log('🔥 Progress bar completed - resetting state'); - // This should trigger when progress bar finishes hiding - }} - /> -
- )} + {/* Progress Modal for Persona Generation */} + setIsProgressModalOpen(false)} + isActive={generationState.isGenerating} + isComplete={generationState.isComplete} + hasError={generationState.hasError} + isCancelling={generationState.isCancelling} + taskId={generationState.taskId} + title="Generating Personas" + description={progressModalDescription} + onCancel={generationControls.cancelGeneration} + onComplete={() => { + setIsProgressModalOpen(false); + generationControls.resetGeneration(); + }} + /> {!showReview ? ( (null); const [draftFocusGroupId, setDraftFocusGroupId] = useState(null); @@ -942,8 +943,9 @@ export default function FocusGroupModerator({ draftToEdit, onDraftSaved, preSele // Function to generate a discussion guide via the API const generateDiscussionGuide = async (values: z.infer, focusGroupId?: string): Promise => { - // Start cancellable generation + // Start cancellable generation and open progress modal guideGenerationControls.startGeneration(); + setIsGuideProgressModalOpen(true); try { // Prepare data for API request @@ -1017,6 +1019,7 @@ export default function FocusGroupModerator({ draftToEdit, onDraftSaved, preSele }; const handleGuideProgressComplete = () => { + setIsGuideProgressModalOpen(false); guideGenerationControls.resetGeneration(); }; @@ -1451,22 +1454,20 @@ true;

AI Focus Group Moderator

- {/* Progress Bar - Consistent top placement for discussion guide generation */} - {guideGenerationState.isGenerating && ( -
- -
- )} + {/* Progress Modal for Discussion Guide Generation */} + setIsGuideProgressModalOpen(false)} + isActive={guideGenerationState.isGenerating} + isComplete={guideGenerationState.isComplete} + hasError={guideGenerationState.hasError} + isCancelling={guideGenerationState.isCancelling} + taskId={guideGenerationState.taskId} + title="Generating Discussion Guide" + description="Creating your discussion guide based on the research objectives. This typically takes 30-60 seconds." + onCancel={guideGenerationControls.cancelGeneration} + onComplete={handleGuideProgressComplete} + /> diff --git a/src/components/persona/PersonaModificationModal.tsx b/src/components/persona/PersonaModificationModal.tsx index bff11558..b5e7e9c5 100644 --- a/src/components/persona/PersonaModificationModal.tsx +++ b/src/components/persona/PersonaModificationModal.tsx @@ -33,7 +33,7 @@ import { personasApi } from '@/lib/api'; import { toastService } from '@/lib/toast'; import { useCancellableGeneration } from '@/hooks/useCancellableGeneration'; import { getSocket } from '@/services/websocketServiceNew'; -import GenerationProgressBar from '@/components/ui/GenerationProgressBar'; +import ProgressModal from '@/components/ui/ProgressModal'; import { Persona } from '@/types/persona'; const modificationFormSchema = z.object({ @@ -65,6 +65,7 @@ export default function PersonaModificationModal({ // Cancellable generation for persona modification const socket = getSocket(); const [modificationState, modificationControls] = useCancellableGeneration('persona modification', socket); + const [isProgressModalOpen, setIsProgressModalOpen] = useState(false); const form = useForm({ resolver: zodResolver(modificationFormSchema), @@ -79,13 +80,20 @@ export default function PersonaModificationModal({ const handleClose = () => { if (modificationState.isGenerating) return; // Prevent closing while processing form.reset(); + setIsProgressModalOpen(false); modificationControls.resetGeneration(); onClose(); }; + const handleProgressComplete = () => { + setIsProgressModalOpen(false); + modificationControls.resetGeneration(); + }; + const onSubmit = async (values: ModificationFormData) => { modificationControls.startGeneration(); - + setIsProgressModalOpen(true); + try { toastService.info("Generating persona preview...", { description: `Using ${values.llm_model} to create a preview of your modifications` @@ -161,22 +169,20 @@ export default function PersonaModificationModal({ - {/* Progress Bar for persona modification */} - {modificationState.isGenerating && ( -
- -
- )} + {/* Progress Modal for persona modification */} + setIsProgressModalOpen(false)} + isActive={modificationState.isGenerating} + isComplete={modificationState.isComplete} + hasError={modificationState.hasError} + isCancelling={modificationState.isCancelling} + taskId={modificationState.taskId} + title="Modifying Persona" + description={`Applying AI modifications to ${persona.name}. This typically takes 20-40 seconds.`} + onCancel={modificationControls.cancelGeneration} + onComplete={handleProgressComplete} + />
diff --git a/src/components/ui/ProgressModal.tsx b/src/components/ui/ProgressModal.tsx new file mode 100644 index 00000000..7a25cf03 --- /dev/null +++ b/src/components/ui/ProgressModal.tsx @@ -0,0 +1,343 @@ +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 = ({ + 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('progressing'); + const [internalComplete, setInternalComplete] = useState(false); + + const timerRef = useRef(null); + const phaseTimerRef = useRef(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 ; + if (internalComplete) return ; + if (icon) return icon; + return ; + }; + + const canCancel = !internalComplete && !hasError && taskId && !taskId.startsWith('temp-') && !isCancelling; + + return ( + + { + // 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(); + } + }} + > + + + {getIcon()} + {title} + + + {description} + + + +
+ {/* Progress Bar */} +
+ div]:bg-green-500" + )} + /> +
+ {Math.round(progress)}% + {totalCount !== undefined && completedCount !== undefined && ( + {completedCount}/{totalCount} + )} +
+
+ + {/* Current Status */} +
+
+ {getStatusLabel()} +
+
+ + {/* Error Message */} + {hasError && errorMessage && ( +
+
+ {errorMessage} +
+
+ )} + + {/* Success Message */} + {internalComplete && !hasError && ( +
+
+ Operation completed successfully! +
+
+ )} + + {/* Action Buttons */} +
+
+ {/* Cancel Button */} + {canCancel && ( + + )} + {isCancelling && ( +
+ Cancelling operation... +
+ )} +
+ +
+ {/* Close Button - only show when complete or error */} + {(internalComplete || hasError) && ( + + )} +
+
+
+
+
+ ); +}; + +export default ProgressModal; diff --git a/src/pages/FocusGroupSession.tsx b/src/pages/FocusGroupSession.tsx index 0e3e454c..5433d639 100644 --- a/src/pages/FocusGroupSession.tsx +++ b/src/pages/FocusGroupSession.tsx @@ -33,7 +33,7 @@ import { Persona } from '@/types/persona'; import api, { focusGroupsApi, personasApi, focusGroupAiApi } from '@/lib/api'; import { useCancellableGeneration } from '@/hooks/useCancellableGeneration'; import { getSocket } from '@/services/websocketServiceNew'; -import GenerationProgressBar from '@/components/ui/GenerationProgressBar'; +import ProgressModal from '@/components/ui/ProgressModal'; // GPT-5 FIX: Use new singleton WebSocket service import { initSocket, joinFocusGroup, leaveFocusGroup } from '@/services/websocketServiceNew'; import { @@ -85,7 +85,8 @@ const FocusGroupSession = () => { // Cancellable generation for key themes const socket = getSocket(); const [themeGenerationState, themeGenerationControls] = useCancellableGeneration('key themes generation', socket); - + const [isThemeProgressModalOpen, setIsThemeProgressModalOpen] = useState(false); + // WebSocket status bar visibility const [isStatusBarVisible, setIsStatusBarVisible] = useState(true); @@ -1676,32 +1677,33 @@ const FocusGroupSession = () => { // Theme generation functions const generateKeyThemes = async () => { if (!id) return; - - // Reset states + + // Reset states and open progress modal themeGenerationControls.startGeneration(); - + setIsThemeProgressModalOpen(true); + toastService.info("Analyzing discussion for key themes...", { description: "This may take a moment as we process the entire conversation." }); - + try { const response = await focusGroupAiApi.generateKeyThemes(id); - + if (response.data && response.data.themes) { // Update themes state immediately setThemes(prevThemes => [...prevThemes, ...response.data.themes]); - - // Allow progress bar to animate for at least 3 seconds before completing + + // Allow progress to animate for at least 3 seconds before completing setTimeout(() => { - setThemeGenerationComplete(true); + themeGenerationControls.completeGeneration(); toastService.success(`Generated ${response.data.themes.length} key themes`, { description: "New themes have been added to the analysis." }); }, 3000); } else { - // Allow progress bar to animate for at least 3 seconds before completing + // Allow progress to animate for at least 3 seconds before completing setTimeout(() => { - setThemeGenerationComplete(true); + themeGenerationControls.completeGeneration(); toastService.warning("No new themes were generated", { description: "Try again when the discussion has more content." }); @@ -1709,15 +1711,16 @@ const FocusGroupSession = () => { } } catch (error) { console.error('Error generating key themes:', error); - setThemeGenerationError(true); + themeGenerationControls.failGeneration('Failed to generate key themes'); toastService.error("Failed to generate key themes", { description: "There was an error analyzing the discussion. Please try again." }); } - // Note: Don't set isThemeGenerating to false here - let the progress bar handle it + // Note: Don't reset generation here - let the progress modal handle it }; const handleThemeProgressComplete = () => { + setIsThemeProgressModalOpen(false); themeGenerationControls.resetGeneration(); }; @@ -1963,23 +1966,20 @@ const FocusGroupSession = () => { - {/* Progress Bar - Consistent top placement for theme generation */} - {themeGenerationState.isGenerating && ( -
- -
- )} + {/* Progress Modal for Key Themes Generation */} + setIsThemeProgressModalOpen(false)} + isActive={themeGenerationState.isGenerating} + isComplete={themeGenerationState.isComplete} + hasError={themeGenerationState.hasError} + isCancelling={themeGenerationState.isCancelling} + taskId={themeGenerationState.taskId} + title="Extracting Key Themes" + description="Analyzing the conversation to identify key themes and insights. This typically takes 30-60 seconds." + onCancel={themeGenerationControls.cancelGeneration} + onComplete={handleThemeProgressComplete} + /> {/* Collapsible Discussion Guide Panel */} { // WebSocket and cancellable generation for summary generation const socket = getSocket(); const [summaryGenerationState, summaryGenerationControls] = useCancellableGeneration('persona summary generation', socket); + const [isSummaryProgressModalOpen, setIsSummaryProgressModalOpen] = useState(false); + const [summaryProgressDescription, setSummaryProgressDescription] = useState(''); // Bulk export no longer needs cancellable generation - it's instant const [searchTerm, setSearchTerm] = useState(''); const [selectedUser, setSelectedUser] = useState(null); @@ -148,6 +150,7 @@ const SyntheticUsers = () => { // Handle summary generation progress completion const handleSummaryProgressComplete = () => { + setIsSummaryProgressModalOpen(false); summaryGenerationControls.resetGeneration(); }; @@ -973,11 +976,13 @@ const SyntheticUsers = () => { // Close modal setDownloadLlmModalOpen(false); - - // Start cancellable generation + + // Start cancellable generation and open progress modal summaryGenerationControls.startGeneration(); + setSummaryProgressDescription(`Generating AI-powered summaries for ${filteredPersonas.length} persona${filteredPersonas.length !== 1 ? 's' : ''}. This may take a few minutes.`); + setIsSummaryProgressModalOpen(true); setIsLoading(true); - + try { // Show initial toast with progress toastService.info("Generating persona summaries...", { @@ -1207,23 +1212,20 @@ const SyntheticUsers = () => { - {/* Progress Bar - Consistent top placement for all long-running operations */} - {mode === 'view' && filteredPersonas.length > 0 && summaryGenerationState.isGenerating && ( -
- -
- )} + {/* Progress Modal for Persona Summary Generation */} + setIsSummaryProgressModalOpen(false)} + isActive={summaryGenerationState.isGenerating} + isComplete={summaryGenerationState.isComplete} + hasError={summaryGenerationState.hasError} + isCancelling={summaryGenerationState.isCancelling} + taskId={summaryGenerationState.taskId} + title="Generating Persona Summaries" + description={summaryProgressDescription} + onCancel={summaryGenerationControls.cancelGeneration} + onComplete={handleSummaryProgressComplete} + /> {mode === 'view' ? ( <>