refactored file uploader and bug fixes

This commit is contained in:
michael 2025-08-13 11:32:56 -05:00
parent 8a5c50cacb
commit 3e73259ff3
17 changed files with 1279 additions and 1048 deletions

View file

@ -563,26 +563,30 @@ def advance_moderator_discussion(focus_group_id):
activates_visual_context = True
print(f"🎨 ADVANCE DISCUSSION: Will activate visual context for asset: {asset_filename}")
# Create visual asset metadata for frontend display
visual_asset_metadata = None
if activates_visual_context and attached_assets and len(attached_assets) > 0:
# Create visual asset metadata that frontend expects
visual_asset_metadata = {
"filename": attached_assets[0], # Use first asset
"displayReference": display_reference or attached_assets[0] # Use display reference or filename as fallback
}
message_data = {
"text": result["moderator_response"],
"type": "question",
"senderId": "moderator",
"attached_assets": attached_assets,
"activates_visual_context": activates_visual_context
"activates_visual_context": activates_visual_context,
"visual_asset": visual_asset_metadata # Frontend needs this for image display
}
message_id = FocusGroup.add_message(focus_group_id, message_data)
# Activate visual assets if needed
# Visual context activation is handled automatically by FocusGroup.add_message()
# when activates_visual_context=True and attached_assets are present
if activates_visual_context and attached_assets:
try:
success = FocusGroup._activate_visual_assets(focus_group_id, attached_assets, message_id)
if success:
print(f"✅ ADVANCE DISCUSSION: Visual context activated for {attached_assets}")
else:
print(f"⚠️ ADVANCE DISCUSSION: Failed to activate visual context for {attached_assets}")
except Exception as activation_error:
print(f"⚠️ ADVANCE DISCUSSION: Error activating visual context: {activation_error}")
print(f"✅ ADVANCE DISCUSSION: Visual context activated for {attached_assets}")
if message_id:
result["message_id"] = message_id
@ -838,13 +842,8 @@ def stop_autonomous_conversation(focus_group_id):
current_app.logger.info(f"Signaled autonomous conversation to stop for focus group {focus_group_id}: {reason}")
# Log the manual mode start event (AI mode stopped)
try:
user_id = get_jwt_identity() # Get user ID if available
mode_event_id = FocusGroup.add_mode_event(focus_group_id, 'manual_mode_started', user_id)
current_app.logger.info(f"Logged manual mode start event: {mode_event_id}")
except Exception as e:
current_app.logger.warning(f"Failed to log manual mode start event: {e}")
# Mode events are now handled by AIModeratorService.end_session_with_concluding_statement()
# to prevent duplicate mode event indicators
# Return immediately with a success response like start_autonomous_conversation
result = {

View file

@ -722,23 +722,17 @@ class AIModeratorService:
'status': 'completed'
})
# Add mode event for AI-driven session conclusions
# This includes auto_complete, natural_completion, discussion_guide_completed, etc.
ai_driven_reasons = [
'auto_complete', 'natural_completion', 'discussion_guide_completed',
'action_limit_reached', 'time_limit'
]
# Add mode event for all AI session conclusions
# This includes auto_complete, natural_completion, discussion_guide_completed, manual_stop, etc.
mode_event_id = FocusGroup.add_mode_event(
focus_group_id=focus_group_id,
event_type='ai_session_concluded'
)
if reason in ai_driven_reasons:
mode_event_id = FocusGroup.add_mode_event(
focus_group_id=focus_group_id,
event_type='ai_session_concluded'
)
if mode_event_id:
print(f"🎯 Added AI session concluded mode event for focus group {focus_group_id} (reason: {reason})")
else:
print(f"Warning: Failed to add AI session concluded mode event for focus group {focus_group_id} (reason: {reason})")
if mode_event_id:
print(f"🎯 Added AI session concluded mode event for focus group {focus_group_id} (reason: {reason})")
else:
print(f"Warning: Failed to add AI session concluded mode event for focus group {focus_group_id} (reason: {reason})")
print(f"🎬 Session ended for focus group {focus_group_id} with reason: {reason}")

View file

