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:
parent
82f2906db5
commit
11fcfdc136
6 changed files with 470 additions and 115 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
343
src/components/ui/ProgressModal.tsx
Normal file
343
src/components/ui/ProgressModal.tsx
Normal 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;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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' ? (
|
||||
<>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue