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>
This commit is contained in:
michael 2025-12-01 11:03:31 -06:00
parent 4d9b0afde7
commit 03bb650ec6
6 changed files with 470 additions and 115 deletions

View file

@ -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<string[]>([]);
const [showReview, setShowReview] = useState(false);
const [generationToastId, setGenerationToastId] = useState<string | null>(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
<h2 className="font-sf text-xl font-semibold">AI Persona Recruiter</h2>
</div>
{(generationState.isGenerating || generationState.isCancelling) && (
<div className="mb-6">
<GenerationProgressBar
isActive={generationState.isGenerating}
isComplete={generationState.isComplete}
hasError={generationState.hasError}
isCancelling={generationState.isCancelling}
label="Generating AI personas..."
onCancel={generationControls.cancelGeneration}
taskId={generationState.taskId}
showCancel={true}
className="max-w-4xl mx-auto"
onComplete={() => {
console.log('🔥 Progress bar completed - resetting state');
// This should trigger when progress bar finishes hiding
}}
/>
</div>
)}
{/* Progress Modal for Persona Generation */}
<ProgressModal
isOpen={isProgressModalOpen}
onClose={() => 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 ? (
<AIRecruiterForm

View file

@ -31,7 +31,7 @@ import { toast } from 'sonner';
import { personasApi, focusGroupsApi, foldersApi } 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';
import DiscussionGuideViewer from './focus-group-session/DiscussionGuideViewer';
import AssetUploader from '@/components/AssetUploader';
@ -151,7 +151,8 @@ export default function FocusGroupModerator({ draftToEdit, onDraftSaved, preSele
// Cancellable generation for discussion guide
const socket = getSocket();
const [guideGenerationState, guideGenerationControls] = useCancellableGeneration('discussion guide generation', socket);
const [isGuideProgressModalOpen, setIsGuideProgressModalOpen] = useState(false);
const [discussionGuide, setDiscussionGuide] = useState<string | any | null>(null);
const [draftFocusGroupId, setDraftFocusGroupId] = useState<string | null>(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<typeof formSchema>, focusGroupId?: string): Promise<string> => {
// 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;
<h2 className="font-sf text-xl font-semibold">AI Focus Group Moderator</h2>
</div>
{/* Progress Bar - Consistent top placement for discussion guide generation */}
{guideGenerationState.isGenerating && (
<div className="mb-6">
<GenerationProgressBar
isActive={guideGenerationState.isGenerating}
isComplete={guideGenerationState.isComplete}
hasError={guideGenerationState.hasError}
isCancelling={guideGenerationState.isCancelling}
label="Generating discussion guide"
onComplete={handleGuideProgressComplete}
onCancel={guideGenerationControls.cancelGeneration}
taskId={guideGenerationState.taskId}
showCancel={true}
/>
</div>
)}
{/* Progress Modal for Discussion Guide Generation */}
<ProgressModal
isOpen={isGuideProgressModalOpen}
onClose={() => 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}
/>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-3 mb-6">

View file

@ -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<ModificationFormData>({
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({
</DialogDescription>
</DialogHeader>
{/* Progress Bar for persona modification */}
{modificationState.isGenerating && (
<div className="mb-4">
<GenerationProgressBar
isActive={modificationState.isGenerating}
isComplete={modificationState.isComplete}
hasError={modificationState.hasError}
isCancelling={modificationState.isCancelling}
label="Modifying persona with AI..."
onCancel={modificationControls.cancelGeneration}
taskId={modificationState.taskId}
showCancel={true}
className="w-full"
/>
</div>
)}
{/* Progress Modal for persona modification */}
<ProgressModal
isOpen={isProgressModalOpen}
onClose={() => 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}
/>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">

View file

@ -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<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;

View file

@ -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 = () => {
</div>
</div>
{/* Progress Bar - Consistent top placement for theme generation */}
{themeGenerationState.isGenerating && (
<div className="mb-6">
<GenerationProgressBar
isActive={themeGenerationState.isGenerating}
isComplete={themeGenerationState.isComplete}
hasError={themeGenerationState.hasError}
isCancelling={themeGenerationState.isCancelling}
label="Analyzing discussion for key themes"
onComplete={handleThemeProgressComplete}
onCancel={themeGenerationControls.cancelGeneration}
taskId={themeGenerationState.taskId}
showCancel={true}
className="max-w-4xl mx-auto"
/>
</div>
)}
{/* Progress Modal for Key Themes Generation */}
<ProgressModal
isOpen={isThemeProgressModalOpen}
onClose={() => 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 */}
<CollapsibleDiscussionGuide

View file

@ -43,7 +43,7 @@ import { useCancellableGeneration } from '@/hooks/useCancellableGeneration';
import { getSocket } from '@/services/websocketServiceNew';
import { personasApi, aiPersonasApi, foldersApi } from '@/lib/api';
import { toastService } from '@/lib/toast';
import GenerationProgressBar from '@/components/ui/GenerationProgressBar';
import ProgressModal from '@/components/ui/ProgressModal';
import FolderTree from '@/components/FolderTree';
@ -91,6 +91,8 @@ const SyntheticUsers = () => {
// 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<Persona | null>(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 = () => {
</div>
</div>
{/* Progress Bar - Consistent top placement for all long-running operations */}
{mode === 'view' && filteredPersonas.length > 0 && summaryGenerationState.isGenerating && (
<div className="mb-6">
<GenerationProgressBar
isActive={summaryGenerationState.isGenerating}
isComplete={summaryGenerationState.isComplete}
hasError={summaryGenerationState.hasError}
isCancelling={summaryGenerationState.isCancelling}
label="Generating comprehensive persona summaries"
onComplete={handleSummaryProgressComplete}
onCancel={summaryGenerationControls.cancelGeneration}
taskId={summaryGenerationState.taskId}
showCancel={true}
className="max-w-4xl mx-auto"
/>
</div>
)}
{/* Progress Modal for Persona Summary Generation */}
<ProgressModal
isOpen={isSummaryProgressModalOpen}
onClose={() => 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' ? (
<>