@ -156,14 +156,8 @@ class AutonomousConversationController:
# GPT-5 fix: Emit AI status update to notify frontend of completion
# The FocusGroup.update() will trigger the websocket event automatically
# Log the mode change event for automatic completion
completion_events = ['completed', 'discussion_guide_completed', 'natural_completion']
if reason in completion_events:
mode_event_id = FocusGroup.add_mode_event(self.focus_group_id, 'ai_session_concluded', None)
self.logger.info(f"Logged AI session conclusion event: {mode_event_id}")
else:
mode_event_id = FocusGroup.add_mode_event(self.focus_group_id, 'manual_mode_started', None)
self.logger.info(f"Logged manual mode start event: {mode_event_id}")
# Mode events are now handled by AIModeratorService.end_session_with_concluding_statement()
# to prevent duplicate mode event indicators
self.logger.info(f"Stopped autonomous conversation for focus group {self.focus_group_id}: {reason}")
@ -782,22 +776,21 @@ class AutonomousConversationController:
async def _add_moderator_message(self, content: str, message_type: str) -> Optional[str]:
"""Add a moderator message to the conversation."""
try:
# Check if this message corresponds to a creative review activity
# Initialize image detection variables
attached_assets = []
activates_visual_context = False
display_reference = None
# Check if we're currently at a creative review activity in the discussion guide
# Check if current discussion guide item has image attachments
try:
from app.services.ai_moderator_service import AIModeratorService
from app.services.focus_group_response_service import extract_asset_filename_from_content
print(f"🔍 MODERATOR MESSAGE DEBUG: Checking for creative review activity")
print(f"🔍 Message content: {content[:100]}...")
print(f"🔍 Checking current discussion guide item for image attachments")
moderator_status = AIModeratorService.get_moderator_status(self.focus_group_id)
print(f"🔍 Moderator status: {moderator_status}")
current_item = None
if moderator_status and 'moderator_position' in moderator_status:
focus_group = FocusGroup.find_by_id(self.focus_group_id)
if focus_group:
@ -810,12 +803,8 @@ class AutonomousConversationController:
item_type = pos.get('item_type', 'activity')
subsection_idx = pos.get('subsection_index')
print(f"🔍 Position: section_idx={section_idx}, item_idx={item_idx}, item_type={item_type}, subsection_idx={subsection_idx}")
print(f"🔍 Total sections: {len(sections)}")
if section_idx < len(sections):
section = sections[section_idx]
print(f"🔍 Section found: {section.get('title', 'No title')}")
# Get current item from subsection or section
if subsection_idx is not None and section.get('subsections'):
@ -831,39 +820,22 @@ class AutonomousConversationController:
if item_idx < len(items):
current_item = items[item_idx]
# Check if item has an image attached (any item type)
print(f"🔍 Item to check: {current_item}")
# Check if current item has visual asset metadata
if current_item:
print(f"🔍 Item type: {current_item.get('type')}")
# Try to get asset info from metadata (new metadata-driven approach)
asset_filename = None
display_reference = None
print(f"🔍 Current item: {current_item.get('content', '')[:50]}...")
metadata = current_item.get('metadata', {})
visual_asset = metadata.get('visual_asset')
if visual_asset:
# Use metadata (preferred method)
# Found image in metadata - use it
asset_filename = visual_asset.get('filename')
display_reference = visual_asset.get('display_reference')
print(f"🔍 Found asset metadata: {display_reference} -> {asset_filename}")
else:
# Fallback to content parsing (legacy support)
activity_content = current_item.get('content', '')
asset_filename = extract_asset_filename_from_content(activity_content)
print(f"🔍 Legacy asset filename extraction: {asset_filename}")
if asset_filename:
print(f"🔍 Item with image found! Type: {current_item.get('type')}")
print(f"🔍 Asset: {display_reference or 'legacy'} -> {asset_filename}")
print(f"🎨 Found image in current item metadata: {display_reference} -> {asset_filename}")
attached_assets = [asset_filename]
activates_visual_context = True
self.logger.info(f"🎨 Detected creative review activity - will activate visual asset: {asset_filename}")
print(f"🎨 MODERATOR MESSAGE: Activating visual context for asset: {asset_filename}")
print(f"🎨 Activity content: {activity_content}")
print(f"🎨 Original moderator message: {content}")
# Generate AI description and enhance the content
try:
@ -887,28 +859,47 @@ class AutonomousConversationController:
content = enhanced_content
print(f"✅ AI MODE: Enhanced moderator message with image description")
print(f"🔍 Enhanced message: {content}")
except ImageDescriptionError as desc_error:
print(f"⚠️ AI MODE: Failed to generate image description: {desc_error}")
self.logger.warning(f"Failed to generate image description in autonomous mode: {desc_error}")
# Continue with original content
else:
self.logger.warning(f"Creative review activity found but no asset filename detected in content: {activity_content}")
# No visual asset metadata, try legacy content parsing
activity_content = current_item.get('content', '')
asset_filename = extract_asset_filename_from_content(activity_content)
if asset_filename:
print(f"🔍 Legacy: Found asset filename in content: {asset_filename}")
attached_assets = [asset_filename]
activates_visual_context = True
else:
print(f"🔍 No image attachments found for current item")
else:
print(f"🔍 No current discussion guide item found")
except Exception as e:
self.logger.warning(f"Error checking for creative review activity: {e}")
print(f"⚠️ Error checking creative review: {e}")
print(f"🔍 FINAL RESULT: attached_assets={attached_assets}, activates_visual_context={activates_visual_context}")
# Create visual asset metadata for frontend display
visual_asset_metadata = None
if activates_visual_context and attached_assets and len(attached_assets) > 0:
# Create visual asset metadata that frontend expects
visual_asset_metadata = {
"filename": attached_assets[0], # Use first asset
"displayReference": display_reference or attached_assets[0] # Use display reference or filename as fallback
}
# Create message data with visual context information
message_data = {
"text": content,
"type": message_type,
"senderId": "moderator",
"attached_assets": attached_assets,
"activates_visual_context": activates_visual_context
"activates_visual_context": activates_visual_context,
"visual_asset": visual_asset_metadata # Frontend needs this for image display
}
message_id = FocusGroup.add_message(self.focus_group_id, message_data)
@ -916,19 +907,11 @@ class AutonomousConversationController:
# GPT-5 fix: Yield after database write to flush WebSocket events
await self._yield_to_eventlet()
# Visual context activation is handled automatically by FocusGroup.add_message()
# when activates_visual_context=True and attached_assets are present
if activates_visual_context and attached_assets:
# Actually activate the visual assets in the focus group for LLM context
try:
success = FocusGroup._activate_visual_assets(self.focus_group_id, attached_assets, message_id)
if success:
self.logger.info(f"✅ Added moderator message with visual context activation: {attached_assets}")
print(f"✅ VISUAL CONTEXT ACTIVATED: {attached_assets}")
else:
self.logger.warning(f"⚠️ Failed to activate visual context for: {attached_assets}")
print(f"⚠️ Failed to activate visual context for: {attached_assets}")
except Exception as activation_error:
self.logger.error(f"⚠️ Error activating visual context: {activation_error}")
print(f"⚠️ Error activating visual context: {activation_error}")
self.logger.info(f"✅ Added moderator message with visual context activation: {attached_assets}")
print(f"✅ VISUAL CONTEXT ACTIVATED: {attached_assets}")
return message_id

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

715
dist/assets/index-KiCc6bNq.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/index-VgChhb1B.css vendored Normal file

File diff suppressed because one or more lines are too long

4
dist/index.html vendored
View file

@ -7,8 +7,8 @@
<meta name="description" content="Lovable Generated Project" />
<meta name="author" content="Lovable" />
<meta property="og:image" content="/og-image.png" />
<script type="module" crossorigin src="/semblance/assets/index-BgDz3VL9.js"></script>
<link rel="stylesheet" crossorigin href="/semblance/assets/index-BLNu9bos.css">
<script type="module" crossorigin src="/semblance/assets/index-KiCc6bNq.js"></script>
<link rel="stylesheet" crossorigin href="/semblance/assets/index-VgChhb1B.css">
</head>
<body>

