semblance-dev/src/pages/SyntheticUsers.tsx
michael 03bb650ec6 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>
2025-12-01 11:03:31 -06:00

1994 lines
83 KiB
TypeScript

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';
import UserCard from '@/components/UserCard';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Search, Filter, Users, FolderPlus, Folder, MoreHorizontal, Plus, Check, X, Trash2, Download, MessageSquare } from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Persona } from '@/types/persona';
import { usePersonaStorage } from '@/hooks/usePersonaStorage';
import { useCancellableGeneration } from '@/hooks/useCancellableGeneration';
import { getSocket } from '@/services/websocketServiceNew';
import { personasApi, aiPersonasApi, foldersApi } from '@/lib/api';
import { toastService } from '@/lib/toast';
import ProgressModal from '@/components/ui/ProgressModal';
import FolderTree from '@/components/FolderTree';
interface Folder {
_id: string;
id?: string; // Legacy field for compatibility
name: string;
parent_folder_id?: string | null;
level: number;
created_by?: string;
created_at?: string;
updated_at?: string;
// Note: No longer storing persona_ids - using persona-centric storage
}
const DEFAULT_FOLDER_ID = 'all';
// Define filter state interface
interface FilterState {
age: string[];
gender: string[];
occupation: string[];
location: string[];
techSavviness: string[];
ethnicity: string[];
folderStatus: string[];
}
const SyntheticUsers = () => {
// Helper function ONLY to ensure the body is interactive - memoized with useCallback
const ensureBodyInteractive = useCallback(() => {
if (document.body.style.pointerEvents === 'none') {
console.log('ensureBodyInteractive: Fixing body pointer-events...'); // Optional log
document.body.style.pointerEvents = 'auto';
}
}, []); // Empty dependency array because it has no external dependencies
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { loadPersonas } = usePersonaStorage();
const { clearNavigationState, setPreviousRoute } = useNavigation();
const [mode, setMode] = useState<'view' | 'create'>('view');
const [creationMode, setCreationMode] = useState<'manual' | 'ai'>('ai');
// 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);
const [selectedFolder, setSelectedFolder] = useState<string>(DEFAULT_FOLDER_ID);
// Handle URL parameters to set mode and folder
useEffect(() => {
const modeParam = searchParams.get('mode');
if (modeParam === 'view' || modeParam === 'create') {
setMode(modeParam);
}
// Handle folder parameter to restore folder selection
const folderParam = searchParams.get('folder');
if (folderParam) {
setSelectedFolder(folderParam);
}
}, [searchParams]);
const [allPersonas, setAllPersonas] = useState<Persona[]>([]);
const [folders, setFolders] = useState<Folder[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [selectedPersonas, setSelectedPersonas] = useState<Set<string>>(new Set());
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [deleteFolderConfirmOpen, setDeleteFolderConfirmOpen] = useState(false);
const [folderToDelete, setFolderToDelete] = useState<Folder | null>(null);
const [moveToFolderOpen, setMoveToFolderOpen] = useState(false);
const [targetFolders, setTargetFolders] = useState<Set<string>>(new Set());
// Filter state
const [isFilterOpen, setIsFilterOpen] = useState(false);
const [activeFilters, setActiveFilters] = useState<FilterState>({
age: [],
gender: [],
occupation: [],
location: [],
techSavviness: [],
ethnicity: [],
folderStatus: [],
});
// Working copy of filter state for the dialog
const [workingFilters, setWorkingFilters] = useState<FilterState>({
age: [],
gender: [],
occupation: [],
location: [],
techSavviness: [],
ethnicity: [],
folderStatus: [],
});
// LLM selection for download
const [downloadLlmModalOpen, setDownloadLlmModalOpen] = useState(false);
const [selectedDownloadLlmModel, setSelectedDownloadLlmModel] = useState<string>('gemini-3-pro-preview');
// Bulk export no longer needs state - direct download
// Handle summary generation progress completion
const handleSummaryProgressComplete = () => {
setIsSummaryProgressModalOpen(false);
summaryGenerationControls.resetGeneration();
};
// 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}`);
};
// Handle navigation to persona details with folder context
const handlePersonaViewDetails = (persona: Persona) => {
// Set navigation context to remember which folder we came from
setPreviousRoute('/synthetic-users', {
folderId: selectedFolder !== DEFAULT_FOLDER_ID ? selectedFolder : undefined
});
// 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 = {
age: new Set<string>(),
gender: new Set<string>(),
occupation: new Set<string>(),
location: new Set<string>(),
techSavviness: new Set<string>(),
ethnicity: new Set<string>(),
};
personas.forEach(persona => {
// Add each attribute to the corresponding set
if (persona.age) options.age.add(persona.age);
if (persona.gender) options.gender.add(persona.gender);
if (persona.occupation) options.occupation.add(persona.occupation);
if (persona.location) options.location.add(persona.location);
// Map numeric ranges to descriptive labels
if (persona.techSavviness !== undefined) {
const techLevel =
persona.techSavviness < 30 ? 'Low (0-30)' :
persona.techSavviness < 70 ? 'Medium (31-70)' :
'High (71-100)';
options.techSavviness.add(techLevel);
}
// Convert boolean values to string labels
if (persona.ethnicity) options.ethnicity.add(persona.ethnicity);
});
// Convert sets to sorted arrays
return {
age: Array.from(options.age).sort(),
gender: Array.from(options.gender).sort(),
occupation: Array.from(options.occupation).sort(),
location: Array.from(options.location).sort(),
techSavviness: Array.from(options.techSavviness).sort((a, b) => {
const order = ['Low (0-30)', 'Medium (31-70)', 'High (71-100)'];
return order.indexOf(a) - order.indexOf(b);
}),
ethnicity: Array.from(options.ethnicity).sort(),
};
};
// Reset filters function
const resetFilters = () => {
setActiveFilters({
age: [],
gender: [],
occupation: [],
location: [],
techSavviness: [],
ethnicity: [],
folderStatus: [],
});
};
// Apply filters function
const applyFilters = () => {
// First close the dialog
setIsFilterOpen(false);
// Then apply the filters after stack clear
setTimeout(() => {
setActiveFilters({...workingFilters});
}, 0);
};
// Reset filters
const handleResetFilters = () => {
setWorkingFilters({
age: [],
gender: [],
occupation: [],
location: [],
techSavviness: [],
ethnicity: [],
folderStatus: [],
});
};
// Toggle a filter value
const toggleFilter = (category: keyof FilterState, value: string) => {
setWorkingFilters(prev => {
const newFilters = {...prev};
// If value is already selected, remove it
if (newFilters[category].includes(value)) {
newFilters[category] = newFilters[category].filter(v => v !== value);
} else {
// Otherwise add it
newFilters[category] = [...newFilters[category], value];
}
return newFilters;
});
};
// Function to fetch folders from API
const fetchFolders = async () => {
try {
const response = await foldersApi.getAll();
const serverFolders = response.data;
// Convert server folder format to match frontend expectations
const processedFolders: Folder[] = serverFolders.map((folder: any) => ({
...folder,
id: folder._id // Add legacy id field for compatibility
// Note: No longer processing persona_ids - using persona-centric storage
}));
setFolders(processedFolders);
return processedFolders;
} catch (error) {
console.error("Error fetching folders:", error);
toastService.error("Failed to load folders");
setFolders([]);
return [];
}
};
// Extract fetchPersonas into a separate function to be used in multiple places
const fetchPersonas = async () => {
const isMounted = true;
setIsLoading(true);
try {
// Fetch personas from API using the proper API client
const response = await personasApi.getAll();
// API client already handles response parsing
const data = response.data;
if (isMounted) {
// Process API data to ensure consistent format
const processedData = data.map((p: Persona) => ({
...p,
// Ensure id is set for compatibility
id: p.id || p._id,
}));
// We're now only showing personas from the database, no local storage
const combinedPersonas = [...processedData];
// Load stored personas asynchronously only for data migration purposes
// but we won't show them to the user anymore
try {
const loadStoredPersonas = async () => {
const storedPersonas = await loadPersonas();
console.log('Loaded stored personas (for debugging only):', storedPersonas ? storedPersonas.length : 0);
};
loadStoredPersonas();
} catch (e) {
console.warn('Error loading stored personas:', e);
}
// Not loading local personas anymore
setAllPersonas(combinedPersonas);
}
} catch (error) {
console.error("Error fetching personas:", error);
if (isMounted) {
toastService.error("Failed to load personas");
setAllPersonas([]);
}
} finally {
if (isMounted) {
setIsLoading(false);
}
}
};
// Note: Folder synchronization is now handled server-side
// Initial load effect
useEffect(() => {
// Use a flag to track if component is still mounted
let isMounted = true;
// Fetch both folders and personas from API
const fetchData = async () => {
try {
// Fetch folders and personas in parallel
const [, ] = await Promise.all([
fetchFolders(),
fetchPersonas()
]);
} catch (error) {
console.error("Error loading data:", error);
}
};
fetchData();
// Cleanup function to prevent memory leaks
return () => {
isMounted = false;
};
}, [ensureBodyInteractive]); // Add ensureBodyInteractive dependency
// Add an effect to refresh data when 'view' mode is activated
// This ensures personas are refreshed when navigating back from creation
useEffect(() => {
if (mode === 'view') {
fetchPersonas();
} else if (mode === 'create') {
// Log selected folder when switching to create mode
console.log(`Switching to create mode with folder: ${selectedFolder}, ${selectedFolder !== DEFAULT_FOLDER_ID ? 'NOT default' : 'IS default'}`);
if (selectedFolder !== DEFAULT_FOLDER_ID) {
const folderName = folders.find(f => f.id === selectedFolder)?.name;
console.log(`Selected folder for creation: ${selectedFolder} (${folderName})`);
}
}
}, [mode]);
// Note: Folder-persona relationships are now managed server-side
// Listen for navigation events to ensure data is refreshed when navigating to this page
useEffect(() => {
// This will run when the component mounts (user navigates to the page)
fetchPersonas();
// Create an event listener for route changes within the app
const handleRouteChange = () => {
// Check if we're on the synthetic users page
if (window.location.pathname.includes('/synthetic-users') &&
!window.location.pathname.includes('/synthetic-users/')) {
console.log('Navigation to synthetic users page detected, refreshing data');
fetchPersonas();
}
};
// Listen for the custom event from the Navigation component
const handleSyntheticUsersNavigation = () => {
console.log('Synthetic users navigation event detected, refreshing data');
fetchPersonas();
};
// --- ADD MutationObserver LOGIC ---
console.log('Setting up MutationObserver for body style');
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (
mutation.type === 'attributes' &&
mutation.attributeName === 'style' &&
document.body.style.pointerEvents === 'none'
) {
// Use the memoized function ensureBodyInteractive
console.log('MutationObserver detected pointer-events: none, fixing...');
ensureBodyInteractive();
}
});
});
observer.observe(document.body, {
attributes: true,
attributeFilter: ['style'],
});
// --- END ADD MutationObserver LOGIC ---
// Ensure body is interactive when component mounts initially
ensureBodyInteractive();
// Add event listeners
window.addEventListener('popstate', handleRouteChange);
window.addEventListener('syntheticUsersNavigation', handleSyntheticUsersNavigation);
// Cleanup function
return () => {
window.removeEventListener('popstate', handleRouteChange);
window.removeEventListener('syntheticUsersNavigation', handleSyntheticUsersNavigation);
// --- ADD Observer Cleanup ---
console.log('Disconnecting MutationObserver');
observer.disconnect();
// --- END ADD Observer Cleanup ---
};
}, []);
// Note: Folders are now stored server-side, no localStorage needed
// Note: Server-side folder management eliminates need for manual synchronization
const createNewFolder = async (name: string, parentId?: string) => {
if (!name.trim()) {
toastService.error("Please enter a folder name");
return;
}
try {
const folderData: any = {
name: name.trim(),
persona_ids: []
};
if (parentId) {
folderData.parent_folder_id = parentId;
}
const response = await foldersApi.create(folderData);
// Refresh folders from server
await fetchFolders();
const folderType = parentId ? 'Sub-folder' : 'Folder';
toastService.success(`${folderType} "${name}" created`);
} catch (error) {
console.error("Error creating folder:", error);
const errorMessage = error.response?.data?.message || "Failed to create folder";
toastService.error(errorMessage);
}
};
const renameFolder = async (folderId: string, newName: string) => {
try {
await foldersApi.update(folderId, {
name: newName
});
// Refresh folders from server
await fetchFolders();
toastService.success(`Folder renamed to "${newName}"`);
} catch (error) {
console.error("Error renaming folder:", error);
const errorMessage = error.response?.data?.message || "Failed to rename folder";
toastService.error(errorMessage);
}
};
const moveFolder = async (folderId: string, newParentId: string | null) => {
try {
await foldersApi.moveFolder(folderId, newParentId);
// Refresh folders from server
await fetchFolders();
const targetName = newParentId
? folders.find(f => f._id === newParentId)?.name || 'folder'
: 'root level';
toastService.success(`Folder moved to ${targetName}`);
} catch (error) {
console.error("Error moving folder:", error);
const errorMessage = error.response?.data?.message || "Failed to move folder";
toastService.error(errorMessage);
}
};
const startDeleteFolder = (folder: Folder) => {
setFolderToDelete(folder);
setDeleteFolderConfirmOpen(true);
};
const completeDeleteFolder = async () => {
if (!folderToDelete) return;
try {
await foldersApi.delete(folderToDelete._id);
// Refresh folders from server
await fetchFolders();
// Update selected folder if the deleted folder was selected
if (selectedFolder === folderToDelete._id || selectedFolder === folderToDelete.id) {
setSelectedFolder(DEFAULT_FOLDER_ID);
}
setDeleteFolderConfirmOpen(false);
setFolderToDelete(null);
toastService.success(`Folder "${folderToDelete.name}" deleted`);
} catch (error) {
console.error("Error deleting folder:", error);
toastService.error("Failed to delete folder");
}
};
const movePersonasToFolder = async (personasToMove?: Set<string>, targetFolderIds?: Set<string>) => {
// Support both direct calls and calls from the dialog button
const personas = personasToMove || selectedPersonas;
const folderIds = targetFolderIds || targetFolders;
if (folderIds.size === 0 || personas.size === 0) return;
const personaIds = Array.from(personas);
// Convert persona.id to persona._id (MongoDB IDs) for backend operations
const mongoIds = personaIds.map(personaId => {
const persona = allPersonas.find(p => p.id === personaId);
return persona?._id || persona?.id || personaId;
}).filter(Boolean);
try {
const successfulUpdates: string[] = [];
const failedUpdates: string[] = [];
const folderNames: string[] = [];
// Handle "All Personas" selection (remove from all folders)
if (folderIds.has(DEFAULT_FOLDER_ID)) {
// Remove personas from all current folders
const currentFolderIds = new Set<string>();
personaIds.forEach(personaId => {
const persona = allPersonas.find(p => p.id === personaId);
if (persona?.folder_ids) {
persona.folder_ids.forEach(fid => currentFolderIds.add(fid));
}
});
for (const folderId of currentFolderIds) {
try {
await foldersApi.removePersonasBatch(folderId, mongoIds);
} catch (error) {
console.error("Error removing personas from folder:", error);
}
}
successfulUpdates.push(...personaIds);
folderNames.push("All Personas (removed from folders)");
} else {
// Add personas to multiple selected folders
for (const folderId of folderIds) {
try {
await foldersApi.addPersonasBatch(folderId, mongoIds);
successfulUpdates.push(...personaIds);
const folderName = folders.find(f => f._id === folderId || f.id === folderId)?.name || "folder";
folderNames.push(folderName);
} catch (error) {
console.error(`Error adding personas to folder ${folderId}:`, error);
failedUpdates.push(...personaIds);
}
}
}
// Refresh data from server
await Promise.all([fetchFolders(), fetchPersonas()]);
// Show toast messages
if (successfulUpdates.length > 0) {
const folderList = folderNames.length > 1
? folderNames.slice(0, -1).join(', ') + ' and ' + folderNames.slice(-1)
: folderNames[0];
toastService.success(`Added ${successfulUpdates.length} persona${successfulUpdates.length !== 1 ? 's' : ''} to ${folderList}`);
}
if (failedUpdates.length > 0) {
toastService.error(`Failed to add some personas to selected folders.`);
}
// Clear selection - caller can also handle this if needed
if (!personasToMove) {
setSelectedPersonas(new Set());
}
return {
success: successfulUpdates.length > 0,
successCount: successfulUpdates.length,
failureCount: failedUpdates.length
};
} catch (error) {
console.error("Error moving personas to folder:", error);
toastService.error("An unexpected error occurred while adding personas to folder.");
return { success: false, error };
}
};
const addPersonaToFolder = async (personaId: string, folderId: string) => {
// First, update local folder state
const updatedFolders = folders.map(folder => {
// Add to target folder if not already included
if (folder.id === folderId) {
if (!folder.personaIds.includes(personaId)) {
return {
...folder,
personaIds: [...folder.personaIds, personaId]
};
}
}
// Remove from other folders
else {
return {
...folder,
personaIds: folder.personaIds.filter(id => id !== personaId)
};
}
return folder;
});
setFolders(updatedFolders);
// Then, update the persona in the database with the new folder
try {
const persona = allPersonas.find(p => p.id === personaId);
if (persona) {
// Prepare updated persona with folder information
const updatedPersona = {
...persona,
folderId: folderId
};
// Find the right ID to use
const idToUse = persona._id || persona.id;
// Update locally first for immediate feedback
setAllPersonas(prev => prev.map(p =>
p.id === personaId ? { ...p, folderId: folderId } : p
));
// Call API to update the persona
await personasApi.update(idToUse, updatedPersona);
// Add small delay to prevent UI interaction issues
setTimeout(() => {
toastService.success("Persona added to folder");
}, 100);
}
} catch (error) {
console.error("Failed to update persona folder:", error);
setTimeout(() => {
toastService.error("Failed to update persona folder");
}, 100);
}
};
const removeSelectedPersonasFromCurrentFolder = async () => {
if (selectedPersonas.size === 0 || selectedFolder === DEFAULT_FOLDER_ID) return;
const selectedIds = Array.from(selectedPersonas);
// Convert persona.id to persona._id (MongoDB IDs) for backend operations
const mongoIds = selectedIds.map(personaId => {
const persona = allPersonas.find(p => p.id === personaId);
return persona?._id || persona?.id || personaId;
}).filter(Boolean);
console.log('Removing personas from folder:', {
selectedFolder,
selectedIds,
mongoIds,
folderName: folders.find(f => f._id === selectedFolder)?.name
});
try {
// Remove personas from the current folder using the batch API
await foldersApi.removePersonasBatch(selectedFolder, mongoIds);
// Refresh data from server
await Promise.all([fetchFolders(), fetchPersonas()]);
const folderName = folders.find(f => f._id === selectedFolder)?.name || 'folder';
toastService.success(`Removed ${selectedIds.length} persona${selectedIds.length !== 1 ? 's' : ''} from ${folderName}`);
// Clear selection
setSelectedPersonas(new Set());
} catch (error) {
console.error("Error removing personas from folder:", error);
console.error("Error details:", error.response?.data || error.message);
toastService.error("Failed to remove personas from folder");
}
};
const togglePersonaSelection = (personaId: string) => {
setSelectedPersonas(prevSelected => {
const newSelected = new Set(prevSelected);
if (newSelected.has(personaId)) {
newSelected.delete(personaId);
} else {
newSelected.add(personaId);
}
return newSelected;
});
};
const handleSelectAllPersonas = () => {
if (selectedPersonas.size === filteredPersonas.length) {
// Deselect all if all are currently selected
setSelectedPersonas(new Set());
} else {
// Select all filtered personas
setSelectedPersonas(new Set(filteredPersonas.map(p => p.id)));
}
};
const deleteSelectedPersonas = async () => {
if (selectedPersonas.size === 0) return;
// Capture selected persona IDs before any state changes
const selectedIds = Array.from(selectedPersonas);
// Clear the selection and close dialog immediately
setSelectedPersonas(new Set()); // Clear selection first
setDeleteConfirmOpen(false); // Close modal second
setIsLoading(true); // Show loading state last
const successfulDeletes: string[] = [];
const failedDeletes: string[] = [];
// Process personas one by one to better handle errors
for (const id of selectedIds) {
try {
// Find the complete persona object to get the MongoDB _id
const persona = allPersonas.find(p => p.id === id);
if (!persona) {
console.error(`Could not find persona with id: ${id}`);
failedDeletes.push(id);
continue;
}
// Use the appropriate ID for the API call
// The backend expects a MongoDB ObjectId string
let idToUse = id;
if (persona._id) {
idToUse = persona._id.toString();
}
console.log(`Attempting to delete persona: ${idToUse}`);
await personasApi.delete(idToUse);
successfulDeletes.push(id);
} catch (error) {
console.error(`Failed to delete persona ${id}:`, error);
failedDeletes.push(id);
// Don't show toast for each failure to avoid spamming the user
}
}
// Update the personas list to remove successfully deleted personas
setAllPersonas(prev => prev.filter(persona => !successfulDeletes.includes(persona.id)));
// Refresh folders from server to reflect persona deletions
await fetchFolders();
setIsLoading(false);
// Show success/failure messages with a small delay
setTimeout(() => {
if (successfulDeletes.length > 0) {
toastService.success(`Successfully deleted ${successfulDeletes.length} persona${successfulDeletes.length !== 1 ? 's' : ''}`);
}
if (failedDeletes.length > 0) {
toastService.error(`Failed to delete ${failedDeletes.length} persona${failedDeletes.length !== 1 ? 's' : ''}`);
}
// Refresh the personas list to ensure consistency
if (successfulDeletes.length > 0 || failedDeletes.length > 0) {
fetchPersonas();
}
}, 50);
};
const filteredPersonas = allPersonas.filter(persona => {
// Text search matching
const matchesSearch = (
persona.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
persona.occupation.toLowerCase().includes(searchTerm.toLowerCase()) ||
persona.location.toLowerCase().includes(searchTerm.toLowerCase())
);
// Apply additional filter criteria
const matchesFilters =
// Match age filter
(activeFilters.age.length === 0 || activeFilters.age.includes(persona.age)) &&
// Match gender filter
(activeFilters.gender.length === 0 || activeFilters.gender.includes(persona.gender)) &&
// Match occupation filter
(activeFilters.occupation.length === 0 || activeFilters.occupation.includes(persona.occupation)) &&
// Match location filter
(activeFilters.location.length === 0 || activeFilters.location.includes(persona.location)) &&
// Match ethnicity filter
(activeFilters.ethnicity.length === 0 ||
(persona.ethnicity && activeFilters.ethnicity.includes(persona.ethnicity))) &&
// Match tech savviness filter (convert numeric value to text bucket)
(activeFilters.techSavviness.length === 0 ||
(persona.techSavviness !== undefined && activeFilters.techSavviness.includes(
persona.techSavviness < 30 ? 'Low (0-30)' :
persona.techSavviness < 70 ? 'Medium (31-70)' :
'High (71-100)'
))) &&
// Match folder status filter
(activeFilters.folderStatus.length === 0 ||
// If both options selected, show all personas (OR logic)
(activeFilters.folderStatus.includes('hasFolder') && activeFilters.folderStatus.includes('noFolder')) ||
// If only "hasFolder" selected
(activeFilters.folderStatus.includes('hasFolder') && !activeFilters.folderStatus.includes('noFolder') &&
((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.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"
if (selectedFolder === DEFAULT_FOLDER_ID) {
return matchesSearch && matchesFilters;
}
// Check if the persona belongs to the selected folder (persona-centric storage)
// Check if the persona has this folder in its folder_ids array
if (persona.folder_ids && Array.isArray(persona.folder_ids)) {
const isInFolder = persona.folder_ids.includes(selectedFolder);
if (isInFolder) {
return matchesSearch && matchesFilters;
}
}
// Legacy support: Check using the single folder_id property
if (persona.folder_id === selectedFolder) {
return matchesSearch && matchesFilters;
}
// Also check legacy folderId for backwards compatibility
if (persona.folderId === selectedFolder) {
return matchesSearch && matchesFilters;
}
// No folder membership found
return false;
});
// Generate markdown content for persona summary export
const generatePersonaSummaryMarkdown = (personas: Persona[], folderName: string) => {
const currentDate = new Date().toISOString().split('T')[0]; // YYYY-MM-DD format
const personaCount = personas.length;
let markdown = `# Persona Summary Report\n\n`;
markdown += `**Folder:** ${folderName}\n`;
markdown += `**Date:** ${currentDate}\n`;
markdown += `**Total Personas:** ${personaCount}\n\n`;
if (personaCount === 0) {
markdown += `No personas found in this folder.\n`;
return markdown;
}
personas.forEach((persona, index) => {
// Persona header
markdown += `## ${persona.name}\n\n`;
// Demographics
markdown += `### Demographics\n`;
markdown += `- **Age:** ${persona.age}\n`;
markdown += `- **Gender:** ${persona.gender}\n`;
markdown += `- **Occupation:** ${persona.occupation}\n`;
markdown += `- **Location:** ${persona.location}\n\n`;
// AI-Synthesized Bio
if (persona.aiSynthesizedBio) {
markdown += `### AI-Synthesized Bio\n`;
markdown += `${persona.aiSynthesizedBio}\n\n`;
}
// Qualitative Attributes
if (persona.qualitativeAttributes && persona.qualitativeAttributes.length > 0) {
markdown += `### Key Attributes\n`;
persona.qualitativeAttributes.forEach(attribute => {
markdown += `- 🏷️ ${attribute}\n`;
});
markdown += `\n`;
}
// Top Personality Traits
if (persona.topPersonalityTraits && persona.topPersonalityTraits.length > 0) {
markdown += `### Top Personality Traits\n`;
persona.topPersonalityTraits.forEach(trait => {
markdown += `- 🧠 ${trait}\n`;
});
markdown += `\n`;
}
// Add horizontal rule between personas (except for the last one)
if (index < personas.length - 1) {
markdown += `---\n\n`;
}
});
return markdown;
};
// Download persona summary for current folder
const downloadPersonaSummary = async () => {
if (filteredPersonas.length === 0) {
toastService.error("No personas to download");
return;
}
// Show LLM selection modal immediately
setDownloadLlmModalOpen(true);
};
// Handle the actual download with selected model
const handleDownloadWithModel = async () => {
const folderName = selectedFolder === DEFAULT_FOLDER_ID
? 'All Personas'
: folders.find(f => f.id === selectedFolder)?.name || 'Unknown Folder';
// Extract persona IDs, using _id for database personas or id as fallback
const personaIds = filteredPersonas.map(persona => persona._id || persona.id);
// Log user's model selection
console.log(`🤖 Frontend: User selected ${selectedDownloadLlmModel} for persona summary download`);
// Close modal
setDownloadLlmModalOpen(false);
// 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...", {
description: `Processing ${filteredPersonas.length} persona${filteredPersonas.length !== 1 ? 's' : ''} with AI`
});
// Call the new API endpoint for batch summary generation
const response = await aiPersonasApi.batchGenerateSummaries(personaIds, 0.7, selectedDownloadLlmModel);
const { summaries, summary_stats, errors, task_id } = response.data;
// Set task ID for cancellation
if (task_id) {
summaryGenerationControls.setTaskId(task_id);
}
// Generate markdown content from LLM-processed summaries
const currentDate = new Date().toISOString().split('T')[0];
const fileName = `persona-summary-${folderName.toLowerCase().replace(/\s+/g, '-')}-${currentDate}.md`;
let markdownContent = `# Persona Summary Report\n\n`;
markdownContent += `**Folder:** ${folderName}\n`;
markdownContent += `**Date:** ${currentDate}\n`;
markdownContent += `**Total Personas:** ${summary_stats.total_requested}\n`;
markdownContent += `**Successfully Processed:** ${summary_stats.total_successful}\n`;
if (summary_stats.total_failed > 0) {
markdownContent += `**Failed to Process:** ${summary_stats.total_failed}\n`;
}
markdownContent += `\n---\n\n`;
if (summaries.length === 0) {
markdownContent += `No persona summaries could be generated.\n`;
} else {
// Add each persona summary
summaries.forEach((summaryData, index) => {
markdownContent += `# ${summaryData.persona_name}\n\n`;
markdownContent += `${summaryData.summary}\n\n`;
// Add separator between personas (except for the last one)
if (index < summaries.length - 1) {
markdownContent += `---\n\n`;
}
});
}
// Add error section if there were failures
if (errors && (errors.failed_summaries?.length > 0 || errors.missing_personas?.length > 0)) {
markdownContent += `\n---\n\n## Processing Errors\n\n`;
if (errors.failed_summaries?.length > 0) {
markdownContent += `### Failed to Generate Summaries\n`;
errors.failed_summaries.forEach(failure => {
markdownContent += `- **${failure.persona_name}** (ID: ${failure.persona_id}): ${failure.error}\n`;
});
markdownContent += `\n`;
}
if (errors.missing_personas?.length > 0) {
markdownContent += `### Missing Personas\n`;
errors.missing_personas.forEach(id => {
markdownContent += `- ID: ${id}\n`;
});
}
}
// Create and download the file
const element = document.createElement('a');
const file = new Blob([markdownContent], { type: 'text/markdown' });
element.href = URL.createObjectURL(file);
element.download = fileName;
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
// Mark generation as complete
summaryGenerationControls.completeGeneration();
// Show success toast with details including model information
const modelDisplayName = selectedDownloadLlmModel === 'gpt-4.1' ? 'GPT-4.1' : 'Gemini 3 Pro';
if (summary_stats.total_successful === summary_stats.total_requested) {
toastService.success("Persona summary downloaded", {
description: `Successfully processed all ${summary_stats.total_successful} persona${summary_stats.total_successful !== 1 ? 's' : ''} from "${folderName}" using ${modelDisplayName}`
});
} else {
toastService.success("Persona summary downloaded with warnings", {
description: `Processed ${summary_stats.total_successful} of ${summary_stats.total_requested} personas from "${folderName}" using ${modelDisplayName}`
});
}
} catch (error) {
// Check if this was a cancellation (expected behavior)
if (error.response?.status === 499) {
return;
}
console.error("Error generating persona summaries:", error);
// Mark generation as failed
summaryGenerationControls.failGeneration(error.message || 'Failed to generate summaries');
// Fall back to basic summary if API fails
toastService.error("AI summary generation failed, creating basic summary", {
description: "Using simplified format due to processing error"
});
try {
const currentDate = new Date().toISOString().split('T')[0];
const fileName = `persona-summary-basic-${folderName.toLowerCase().replace(/\s+/g, '-')}-${currentDate}.md`;
const basicContent = generatePersonaSummaryMarkdown(filteredPersonas, folderName);
const element = document.createElement('a');
const file = new Blob([basicContent], { type: 'text/markdown' });
element.href = URL.createObjectURL(file);
element.download = fileName;
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
} catch (fallbackError) {
toastService.error("Failed to create persona summary", {
description: "Unable to generate summary in any format"
});
}
} finally {
setIsLoading(false);
}
};
// Bulk export functions
const handleBulkExport = async (format: 'markdown' | 'json' | 'csv') => {
const selectedIds = Array.from(selectedPersonas);
if (selectedIds.length === 0) {
toastService.error("Please select personas to export");
return;
}
// Show loading toast
toastService.info(`Exporting ${selectedIds.length} persona${selectedIds.length !== 1 ? 's' : ''} to ${format.toUpperCase()}...`);
try {
// Get JWT token for the request
const token = localStorage.getItem('auth_token');
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/semblance_back/api';
// Make direct fetch request since response will be a file
const response = await fetch(`${API_BASE_URL}/personas/bulk-export`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
persona_ids: selectedIds,
export_format: format
})
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Export failed');
}
// The response should be the file blob directly
const blob = await response.blob();
// Create download link
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `persona_profiles_${format}_${new Date().toISOString().slice(0,19).replace(/:/g,'-')}.zip`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// Clean up
URL.revokeObjectURL(url);
toastService.success(`${format.toUpperCase()} export completed!`, {
description: `Successfully exported ${selectedIds.length} persona profile${selectedIds.length !== 1 ? 's' : ''}`
});
} catch (error) {
console.error("Error during bulk export:", error);
toastService.error("Export failed", {
description: error.message || 'Unknown error occurred'
});
}
};
// Removed separate download function - now using direct file response
return (
<div className="min-h-screen bg-slate-50">
<Navigation />
<main className="pt-20 pb-16 px-4 sm:px-6 lg:px-8 max-w-7xl mx-auto">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-8">
<div>
<h1 className="font-sf text-3xl font-bold text-slate-900">Synthetic Personas</h1>
<p className="text-slate-600 mt-1">Create and manage AI-generated research participants</p>
</div>
<div className="mt-4 sm:mt-0 flex flex-col items-end gap-3">
<div className="flex items-center gap-3">
{mode === 'view' && filteredPersonas.length > 0 && (
<Button
variant="outline"
onClick={downloadPersonaSummary}
disabled={summaryGenerationState.isGenerating}
className="flex items-center gap-2 hover-transition"
>
<Download className="h-4 w-4" />
{summaryGenerationState.isGenerating ? 'Generating Summary...' : 'Download Persona Summary'}
</Button>
)}
<Button
onClick={() => setMode(mode === 'view' ? 'create' : 'view')}
className="hover-transition"
>
{mode === 'view' ? 'Create New Personas' : 'View All Personas'}
</Button>
</div>
</div>
</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' ? (
<>
<div className="flex flex-col md:flex-row gap-6 mb-6">
<FolderTree
folders={folders}
allPersonas={allPersonas}
selectedFolder={selectedFolder}
onFolderSelect={setSelectedFolder}
onCreateFolder={createNewFolder}
onRenameFolder={renameFolder}
onDeleteFolder={startDeleteFolder}
onMoveFolder={moveFolder}
defaultFolderId={DEFAULT_FOLDER_ID}
/>
<div className="flex-1">
<div className="flex flex-col sm:flex-row gap-4 mb-6">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
<Input
placeholder="Search personas by name, occupation, or location..."
className="pl-10 bg-white"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="flex items-center gap-2">
{selectedPersonas.size > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="flex items-center gap-2"
onClick={(e) => {
// Prevent any click events from propagating
e.stopPropagation();
}}
>
<span>Actions ({selectedPersonas.size})</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
onCloseAutoFocus={(e) => {
// Prevent focusing after close which can cause issues
e.preventDefault();
}}
>
<DropdownMenuItem
className="flex items-center gap-2 cursor-pointer"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
const selectedIds = Array.from(selectedPersonas);
navigate('/focus-groups', {
state: {
mode: 'create',
preSelectedParticipants: selectedIds
}
});
}}
>
<MessageSquare className="h-4 w-4" />
Create Focus Group with selected Personas
</DropdownMenuItem>
<DropdownMenuItem
className="flex items-center gap-2 cursor-pointer"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setDeleteConfirmOpen(true);
}}
>
<Trash2 className="h-4 w-4" />
Delete
</DropdownMenuItem>
<DropdownMenuItem
className="flex items-center gap-2 cursor-pointer"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setMoveToFolderOpen(true);
}}
>
<Folder className="h-4 w-4" />
Move to folder
</DropdownMenuItem>
{selectedFolder !== DEFAULT_FOLDER_ID && (
<DropdownMenuItem
className="flex items-center gap-2 cursor-pointer"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
removeSelectedPersonasFromCurrentFolder();
}}
>
<X className="h-4 w-4" />
Remove from {folders.find(f => f._id === selectedFolder)?.name || 'folder'}
</DropdownMenuItem>
)}
<DropdownMenuItem
className="flex items-center gap-2 cursor-pointer"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleBulkExport('markdown');
}}
>
<Download className="h-4 w-4" />
Download Full Persona Profiles (Markdown)
</DropdownMenuItem>
<DropdownMenuItem
className="flex items-center gap-2 cursor-pointer"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleBulkExport('json');
}}
>
<Download className="h-4 w-4" />
Download Full Persona Profiles (JSON)
</DropdownMenuItem>
<DropdownMenuItem
className="flex items-center gap-2 cursor-pointer"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleBulkExport('csv');
}}
>
<Download className="h-4 w-4" />
Download Full Persona Profiles (CSV)
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
<Button
variant="outline"
className="flex items-center gap-2"
onClick={() => setIsFilterOpen(true)}
>
<Filter className="h-4 w-4" />
<span>Filter{Object.values(activeFilters).some(arr => arr.length > 0) ? ` (${Object.values(activeFilters).reduce((count, arr) => count + arr.length, 0)})` : ''}</span>
</Button>
</div>
</div>
<div className="glass-panel rounded-xl p-6 mb-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Users className="h-5 w-5 text-primary" />
<h2 className="font-sf text-xl font-semibold">
{selectedFolder === DEFAULT_FOLDER_ID
? 'Your Synthetic Persona Library'
: folders.find(f => f._id === selectedFolder)?.name || 'Personas'}
</h2>
<span className="text-sm text-muted-foreground">
({filteredPersonas.length})
</span>
</div>
{filteredPersonas.length > 0 && (
<div className="flex items-center">
<Checkbox
id="select-all"
checked={filteredPersonas.length > 0 && selectedPersonas.size === filteredPersonas.length}
onCheckedChange={handleSelectAllPersonas}
className="mr-2"
/>
<label htmlFor="select-all" className="text-sm cursor-pointer">
Select All
</label>
</div>
)}
</div>
{filteredPersonas.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-1 lg:grid-cols-2 xl:grid-cols-2 gap-4">
{filteredPersonas.map((persona) => (
<div key={persona.id} className="relative group">
<UserCard
user={persona}
selected={selectedPersonas.has(persona.id)}
onClick={() => handlePersonaClick(persona)}
onSelectionToggle={(e) => {
e.stopPropagation();
togglePersonaSelection(persona.id);
}}
onViewDetails={handlePersonaViewDetails}
showAddToFolderButton={false}
folders={folders}
/>
</div>
))}
</div>
) : (
<div className="text-center py-12">
<p className="text-muted-foreground">No personas found matching your criteria.</p>
</div>
)}
</div>
<AlertDialog
open={deleteConfirmOpen}
onOpenChange={(open) => {
// If dialog is closing without delete action
if (!open) {
setDeleteConfirmOpen(false);
} else {
setDeleteConfirmOpen(open);
}
}}
>
<AlertDialogContent
onInteractOutside={(e) => {
// Prevent interaction with outside elements but do not block pointer events
// This prevents clicks from going through but won't disable the whole body
e.preventDefault();
}}
>
<AlertDialogHeader>
<AlertDialogTitle>Delete Personas</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete {selectedPersonas.size} selected persona{selectedPersonas.size !== 1 ? 's' : ''}?
This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => {
// Clear selection after canceling
setTimeout(() => setSelectedPersonas(new Set()), 50);
}}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={deleteSelectedPersonas}
className="bg-red-600 hover:bg-red-700"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog
open={deleteFolderConfirmOpen}
onOpenChange={(open) => {
if (!open) {
setDeleteFolderConfirmOpen(false);
} else {
setDeleteFolderConfirmOpen(open);
}
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Folder</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete the folder "{folderToDelete?.name}"?
<br /><br />
<strong>Note:</strong> Any personas in this folder will not be deleted - they will still be available under 'All Personas' after folder deletion.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={completeDeleteFolder}
className="bg-red-600 hover:bg-red-700"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Dialog
open={moveToFolderOpen}
onOpenChange={(open) => {
// Handle dialog open/close state
if (!open) {
// Close the dialog
setMoveToFolderOpen(false);
} else {
setMoveToFolderOpen(open);
}
}}
>
<DialogContent
className="z-50" // Ensure highest z-index
>
<DialogHeader>
<DialogTitle>Move to Folder</DialogTitle>
<DialogDescription>
Choose one or more folders to add {selectedPersonas.size} selected persona{selectedPersonas.size !== 1 ? 's' : ''} to. Personas can belong to multiple folders.
</DialogDescription>
</DialogHeader>
<div className="py-4">
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Checkbox
id="folder-all"
checked={targetFolders.has(DEFAULT_FOLDER_ID)}
onCheckedChange={(checked) => {
const newTargetFolders = new Set(targetFolders);
if (checked) {
// If "All Personas" is selected, clear all other selections
setTargetFolders(new Set([DEFAULT_FOLDER_ID]));
} else {
newTargetFolders.delete(DEFAULT_FOLDER_ID);
setTargetFolders(newTargetFolders);
}
}}
/>
<Label htmlFor="folder-all" className="flex items-center gap-2 cursor-pointer">
<Folder className="h-4 w-4" />
<span>All Personas (Remove from folders)</span>
</Label>
</div>
{(() => {
// Organize folders into hierarchy for the dialog
const rootFolders = folders.filter(folder => !folder.parent_folder_id || folder.level === 0);
const childMap: Record<string, Folder[]> = {};
folders.forEach(folder => {
if (folder.parent_folder_id && folder.level > 0) {
if (!childMap[folder.parent_folder_id]) {
childMap[folder.parent_folder_id] = [];
}
childMap[folder.parent_folder_id].push(folder);
}
});
const handleFolderToggle = (folderId: string, checked: boolean) => {
const newTargetFolders = new Set(targetFolders);
if (checked) {
// If selecting a regular folder, remove "All Personas" if it was selected
newTargetFolders.delete(DEFAULT_FOLDER_ID);
newTargetFolders.add(folderId);
} else {
newTargetFolders.delete(folderId);
}
setTargetFolders(newTargetFolders);
};
const renderFolderOption = (folder: Folder, isChild = false) => (
<div key={folder._id}>
<div className={`flex items-center space-x-2 ${isChild ? 'ml-6 border-l border-slate-200 pl-4' : ''}`}>
<Checkbox
id={`folder-${folder._id}`}
checked={targetFolders.has(folder._id)}
onCheckedChange={(checked) => handleFolderToggle(folder._id, !!checked)}
/>
<Label htmlFor={`folder-${folder._id}`} className="flex items-center gap-2 cursor-pointer">
<Folder className={`h-4 w-4 ${isChild ? 'opacity-75' : ''}`} />
<span className={isChild ? 'text-slate-600' : ''}>{folder.name}</span>
</Label>
</div>
{/* Render children if any */}
{childMap[folder._id] && childMap[folder._id].map(childFolder =>
renderFolderOption(childFolder, true)
)}
</div>
);
return rootFolders.map(folder => renderFolderOption(folder));
})()}
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={(e) => {
// Prevent event propagation
e.preventDefault();
e.stopPropagation();
// Close dialog, don't touch selections
setMoveToFolderOpen(false);
setTargetFolders(new Set());
}}
>
Cancel
</Button>
<Button
onClick={async (e) => {
// Prevent default behavior and stop propagation
e.preventDefault();
e.stopPropagation();
if (targetFolders.size === 0) return; // Ensure target folders are selected
// Capture values before closing dialog or clearing state
const currentSelectedPersonas = new Set(selectedPersonas);
const currentTargetFolders = new Set(targetFolders);
// Close dialog immediately for better UX
setMoveToFolderOpen(false);
setTargetFolders(new Set()); // Reset target folders state
// Perform the move operation
if (currentTargetFolders.size > 0 && currentSelectedPersonas.size > 0) {
setIsLoading(true); // Show loading indicator
try {
// Call the move function with captured values
await movePersonasToFolder(currentSelectedPersonas, currentTargetFolders);
} finally {
setIsLoading(false); // Hide loading indicator first
// Clear selection after the operation is complete
setSelectedPersonas(new Set());
}
}
}}
disabled={targetFolders.size === 0}
>
Move
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Filter Dialog */}
<Dialog
open={isFilterOpen}
onOpenChange={(open) => {
// Important: Cancel any pending selections first
if (!open) {
if (selectedPersonas.size > 0) {
setSelectedPersonas(new Set());
}
// Then update filter state
setIsFilterOpen(false);
} else {
setIsFilterOpen(open);
// When opening, set working filters to active filters
setWorkingFilters({...activeFilters});
}
}}
>
<DialogContent
className="max-w-4xl max-h-[80vh] flex flex-col"
onInteractOutside={(e) => {
// Prevent interaction with outside elements but do not block pointer events
e.preventDefault();
}}
>
{/* Sticky Header */}
<div className="sticky top-0 bg-background border-b shadow-sm pb-4 z-10">
<DialogHeader>
<DialogTitle>Filter Personas</DialogTitle>
<DialogDescription>
Select attributes to filter personas by. Multiple selections within a category use OR logic,
different categories use AND logic. Filter options dynamically update to show only relevant values.
</DialogDescription>
</DialogHeader>
</div>
{/* Scrollable Content Area */}
<div className="flex-1 overflow-y-auto px-1 py-4 space-y-6">
{/* Display number of active filters */}
{Object.values(workingFilters).some(arr => arr.length > 0) && (
<div className="bg-muted/30 p-3 rounded-md">
<p className="text-sm text-muted-foreground">
{Object.values(workingFilters).reduce((count, arr) => count + arr.length, 0)} active filters
</p>
</div>
)}
{/* Basic Demographics Filters */}
<div className="space-y-4">
{(() => {
// Get filter options from filtered personas based on current selections
// We need to find which personas match the current filters (excluding the category being viewed)
const getFilteredOptions = (currentCategory: keyof FilterState) => {
// Create a temporary filter state without the current category
const tempFilters = {...workingFilters};
tempFilters[currentCategory] = []; // Clear the current category
// Filter the personas using the temporary filters
const eligiblePersonas = allPersonas.filter(persona => {
// Apply all filters except the one for the current category
return Object.entries(tempFilters).every(([category, values]) => {
if (values.length === 0) return true; // Skip empty filter arrays
const cat = category as keyof FilterState;
if (cat === 'techSavviness' && persona.techSavviness !== undefined) {
const techLevel =
persona.techSavviness < 30 ? 'Low (0-30)' :
persona.techSavviness < 70 ? 'Medium (31-70)' :
'High (71-100)';
return values.includes(techLevel);
}
else if (cat === 'age' && persona.age) {
return values.includes(persona.age);
}
else if (cat === 'gender' && persona.gender) {
return values.includes(persona.gender);
}
else if (cat === 'occupation' && persona.occupation) {
return values.includes(persona.occupation);
}
else if (cat === 'location' && persona.location) {
return values.includes(persona.location);
}
else if (cat === 'ethnicity' && persona.ethnicity) {
return values.includes(persona.ethnicity);
}
return true; // No matching filter for this category
});
});
// Now get the filter options from the filtered personas
return getFilterOptions(eligiblePersonas);
};
// If no filters are active, use all personas
const noActiveFilters = Object.values(workingFilters).every(values => values.length === 0);
const filterOptions = noActiveFilters
? getFilterOptions(allPersonas)
: getFilterOptions(allPersonas); // Default, will be overridden for each category
// Render filter section
const renderFilterSection = (
title: string,
category: keyof FilterState,
options: string[],
columns: number = 1
) => {
// Include already selected options that might be filtered out
// This ensures that users can see and unselect options they've already selected
const selectedOptions = workingFilters[category];
// Combine filtered options with selected options
const combinedOptions = [...new Set([...options, ...selectedOptions])].sort();
if (combinedOptions.length === 0) return null;
return (
<div className="mb-6">
<h3 className="text-sm font-medium mb-3">{title}</h3>
<div className={`grid grid-cols-1 ${columns === 2 ? 'sm:grid-cols-2' : columns === 3 ? 'sm:grid-cols-2 md:grid-cols-3' : ''} gap-2`}>
{combinedOptions.map(option => {
const isSelected = workingFilters[category].includes(option);
const isAvailable = options.includes(option);
return (
<div
key={option}
className={`flex items-center space-x-2 ${!isAvailable && !isSelected ? 'opacity-50' : ''}`}
>
<Checkbox
id={`${category}-${option}`}
checked={isSelected}
onCheckedChange={() => toggleFilter(category, option)}
disabled={!isAvailable && !isSelected}
/>
<Label
htmlFor={`${category}-${option}`}
className="truncate overflow-hidden"
>
{option}
{isSelected && !isAvailable && (
<span className="ml-1 text-xs text-muted-foreground">(no matches)</span>
)}
</Label>
</div>
);
})}
</div>
</div>
);
};
return (
<>
{renderFilterSection(
'Gender',
'gender',
noActiveFilters ? filterOptions.gender : getFilteredOptions('gender').gender,
3
)}
{renderFilterSection(
'Age',
'age',
noActiveFilters ? filterOptions.age : getFilteredOptions('age').age,
3
)}
{renderFilterSection(
'Ethnicity',
'ethnicity',
noActiveFilters ? filterOptions.ethnicity : getFilteredOptions('ethnicity').ethnicity,
2
)}
{renderFilterSection(
'Location',
'location',
noActiveFilters ? filterOptions.location : getFilteredOptions('location').location,
2
)}
{renderFilterSection(
'Occupation',
'occupation',
noActiveFilters ? filterOptions.occupation : getFilteredOptions('occupation').occupation,
2
)}
{/* Tech Savviness */}
{renderFilterSection(
'Tech Savviness',
'techSavviness',
noActiveFilters ? filterOptions.techSavviness : getFilteredOptions('techSavviness').techSavviness,
3
)}
{/* Folder Assignment */}
<div className="mb-6">
<h3 className="text-sm font-medium mb-3">Folder Assignment</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<div className="flex items-center space-x-2">
<Checkbox
id="folderStatus-hasFolder"
checked={workingFilters.folderStatus.includes('hasFolder')}
onCheckedChange={() => toggleFilter('folderStatus', 'hasFolder')}
/>
<Label
htmlFor="folderStatus-hasFolder"
className="truncate overflow-hidden"
>
Has folder assignment
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="folderStatus-noFolder"
checked={workingFilters.folderStatus.includes('noFolder')}
onCheckedChange={() => toggleFilter('folderStatus', 'noFolder')}
/>
<Label
htmlFor="folderStatus-noFolder"
className="truncate overflow-hidden"
>
No folder assignment
</Label>
</div>
</div>
</div>
{/* Boolean Attributes */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
</div>
</>
);
})()}
</div>
</div>
{/* Sticky Footer */}
<div className="sticky bottom-0 bg-background border-t shadow-[0_-2px_4px_rgba(0,0,0,0.05)] pt-4 z-10">
<DialogFooter>
<Button
variant="outline"
onClick={handleResetFilters}
>
Reset
</Button>
<Button onClick={applyFilters}>
Apply Filters
</Button>
</DialogFooter>
</div>
</DialogContent>
</Dialog>
{/* LLM Selection Modal for Download */}
<Dialog
open={downloadLlmModalOpen}
onOpenChange={setDownloadLlmModalOpen}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Select AI Model for Summary Generation</DialogTitle>
<DialogDescription>
Choose which AI model to use for generating persona summaries
</DialogDescription>
</DialogHeader>
<div className="py-4">
<RadioGroup
value={selectedDownloadLlmModel}
onValueChange={setSelectedDownloadLlmModel}
className="space-y-3"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="gemini-3-pro-preview" id="download-gemini" />
<Label htmlFor="download-gemini" className="text-sm font-medium">
Gemini 3 Pro
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="gpt-4.1" id="download-gpt" />
<Label htmlFor="download-gpt" className="text-sm font-medium">
GPT-4.1
</Label>
</div>
</RadioGroup>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDownloadLlmModalOpen(false)}
>
Cancel
</Button>
<Button onClick={handleDownloadWithModel}>
Generate Summary
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
</>
) : (
<Tabs defaultValue="ai" onValueChange={(value) => setCreationMode(value as 'manual' | 'ai')}>
<TabsList className="grid w-full grid-cols-2 mb-6">
<TabsTrigger value="ai">AI Recruiter</TabsTrigger>
<TabsTrigger value="manual">Manual Creation</TabsTrigger>
</TabsList>
<TabsContent value="ai">
{/* Log detailed info about the selected folder */}
{console.log(`Rendering AIRecruiter with targetFolderId: ${selectedFolder !== DEFAULT_FOLDER_ID ? selectedFolder : 'null'}`)}
{console.log(`Current folders:`, folders.map(f => ({ id: f.id, name: f.name })))}
<AIRecruiter
targetFolderId={selectedFolder !== DEFAULT_FOLDER_ID ? selectedFolder : null}
targetFolderName={selectedFolder !== DEFAULT_FOLDER_ID ? folders.find(f => f.id === selectedFolder)?.name : null}
/>
</TabsContent>
<TabsContent value="manual">
<UserCreator
targetFolderId={selectedFolder !== DEFAULT_FOLDER_ID ? selectedFolder : null}
targetFolderName={selectedFolder !== DEFAULT_FOLDER_ID ? folders.find(f => f.id === selectedFolder)?.name : null}
/>
</TabsContent>
</Tabs>
)}
</main>
</div>
);
};
export default SyntheticUsers;