View file

@ -1,9 +1,11 @@
import { useState, useEffect } from 'react';
import { Upload, UploadCloud, X, FileText, Image as ImageIcon, FileVideo, Loader2, RefreshCw, Edit3, Check } from 'lucide-react';
import { useState, useEffect, useRef } from 'react';
import { Upload, UploadCloud, X, FileText, Image as ImageIcon, FileVideo, Loader2, RefreshCw, Edit3, Check, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Progress } from "@/components/ui/progress";
import { Badge } from "@/components/ui/badge";
import { focusGroupsApi } from '@/lib/api';
// Backend asset interface (matches what we get from the API)
@ -24,35 +26,45 @@ interface LocalAsset {
status: 'uploading' | 'uploaded' | 'failed' | 'retry';
backendAsset?: BackendAsset;
error?: string;
progress?: number;
}
interface AssetUploaderProps {
onAssetsChange?: (assets: BackendAsset[]) => void;
onUploadComplete?: (assets: BackendAsset[]) => void;
onUploadError?: (error: unknown) => void;
onFilesChange?: (files: File[]) => void; // for non-backend file tracking
focusGroupId?: string;
disabled?: boolean;
maxAssets?: number;
allowedTypes?: string[];
label?: string;
description?: string;
maxFileSize?: number; // in MB
enableRenaming?: boolean; // whether to show file renaming functionality
}
export default function AssetUploader({
onAssetsChange,
onUploadComplete,
onUploadError,
onFilesChange,
focusGroupId,
disabled = false,
maxAssets = 10,
allowedTypes = ['image/*', 'application/pdf', 'video/*'],
label = 'Upload Assets',
description = 'Upload creative assets for testing'
description = 'Upload creative assets for testing',
maxFileSize = 10, // 10MB default
enableRenaming = true
}: AssetUploaderProps) {
const [localAssets, setLocalAssets] = useState<LocalAsset[]>([]);
const [backendAssets, setBackendAssets] = useState<BackendAsset[]>([]);
const [editingAsset, setEditingAsset] = useState<string | null>(null);
const [editingName, setEditingName] = useState<string>('');
const [isDragOver, setIsDragOver] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const dropzoneRef = useRef<HTMLDivElement>(null);
// Fetch existing backend assets when focusGroupId changes
useEffect(() => {
@ -61,6 +73,14 @@ export default function AssetUploader({
}
}, [focusGroupId]); // fetchBackendAssets is stable and doesn't need to be in deps
// Notify parent component of file changes for non-backend usage
useEffect(() => {
if (onFilesChange) {
const currentFiles = localAssets.map(asset => asset.file);
onFilesChange(currentFiles);
}
}, [localAssets, onFilesChange]);
const fetchBackendAssets = async () => {
if (!focusGroupId) return;
@ -78,8 +98,47 @@ export default function AssetUploader({
}
};
// Validate file type and size
const validateFile = (file: File): string | null => {
// Check file type
const isValidType = allowedTypes.some(type => {
if (type.includes('*')) {
const baseType = type.split('/')[0];
return file.type.startsWith(baseType + '/');
}
return file.type === type;
});
if (!isValidType) {
return `File "${file.name}" is not a supported file type. Supported types: ${getFileTypeNames().join(', ')}.`;
}
// Check file size (convert MB to bytes)
const maxSizeInBytes = maxFileSize * 1024 * 1024;
if (file.size > maxSizeInBytes) {
return `File "${file.name}" is too large. Maximum file size: ${maxFileSize}MB.`;
}
return null;
};
// Get friendly file type names
const getFileTypeNames = () => {
const typeMap: { [key: string]: string } = {
'image/*': 'Images',
'application/pdf': 'PDF',
'video/*': 'Videos',
'text/*': 'Text files',
'application/msword': 'Word docs',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'Word docs',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'Excel files'
};
return allowedTypes.map(type => typeMap[type] || type).filter((v, i, a) => a.indexOf(v) === i);
};
const handleFileUpload = async (files: FileList | null) => {
if (!files || files.length === 0 || !focusGroupId) return;
if (!files || files.length === 0) return;
// Check if adding these files would exceed the limit
const totalAssets = localAssets.length + backendAssets.length;
@ -88,8 +147,29 @@ export default function AssetUploader({
return;
}
// Create local asset objects for immediate UI feedback
const newLocalAssets: LocalAsset[] = Array.from(files).map(file => {
// Validate each file
const validFiles: File[] = [];
const errors: string[] = [];
Array.from(files).forEach(file => {
const validationError = validateFile(file);
if (validationError) {
errors.push(validationError);
} else {
validFiles.push(file);
}
});
// Show validation errors
if (errors.length > 0) {
errors.forEach(error => toast.error(error));
if (validFiles.length === 0) {
return;
}
}
// Create local asset objects for immediate UI feedback (only for valid files)
const newLocalAssets: LocalAsset[] = validFiles.map(file => {
const previewUrl = file.type.startsWith('image/')
? URL.createObjectURL(file)
: undefined;
@ -98,29 +178,50 @@ export default function AssetUploader({
id: `local-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
file,
previewUrl,
status: 'uploading'
status: 'uploading',
progress: 0
};
});
// Add to local state for immediate UI feedback
setLocalAssets(prev => [...prev, ...newLocalAssets]);
// Upload each file to backend
// Upload each file to backend (only if focusGroupId exists for backend uploads)
for (const localAsset of newLocalAssets) {
if (!focusGroupId) {
// For generic file upload without backend (like persona creation), just mark as complete
setLocalAssets(prev => prev.map(asset =>
asset.id === localAsset.id
? { ...asset, status: 'uploaded', progress: 100 }
: asset
));
continue;
}
// Simulate progress for better UX
const updateProgress = (progress: number) => {
setLocalAssets(prev => prev.map(asset =>
asset.id === localAsset.id
? { ...asset, progress }
: asset
));
};
try {
updateProgress(10);
const formData = new FormData();
formData.append('assets', localAsset.file);
updateProgress(50);
const uploadResponse = await focusGroupsApi.uploadAssets(focusGroupId, formData, false);
const uploadResult = uploadResponse.data;
if (uploadResult.uploaded_assets > 0) {
// Update local asset status
setLocalAssets(prev => prev.map(asset =>
asset.id === localAsset.id
? { ...asset, status: 'uploaded' }
: asset
));
updateProgress(100);
// Remove local asset from the list since it's now a backend asset
setTimeout(() => {
setLocalAssets(prev => prev.filter(asset => asset.id !== localAsset.id));
}, 500); // Small delay to show 100% progress
// Fetch updated backend assets
await fetchBackendAssets();
@ -138,6 +239,7 @@ export default function AssetUploader({
? {
...asset,
status: 'failed',
progress: 0,
error: (error as { response?: { data?: { error?: string } } }).response?.data?.error || 'Upload failed'
}
: asset
@ -179,6 +281,72 @@ export default function AssetUploader({
setLocalAssets(prev => prev.filter(asset => asset.id !== assetId));
};
// Handle drag and drop events
const handleDragEnter = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
// Only set drag over to false if we're leaving the dropzone entirely
if (!dropzoneRef.current?.contains(e.relatedTarget as Node)) {
setIsDragOver(false);
}
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
if (disabled) return;
const files = e.dataTransfer.files;
if (files.length > 0) {
handleFileUpload(files);
}
};
// Clear all files functionality
const handleClearAll = () => {
if (localAssets.length === 0 && backendAssets.length === 0) return;
// Clean up preview URLs
localAssets.forEach(asset => {
if (asset.previewUrl) {
URL.revokeObjectURL(asset.previewUrl);
}
});
setLocalAssets([]);
// Remove backend assets if focusGroupId exists
if (focusGroupId && backendAssets.length > 0) {
Promise.all(backendAssets.map(asset =>
focusGroupsApi.deleteAsset(focusGroupId, asset.filename).catch(console.error)
)).then(() => {
setBackendAssets([]);
if (onAssetsChange) {
onAssetsChange([]);
}
toast.info('All assets cleared');
});
} else {
setBackendAssets([]);
if (onAssetsChange) {
onAssetsChange([]);
}
}
};
const handleRetryUpload = async (localAsset: LocalAsset) => {
if (!focusGroupId) return;
@ -186,7 +354,7 @@ export default function AssetUploader({
// Update status to uploading
setLocalAssets(prev => prev.map(asset =>
asset.id === localAsset.id
? { ...asset, status: 'uploading', error: undefined }
? { ...asset, status: 'uploading', error: undefined, progress: 0 }
: asset
));
@ -194,16 +362,28 @@ export default function AssetUploader({
const formData = new FormData();
formData.append('assets', localAsset.file);
// Update progress during retry
setLocalAssets(prev => prev.map(asset =>
asset.id === localAsset.id
? { ...asset, progress: 50 }
: asset
));
const uploadResponse = await focusGroupsApi.uploadAssets(focusGroupId, formData, false);
const uploadResult = uploadResponse.data;
if (uploadResult.uploaded_assets > 0) {
setLocalAssets(prev => prev.map(asset =>
asset.id === localAsset.id
? { ...asset, status: 'uploaded' }
? { ...asset, progress: 100 }
: asset
));
// Remove local asset from the list since it's now a backend asset
setTimeout(() => {
setLocalAssets(prev => prev.filter(asset => asset.id !== localAsset.id));
}, 500);
await fetchBackendAssets();
toast.success(`${localAsset.file.name} uploaded successfully`);
} else {
@ -215,6 +395,7 @@ export default function AssetUploader({
? {
...asset,
status: 'failed',
progress: 0,
error: (error as { response?: { data?: { error?: string } } }).response?.data?.error || 'Upload failed'
}
: asset
@ -283,79 +464,150 @@ export default function AssetUploader({
const remainingSlots = maxAssets - totalAssets;
return (
<div className="space-y-4">
{/* Upload area */}
<div
className={`border-2 border-dashed rounded-lg p-6 flex flex-col items-center justify-center transition ${
disabled
? 'border-slate-100 bg-slate-25 cursor-not-allowed'
: 'border-slate-200 bg-slate-50 hover:bg-slate-100 cursor-pointer'
}`}
>
{disabled ? (
<>
<UploadCloud className="h-10 w-10 text-slate-300 mb-2" />
<p className="text-sm text-slate-400 mb-1">Asset Upload Disabled</p>
<p className="text-xs text-slate-400 mb-3">Complete focus group details above to enable asset uploads</p>
</>
) : (
<>
<UploadCloud className="h-10 w-10 text-slate-400 mb-2" />
<p className="text-sm text-slate-600 mb-1">{label}</p>
<p className="text-xs text-slate-500 mb-3">{description}</p>
<input
type="file"
accept={allowedTypes.join(',')}
multiple
onChange={(e) => handleFileUpload(e.target.files)}
className="hidden"
id="asset-uploader-input"
disabled={disabled}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => document.getElementById('asset-uploader-input')?.click()}
disabled={disabled || remainingSlots <= 0}
>
<Upload className="mr-2 h-4 w-4" />
Select Files
</Button>
<p className="text-xs text-slate-500 mt-2">
{remainingSlots} of {maxAssets} uploads remaining
</p>
</>
)}
<div className="space-y-6">
{/* Enhanced Upload Dropzone */}
<div className="space-y-4">
<div className="text-center">
<h3 className="text-lg font-semibold text-foreground">{label}</h3>
<p className="text-muted-foreground mt-1">{description}</p>
</div>
<div
ref={dropzoneRef}
className={`relative border-2 border-dashed rounded-xl p-8 transition-all duration-200 ${
disabled
? 'border-muted-foreground/20 bg-muted/30 cursor-not-allowed'
: isDragOver
? 'border-primary bg-primary/5 scale-[1.02]'
: 'border-muted-foreground/30 bg-background hover:border-primary/50 hover:bg-muted/50 cursor-pointer'
}`}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
onClick={() => !disabled && fileInputRef.current?.click()}
>
<div className="flex flex-col items-center justify-center text-center">
<UploadCloud className={`h-12 w-12 mb-4 ${
disabled ? 'text-muted-foreground/40' :
isDragOver ? 'text-primary' : 'text-muted-foreground'
}`} />
{disabled ? (
<div>
<p className="text-lg font-medium text-muted-foreground mb-2">File Upload Disabled</p>
<p className="text-sm text-muted-foreground">Complete the required details above to enable file uploads</p>
</div>
) : (
<div>
<p className="text-lg font-medium text-foreground mb-2">
{isDragOver ? 'Drop files here' : 'Drag and drop files here, or click to browse'}
</p>
{/* File Type Pills */}
<div className="flex flex-wrap justify-center gap-2 mb-4">
{getFileTypeNames().map((fileType, index) => (
<Badge key={index} variant="secondary" className="text-xs">
{fileType}
</Badge>
))}
</div>
<p className="text-sm text-muted-foreground mb-4">
Maximum file size: {maxFileSize}MB
</p>
<input
ref={fileInputRef}
type="file"
accept={allowedTypes.join(',')}
multiple
onChange={(e) => handleFileUpload(e.target.files)}
className="hidden"
disabled={disabled}
/>
<div className="flex flex-col sm:flex-row items-center justify-center gap-3">
<Button
type="button"
variant="default"
size="sm"
disabled={disabled || remainingSlots <= 0}
onClick={(e) => {
e.stopPropagation();
fileInputRef.current?.click();
}}
>
<Upload className="mr-2 h-4 w-4" />
Select Files
</Button>
{(localAssets.length > 0 || backendAssets.length > 0) && (
<Button
type="button"
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleClearAll();
}}
>
<Trash2 className="mr-2 h-4 w-4" />
Clear All
</Button>
)}
</div>
<p className="text-xs text-muted-foreground mt-3">
{remainingSlots} of {maxAssets} uploads remaining
</p>
</div>
)}
</div>
</div>
</div>
{/* Assets preview */}
{/* Enhanced Assets Preview */}
{(backendAssets.length > 0 || localAssets.length > 0) && (
<Card className="p-4">
<h4 className="text-sm font-medium mb-3">
Uploaded Assets ({backendAssets.length + localAssets.filter(a => a.status === 'uploaded').length})
</h4>
<Card className="p-6">
<div className="flex items-center justify-between mb-4">
<h4 className="text-lg font-semibold text-foreground">
Uploaded Files ({localAssets.length + backendAssets.length})
</h4>
{(localAssets.length > 0 || backendAssets.length > 0) && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleClearAll}
className="text-muted-foreground hover:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
Clear All
</Button>
)}
</div>
<div className="space-y-3">
{/* Backend assets (successfully uploaded) */}
{backendAssets.map((asset, index) => (
<div key={asset.filename} className="flex items-center gap-4 p-3 border rounded-lg bg-white">
{/* Asset preview */}
<div className="w-12 h-12 bg-slate-100 rounded flex items-center justify-center flex-shrink-0">
<div key={asset.filename} className="flex items-center gap-4 p-4 border rounded-lg bg-card shadow-sm">
{/* File preview/icon */}
<div className="w-12 h-12 bg-muted/50 rounded-md flex items-center justify-center flex-shrink-0">
{asset.mime_type?.startsWith('image/') ? (
<img
src={focusGroupsApi.getAssetUrl(focusGroupId!, asset.filename)}
alt={getDisplayName(asset, index)}
className="max-h-full max-w-full object-contain rounded"
className="max-h-full max-w-full object-contain rounded-md"
/>
) : (
getAssetIcon(asset.mime_type)
)}
</div>
{/* Asset info and naming */}
{/* File info */}
<div className="flex-grow min-w-0">
{editingAsset === asset.filename ? (
{enableRenaming && editingAsset === asset.filename ? (
<div className="flex items-center gap-2">
<Input
value={editingName}
@ -365,7 +617,7 @@ export default function AssetUploader({
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault(); // Prevent form submission
e.preventDefault();
saveAssetName(asset.filename);
} else if (e.key === 'Escape') {
cancelEditingAssetName();
@ -395,37 +647,46 @@ export default function AssetUploader({
<p className="font-medium text-sm truncate">
{getDisplayName(asset, index)}
</p>
<Button
size="sm"
variant="ghost"
type="button"
onClick={() => startEditingAssetName(asset)}
className="h-6 w-6 p-0"
>
<Edit3 className="h-3 w-3" />
</Button>
{enableRenaming && (
<Button
size="sm"
variant="ghost"
type="button"
onClick={() => startEditingAssetName(asset)}
className="h-6 w-6 p-0"
>
<Edit3 className="h-3 w-3" />
</Button>
)}
</div>
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground truncate">
Original: {asset.original_name}
</p>
<Badge variant="outline" className="text-xs text-green-600 bg-green-50 border-green-200">
Complete
</Badge>
</div>
<p className="text-xs text-slate-500 truncate">
Original: {asset.original_name}
</p>
</div>
)}
</div>
{/* Actions and status */}
{/* Actions */}
<div className="flex items-center gap-2 flex-shrink-0">
<div className="text-right">
<div className="text-xs text-slate-500 mb-1">Will appear as:</div>
<div className="text-sm font-medium text-primary">
"{getDisplayName(asset, index)}"
{enableRenaming && (
<div className="text-right mr-2">
<div className="text-xs text-muted-foreground mb-1">Will appear as:</div>
<div className="text-sm font-medium text-primary">
"{getDisplayName(asset, index)}"
</div>
</div>
</div>
)}
<Button
size="sm"
variant="ghost"
type="button"
onClick={() => handleRemoveAsset(asset.filename)}
className="h-8 w-8 p-0 text-slate-400 hover:text-red-500"
className="h-8 w-8 p-0 text-muted-foreground hover:text-destructive"
>
<X className="h-4 w-4" />
</Button>
@ -433,43 +694,68 @@ export default function AssetUploader({
</div>
))}
{/* Local assets (uploading/failed) */}
{/* Local assets (uploading/failed/completed) */}
{localAssets.map((asset) => (
<div key={asset.id} className="flex items-center gap-4 p-3 border rounded-lg bg-slate-50">
{/* Asset preview */}
<div className="w-12 h-12 bg-slate-100 rounded flex items-center justify-center flex-shrink-0">
<div key={asset.id} className="flex items-center gap-4 p-4 border rounded-lg bg-muted/30">
{/* File preview/icon */}
<div className="w-12 h-12 bg-muted/50 rounded-md flex items-center justify-center flex-shrink-0">
{asset.previewUrl ? (
<img
src={asset.previewUrl}
alt={asset.file.name}
className="max-h-full max-w-full object-contain rounded"
className="max-h-full max-w-full object-contain rounded-md"
/>
) : (
getAssetIcon(asset.file.type)
)}
</div>
{/* Asset info */}
{/* File info */}
<div className="flex-grow min-w-0">
<p className="font-medium text-sm truncate">{asset.file.name}</p>
<p className="text-xs text-slate-500">
{(asset.file.size / 1024).toFixed(1)} KB
<p className="text-xs text-muted-foreground mb-2">
{(asset.file.size / 1024 / 1024).toFixed(2)} MB
</p>
{/* Progress bar for uploading files */}
{asset.status === 'uploading' && (
<div className="space-y-1">
<Progress value={asset.progress || 0} className="h-2" />
<div className="flex justify-between items-center text-xs text-muted-foreground">
<span>Uploading...</span>
<span>{asset.progress || 0}%</span>
</div>
</div>
)}
{/* Error message for failed files */}
{asset.error && (
<p className="text-xs text-red-500 truncate">{asset.error}</p>
<p className="text-xs text-destructive truncate mt-1">{asset.error}</p>
)}
</div>
{/* Status and actions */}
{/* Status badge and actions */}
<div className="flex items-center gap-2 flex-shrink-0">
{/* Status badge */}
{asset.status === 'uploading' && (
<div className="flex items-center gap-2 text-blue-600">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-xs">Uploading...</span>
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs text-blue-600 bg-blue-50 border-blue-200">
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
Uploading
</Badge>
</div>
)}
{asset.status === 'uploaded' && (
<Badge variant="outline" className="text-xs text-green-600 bg-green-50 border-green-200">
<Check className="h-3 w-3 mr-1" />
Complete
</Badge>
)}
{asset.status === 'failed' && (
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs text-destructive bg-destructive/10 border-destructive/20">
Failed
</Badge>
<Button
size="sm"
variant="outline"
@ -482,12 +768,14 @@ export default function AssetUploader({
</Button>
</div>
)}
{/* Remove button */}
<Button
size="sm"
variant="ghost"
type="button"
onClick={() => handleRemoveLocalAsset(asset.id)}
className="h-8 w-8 p-0 text-slate-400 hover:text-red-500"
className="h-8 w-8 p-0 text-muted-foreground hover:text-destructive"
>
<X className="h-4 w-4" />
</Button>
@ -497,9 +785,9 @@ export default function AssetUploader({
</div>
{/* Help text */}
{backendAssets.length > 0 && (
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
<p className="text-sm text-blue-800">
{enableRenaming && backendAssets.length > 0 && (
<div className="mt-6 p-4 bg-primary/5 border border-primary/20 rounded-lg">
<p className="text-sm text-foreground">
<strong>Asset Names:</strong> Click the edit icon to customize how assets will be referenced
in the discussion guide. Leave blank to use default numbering.
</p>

View file

@ -1545,9 +1545,11 @@ Controls how much time GPT-5 spends thinking before responding
setBackendAssets(assets);
}}
maxAssets={10}
allowedTypes={['image/*', 'application/pdf', 'video/*']}
label="Upload Creative Assets"
description="Upload images, mockups, or product designs for testing"
maxFileSize={10}
allowedTypes={['image/*', 'application/pdf', 'video/*', 'text/*', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document']}
label="Asset Upload"
description="Provide any files you wish the moderator to use in the focus group session. This could include mockups, designs, documents, or other materials for discussion."
enableRenaming={true}
/>
<p className="text-sm text-muted-foreground mt-2">
Upload visuals that you want feedback on during the session

View file

@ -6,6 +6,7 @@ import { z } from "zod";
import { Upload, Users, FileText, RefreshCw, CheckCircle2, UploadCloud, Lightbulb, X, ChevronDown, ChevronUp } from 'lucide-react';
import { toast } from 'sonner';
import { aiPersonasApi } from '@/lib/api';
import AssetUploader from '@/components/AssetUploader';
import { Button } from "@/components/ui/button";
import {
@ -51,6 +52,15 @@ export default function AIRecruiterForm({ onSubmit, isGenerating }: AIRecruiterF
const [suggestions, setSuggestions] = useState<{audience_brief: string[], research_objective: string[]}>({audience_brief: [], research_objective: []});
const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false);
const [enhancementError, setEnhancementError] = useState<string | null>(null);
const [uploadedFiles, setUploadedFiles] = useState<FileList | null>(null);
const [currentFiles, setCurrentFiles] = useState<File[]>([]);
// Helper function to convert File array to FileList
const createFileList = (files: File[]): FileList => {
const dt = new DataTransfer();
files.forEach(file => dt.items.add(file));
return dt.files;
};
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
@ -212,54 +222,25 @@ export default function AIRecruiterForm({ onSubmit, isGenerating }: AIRecruiterF
</div>
<div className="space-y-6">
<FormField
control={form.control}
name="dataFile"
render={({ field: { value, onChange, ...fieldProps } }) => (
<FormItem>
<FormLabel>Customer Data (Optional)</FormLabel>
<FormControl>
<div className="border-2 border-dashed border-slate-200 rounded-lg p-6 flex flex-col items-center justify-center bg-slate-50 hover:bg-slate-100 transition cursor-pointer">
<UploadCloud className="h-10 w-10 text-slate-400 mb-2" />
<p className="text-sm text-slate-600 mb-1">Upload customer data for more accurate personas</p>
<p className="text-xs text-slate-500 mb-3">Supports PDF, Office docs, images, and more</p>
<Input
{...fieldProps}
type="file"
multiple
accept=".pdf,.docx,.pptx,.xlsx,.html,.xml,.rtf,.pages,.key,.epub,.txt,.csv,.jpg,.jpeg,.png"
onChange={(e) => {
onChange(e.target.files);
}}
className="hidden"
id="data-file-input"
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => document.getElementById('data-file-input')?.click()}
>
<Upload className="mr-2 h-4 w-4" />
Select Files
</Button>
{value && value.length > 0 && (
<p className="text-xs text-primary mt-2">
{value.length === 1
? value[0].name
: `${value.length} files selected`
}
</p>
)}
</div>
</FormControl>
<FormDescription>
Upload existing customer data to create more realistic personas
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-2">
<AssetUploader
focusGroupId={undefined} // No backend upload for persona creation
disabled={isGenerating}
maxAssets={5}
maxFileSize={10}
allowedTypes={['application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'text/*', 'image/*']}
label="Customer Data Upload"
description="Upload existing customer data to create more realistic and accurate synthetic personas. This helps the AI understand your target audience better."
enableRenaming={false}
onFilesChange={(files) => {
setCurrentFiles(files);
setUploadedFiles(files.length > 0 ? createFileList(files) : null);
}}
/>
<p className="text-xs text-muted-foreground">
Supports PDF, Word docs, Excel files, text files, and images
</p>
</div>
<div className="bg-muted/30 p-4 rounded-lg border border-border">
<div className="flex items-center mb-2">
@ -431,9 +412,17 @@ export default function AIRecruiterForm({ onSubmit, isGenerating }: AIRecruiterF
<div className="flex flex-col items-end">
<Button
type="submit"
type="button"
disabled={isGenerating}
className="min-w-36"
onClick={() => {
const formData = form.getValues();
// Replace the dataFile with uploadedFiles from AssetUploader
onSubmit({
...formData,
dataFile: uploadedFiles
});
}}
>
{isGenerating ? (
<>

View file

@ -57,8 +57,11 @@ export default function PersonaProfile() {
}
}, [navigationState.focusGroupId, navigationState.previousRoute]);
// Determine if we should show breadcrumbs
const showBreadcrumbs = navigationState.previousRoute?.startsWith('/focus-groups/') && navigationState.focusGroupId;
// Determine if we should show breadcrumbs - only if we have both a focus group route and ID
// This ensures we don't show stale breadcrumb data from previous sessions
const showBreadcrumbs = navigationState.previousRoute?.startsWith('/focus-groups/') &&
navigationState.focusGroupId &&
Object.keys(navigationState).length > 0;
// Handle persona profile export
const handleExportProfile = async () => {

View file

@ -627,6 +627,8 @@ export function usePersonaDetails() {
if (navigationState.previousRoute && navigationState.previousRoute.startsWith('/focus-groups/') && navigationState.focusGroupId) {
// Navigate back to the focus group session
navigate(`/focus-groups/${navigationState.focusGroupId}`);
// Clear navigation state after using it
clearNavigationState();
}
// Check if we came from focus group editing
else if (navigationState.previousRoute === '/focus-groups' && navigationState.focusGroupTab) {
@ -641,11 +643,13 @@ export function usePersonaDetails() {
// Fallback to focus groups page in create mode with participants tab
navigate('/focus-groups?mode=create&tab=participants');
}
// Clear navigation state after using it
clearNavigationState();
} else if (isFromReview) {
// Legacy behavior for review mode
navigate('/synthetic-users?mode=create&tab=ai&step=review');
} else {
// Default behavior - go to synthetic users page
// Default behavior - go to synthetic users page (coming from synthetic users list)
navigate('/synthetic-users');
}
};

View file

@ -563,7 +563,7 @@ const FocusGroupSession = () => {
id: msg._id || msg.id || `msg-${Date.now()}`,
senderId: msg.senderId,
text: msg.text,
timestamp: new Date(msg.timestamp || msg.created_at || new Date()),
timestamp: new Date(msg.timestamp || msg.created_at),
type: msg.type || 'response',
highlighted: msg.highlighted || false,
visualAsset: msg.visualAsset // Include visual asset metadata for image display
@ -583,9 +583,9 @@ const FocusGroupSession = () => {
id: event._id || event.id || `event-${Date.now()}`,
focus_group_id: event.focus_group_id,
event_type: event.event_type,
timestamp: new Date(event.timestamp || event.created_at || new Date()),
timestamp: new Date(event.timestamp || event.created_at),
user_id: event.user_id,
created_at: new Date(event.created_at || new Date())
created_at: new Date(event.created_at)
}));
// Always update mode events
@ -1106,30 +1106,13 @@ const FocusGroupSession = () => {
try {
const firstDiscussionItem = getFirstDiscussionItem(focusGroup?.discussionGuide);
// Create the initial moderator message
const initialMessage: Message = {
id: `msg-${Date.now()}`,
senderId: 'moderator',
text: firstDiscussionItem.content,
timestamp: new Date(),
type: 'question'
};
// Send the message to the API
// Send the message to the API - it will be added back via websocket/polling with server timestamp
const msgResponse = await focusGroupsApi.sendMessage(id, {
senderId: 'moderator',
text: initialMessage.text,
text: firstDiscussionItem.content,
type: 'question'
});
// Update message ID if provided by API
if (msgResponse?.data?.message_id) {
initialMessage.id = msgResponse.data.message_id;
}
// Add the message to the local state using the existing handler
handleNewMessage(initialMessage);
console.log('🚀 Initial moderator message created:', {
content: firstDiscussionItem.content,
sectionId: firstDiscussionItem.sectionId,
@ -1160,30 +1143,13 @@ const FocusGroupSession = () => {
try {
if (!id) return;
// Create a generic moderator message
const newMessage: Message = {
id: `msg-${Date.now()}`,
senderId: 'moderator',
text: 'Can you share specific examples that you think handle this particularly well?',
timestamp: new Date(),
type: 'question'
};
// Send to API
// Send to API - message will be added back via websocket/polling with server timestamp
const msgResponse = await focusGroupsApi.sendMessage(id, {
senderId: 'moderator',
text: newMessage.text,
text: 'Can you share specific examples that you think handle this particularly well?',
type: 'question'
});
// Update message ID if provided by API
if (msgResponse?.data?.message_id) {
newMessage.id = msgResponse.data.message_id;
}
// Update local state
setMessages(prev => [...prev, newMessage]);
toastService.info("Added moderator message", {
description: "You can now click 'Advance Discussion' to get AI-generated responses."
});
@ -1659,10 +1625,25 @@ const FocusGroupSession = () => {
const getMessageTimestamp = (): Date => {
const messageId = getMostRecentMessageId();
if (!messageId || messages.length === 0) return new Date();
const associatedMessage = messages.find(msg => msg.id === messageId);
return associatedMessage ? associatedMessage.timestamp : new Date();
// If we have messages, use the most recent message timestamp
if (messageId && messages.length > 0) {
const associatedMessage = messages.find(msg => msg.id === messageId);
if (associatedMessage) {
return associatedMessage.timestamp;
}
}
// Fallback: use focus group date, session start time, or current time as last resort
if (focusGroup?.date) {
return new Date(focusGroup.date);
}
if (sessionStartTime) {
return sessionStartTime;
}
return new Date();
};
// Theme generation functions
@ -2206,20 +2187,7 @@ const FocusGroupSession = () => {
}
}
// Create and send the moderator message
const moderatorMessage: Message = {
id: `msg-${Date.now()}`,
senderId: 'moderator',
text: enhancedMessageText, // Use enhanced text
timestamp: new Date(),
type: 'question',
visualAsset: hasImageAttached && visualAsset ? {
filename: assetFilename,
displayReference: visualAsset.display_reference
} : undefined
};
// Send to API first with visual asset information
// Send the moderator message to API - it will be added back via websocket/polling with server timestamp
console.log('📤 Sending moderator message to API:', {
text: enhancedMessageText,
attachedAssets,
@ -2229,7 +2197,7 @@ const FocusGroupSession = () => {
try {
const msgResponse = await focusGroupsApi.sendMessage(id, {
senderId: 'moderator',
text: enhancedMessageText, // Use enhanced text in API call too
text: enhancedMessageText,
type: 'question',
attached_assets: attachedAssets,
activates_visual_context: activatesVisualContext,
@ -2239,27 +2207,14 @@ const FocusGroupSession = () => {
} : undefined
});
if (msgResponse?.data?.message_id) {
moderatorMessage.id = msgResponse.data.message_id;
console.log('✅ Message API call successful, assigned ID:', moderatorMessage.id);
} else {
console.warn('⚠️ Message API call succeeded but no message_id returned:', msgResponse?.data);
}
console.log('✅ Message API call successful:', msgResponse?.data);
} catch (msgError) {
console.error('❌ Failed to save message to API:', msgError);
toastService.warning('Message display only', {
description: 'The moderator message is shown locally but may not be saved to the server.'
toastService.warning('Message not saved', {
description: 'Failed to save the moderator message to the server.'
});
}
// Add the message to the UI
console.log('📨 Adding moderator message to UI:', {
messageId: moderatorMessage.id,
text: moderatorMessage.text,
hasAssets: attachedAssets.length > 0
});
handleNewMessage(moderatorMessage);
// Close dialog first for immediate feedback
setSetPositionDialog({ isOpen: false });

View file

@ -1,6 +1,7 @@
import { useState, useEffect, useCallback } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useNavigation } from '@/contexts/NavigationContext';
import Navigation from '@/components/Navigation';
import AIRecruiter from '@/components/AIRecruiter';
import UserCreator from '@/components/UserCreator';
@ -77,6 +78,7 @@ const SyntheticUsers = () => {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { loadPersonas } = usePersonaStorage();
const { clearNavigationState } = useNavigation();
const [mode, setMode] = useState<'view' | 'create'>('view');
const [creationMode, setCreationMode] = useState<'manual' | 'ai'>('ai');
@ -141,6 +143,14 @@ const SyntheticUsers = () => {
setSummaryGenerationError(false);
};
// Handle navigation to persona details from synthetic users list
const handlePersonaClick = (persona: Persona) => {
// Clear any existing navigation state since we're coming from synthetic users
clearNavigationState();
// Navigate to persona details
navigate(`/synthetic-users/${persona._id || persona.id}`);
};
// Function to collect unique filter options from personas
const getFilterOptions = (personas: Persona[]) => {
const options = {
@ -800,10 +810,14 @@ const SyntheticUsers = () => {
(activeFilters.folderStatus.includes('hasFolder') && activeFilters.folderStatus.includes('noFolder')) ||
// If only "hasFolder" selected
(activeFilters.folderStatus.includes('hasFolder') && !activeFilters.folderStatus.includes('noFolder') &&
persona.folderId && persona.folderId !== DEFAULT_FOLDER_ID) ||
((persona.folder_ids && persona.folder_ids.length > 0) ||
(persona.folder_id && persona.folder_id !== DEFAULT_FOLDER_ID) ||
(persona.folderId && persona.folderId !== DEFAULT_FOLDER_ID))) ||
// If only "noFolder" selected
(activeFilters.folderStatus.includes('noFolder') && !activeFilters.folderStatus.includes('hasFolder') &&
(!persona.folderId || persona.folderId === DEFAULT_FOLDER_ID))
((!persona.folder_ids || persona.folder_ids.length === 0) &&
(!persona.folder_id || persona.folder_id === DEFAULT_FOLDER_ID) &&
(!persona.folderId || persona.folderId === DEFAULT_FOLDER_ID)))
);
// First check if the selected folder is "All Personas"
@ -1397,7 +1411,7 @@ const SyntheticUsers = () => {
<UserCard
user={persona}
selected={selectedPersonas.has(persona.id)}
onClick={() => navigate(`/synthetic-users/${persona._id || persona.id}`)}
onClick={() => handlePersonaClick(persona)}
onSelectionToggle={(e) => {
e.stopPropagation();
togglePersonaSelection(persona.id);