various bug fixes and feature additions per Alec's requests

This commit is contained in:
michael 2025-08-06 19:29:27 -05:00
parent b649793013
commit 8dcbe7efee
27 changed files with 2384 additions and 2942 deletions

View file

@ -657,7 +657,7 @@ class FocusGroup:
# Prepare the mode event
mode_event = {
"focus_group_id": focus_group_id,
"event_type": event_type, # 'ai_mode_started' or 'manual_mode_started'
"event_type": event_type, # 'ai_mode_started', 'manual_mode_started', or 'ai_session_concluded'
"timestamp": datetime.utcnow(),
"user_id": user_id, # None for system-initiated changes
"created_at": datetime.utcnow()

View file

@ -700,6 +700,18 @@ class AIModeratorService:
'status': 'completed'
})
# Add mode event for AI auto-completion (only for auto_complete reason)
if reason == 'auto_complete':
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}")
else:
print(f"Warning: Failed to add AI session concluded mode event for focus group {focus_group_id}")
print(f"🎬 Session ended for focus group {focus_group_id} with reason: {reason}")
return {

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

723
dist/assets/index-ImyDGn9B.js 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-Dod4tGHl.js"></script>
<link rel="stylesheet" crossorigin href="/semblance/assets/index-CMEVr6tk.css">
<script type="module" crossorigin src="/semblance/assets/index-ImyDGn9B.js"></script>
<link rel="stylesheet" crossorigin href="/semblance/assets/index-ByQ3S_f0.css">
</head>
<body>

View file

@ -13,6 +13,7 @@ import PersonaProfile from "./components/persona/PersonaProfile";
import Login from "./pages/Login";
import ProtectedRoute from "./components/ProtectedRoute";
import { AuthProvider } from "./contexts/AuthContext";
import { NavigationProvider } from "./contexts/NavigationContext";
// CSS for consistent back button positioning
import "./styles/backButton.css";
@ -23,7 +24,8 @@ const App = () => (
<QueryClientProvider client={queryClient}>
<BrowserRouter basename="/semblance">
<AuthProvider>
<TooltipProvider>
<NavigationProvider>
<TooltipProvider>
<Toaster />
<Routes>
<Route path="/" element={<Index />} />
@ -71,7 +73,8 @@ const App = () => (
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
<Route path="*" element={<NotFound />} />
</Routes>
</TooltipProvider>
</TooltipProvider>
</NavigationProvider>
</AuthProvider>
</BrowserRouter>
</QueryClientProvider>

View file

@ -154,7 +154,7 @@ export default function AIRecruiter({ targetFolderId, targetFolderName }: AIRecr
}
// Navigate directly back to synthetic users list
navigate('/synthetic-users');
navigate('/synthetic-users?mode=view');
} else {
throw new Error("No personas were generated");
}
@ -307,7 +307,7 @@ export default function AIRecruiter({ targetFolderId, targetFolderName }: AIRecr
savePersonas(approved);
// Redirect to the persona library view instead of resetting
navigate('/synthetic-users');
navigate('/synthetic-users?mode=view');
};
return (

View file

@ -1,5 +1,6 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate, useLocation } from 'react-router-dom';
import { useNavigation } from '@/contexts/NavigationContext';
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
@ -75,7 +76,6 @@ import {
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import UserCard from "@/components/UserCard";
import PersonaDetailsModal from "@/components/PersonaDetailsModal";
import { Persona } from "@/types/persona";
// Define folder interface
@ -129,11 +129,14 @@ const sampleGuide = {
interface FocusGroupModeratorProps {
draftToEdit?: any | null;
onDraftSaved?: () => void;
preSelectedParticipants?: string[];
}
export default function FocusGroupModerator({ draftToEdit, onDraftSaved }: FocusGroupModeratorProps = {}) {
export default function FocusGroupModerator({ draftToEdit, onDraftSaved, preSelectedParticipants = [] }: FocusGroupModeratorProps = {}) {
console.log('FocusGroupModerator component rendering, draftToEdit:', draftToEdit);
const navigate = useNavigate();
const location = useLocation();
const { setPreviousRoute, navigationState, clearNavigationState } = useNavigation();
const [activeTab, setActiveTab] = useState('setup');
const [isGenerating, setIsGenerating] = useState(false);
const [guideGenerationComplete, setGuideGenerationComplete] = useState(false);
@ -141,6 +144,9 @@ export default function FocusGroupModerator({ draftToEdit, onDraftSaved }: Focus
const [discussionGuide, setDiscussionGuide] = useState<string | any | null>(null);
const [draftFocusGroupId, setDraftFocusGroupId] = useState<string | null>(null);
// Track if discussion guide is being edited to prevent updates during editing
const [isEditingGuide, setIsEditingGuide] = useState(false);
// Ref to access current discussionGuide in callbacks without adding it as dependency
const discussionGuideRef = useRef(discussionGuide);
discussionGuideRef.current = discussionGuide;
@ -188,20 +194,32 @@ export default function FocusGroupModerator({ draftToEdit, onDraftSaved }: Focus
ethnicity: [],
});
// Persona details modal state
const [isPersonaModalOpen, setIsPersonaModalOpen] = useState(false);
const [selectedPersonaForModal, setSelectedPersonaForModal] = useState<Persona | null>(null);
// Handler for opening persona details modal
const handleOpenPersonaModal = (persona: Persona) => {
setSelectedPersonaForModal(persona);
setIsPersonaModalOpen(true);
};
// Auto-save state management
const [autoSaveStatus, setAutoSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
const [lastSavedData, setLastSavedData] = useState<any>(null);
const [saveRetryCount, setSaveRetryCount] = useState(0);
const debouncedSaveTimerRef = useRef<NodeJS.Timeout | null>(null);
const isSavingRef = useRef(false);
const isLoadingDraftRef = useRef(false);
// Handler for closing persona details modal
const handleClosePersonaModal = () => {
setIsPersonaModalOpen(false);
setSelectedPersonaForModal(null);
// Handler for persona view details navigation
const handlePersonaViewDetails = (persona: Persona) => {
// Set navigation context with current focus group state
setPreviousRoute('/focus-groups', {
focusGroupId: draftFocusGroupId,
focusGroupTab: 'participants',
isNewFocusGroup: !draftToEdit,
focusGroupData: {
name: form.getValues('name'),
description: form.getValues('description'),
selectedParticipants: selectedParticipants,
discussionGuide: discussionGuide,
}
});
// Navigate to persona profile page
navigate(`/synthetic-users/${persona.id}`);
};
// Function to collect unique filter options from personas
@ -514,15 +532,167 @@ export default function FocusGroupModerator({ draftToEdit, onDraftSaved }: Focus
},
});
console.log('Form initialized successfully');
// Simplified auto-save trigger function - only debounced save on changes
const triggerAutoSave = () => {
if (activeTab !== 'setup' || isLoadingDraftRef.current) return;
// Clear existing debounced timer
if (debouncedSaveTimerRef.current) {
clearTimeout(debouncedSaveTimerRef.current);
}
// Schedule debounced save - create inline function to avoid dependencies
debouncedSaveTimerRef.current = setTimeout(async () => {
if (isSavingRef.current) return;
const values = form.getValues();
const currentData = {
name: values.focusGroupName || '',
description: values.researchBrief || '',
objective: values.researchBrief || '',
topic: values.discussionTopics || '',
duration: values.duration ? parseInt(values.duration) : 60,
llm_model: values.llm_model || 'gemini-2.5-pro',
participants: selectedParticipants,
participants_count: selectedParticipants.length,
status: 'draft',
date: new Date().toISOString(),
uploadedAssets: uploadedAssets.map(file => file.name)
};
if (lastSavedData && JSON.stringify(currentData) === JSON.stringify(lastSavedData)) {
return; // No changes
}
if (!currentData.name && !currentData.description && !currentData.topic) {
return; // Don't save empty form
}
isSavingRef.current = true;
setAutoSaveStatus('saving');
try {
// Use draftFocusGroupId from state, or fall back to draftToEdit ID if available
let focusGroupId = draftFocusGroupId || (draftToEdit?.id || draftToEdit?._id);
console.log("Auto-save: draftFocusGroupId =", draftFocusGroupId);
console.log("Auto-save: draftToEdit ID =", draftToEdit?.id || draftToEdit?._id);
console.log("Auto-save: using focusGroupId =", focusGroupId);
console.log("Auto-save: llm_model in currentData =", currentData.llm_model);
console.log("Auto-save: duration in currentData =", currentData.duration);
if (!focusGroupId) {
console.log("Auto-save: Creating NEW focus group (no existing ID)");
const response = await focusGroupsApi.create(currentData);
focusGroupId = response.data.focus_group_id || response.data.id || response.data._id;
setDraftFocusGroupId(focusGroupId);
console.log("Auto-save: Created new draft with ID:", focusGroupId);
} else {
console.log("Auto-save: Updating existing focus group:", focusGroupId);
await focusGroupsApi.update(focusGroupId, currentData);
console.log("Auto-save: Updated existing draft:", focusGroupId);
}
setLastSavedData(currentData);
setAutoSaveStatus('saved');
setSaveRetryCount(0);
setTimeout(() => {
setAutoSaveStatus('idle');
}, 2000);
} catch (error) {
console.error("Auto-save failed:", error);
setAutoSaveStatus('error');
setSaveRetryCount(prev => prev + 1);
if (saveRetryCount < 3) {
const retryDelay = Math.pow(2, saveRetryCount) * 2000;
setTimeout(() => {
triggerAutoSave();
}, retryDelay);
} else {
toast.error("Auto-save failed", {
description: "Your changes may not be saved. Please check your connection.",
});
}
} finally {
isSavingRef.current = false;
}
}, 2000);
};
// Watch for form field changes to trigger auto-save
const watchedFields = form.watch();
// Use refs to track previous values to prevent unnecessary saves
const prevWatchedFieldsRef = useRef<string>('');
const prevSelectedParticipantsRef = useRef<string>('');
const prevUploadedAssetsRef = useRef<string>('');
// Effect to handle form field changes and trigger auto-save
useEffect(() => {
const currentWatchedFields = JSON.stringify(watchedFields);
if (activeTab === 'setup' && currentWatchedFields !== prevWatchedFieldsRef.current) {
prevWatchedFieldsRef.current = currentWatchedFields;
triggerAutoSave();
}
}, [watchedFields, activeTab]);
// Effect to handle participant changes
useEffect(() => {
const currentParticipants = JSON.stringify(selectedParticipants);
if (activeTab === 'setup' && currentParticipants !== prevSelectedParticipantsRef.current) {
prevSelectedParticipantsRef.current = currentParticipants;
triggerAutoSave();
}
}, [selectedParticipants, activeTab]);
// Effect to handle uploaded assets changes
useEffect(() => {
const currentAssets = JSON.stringify(uploadedAssets.map(f => f.name));
if (activeTab === 'setup' && currentAssets !== prevUploadedAssetsRef.current) {
prevUploadedAssetsRef.current = currentAssets;
triggerAutoSave();
}
}, [uploadedAssets, activeTab]);
// Effect to clear timers when leaving setup tab or component unmounts
useEffect(() => {
if (activeTab !== 'setup') {
// Clear debounced timer when leaving setup tab
if (debouncedSaveTimerRef.current) {
clearTimeout(debouncedSaveTimerRef.current);
}
}
// Cleanup timer on unmount
return () => {
if (debouncedSaveTimerRef.current) {
clearTimeout(debouncedSaveTimerRef.current);
}
};
}, [activeTab]);
// Effect to load draft data when editing an existing draft
useEffect(() => {
console.log("Draft loading effect - draftToEdit:", draftToEdit, "draftLoadedRef.current:", draftLoadedRef.current);
// Reset loaded flag when draftToEdit changes
if (!draftToEdit) {
draftLoadedRef.current = false;
return;
}
if (draftToEdit && !draftLoadedRef.current) {
console.log("Loading draft focus group:", draftToEdit);
isLoadingDraftRef.current = true; // Prevent auto-save during loading
draftLoadedRef.current = true; // Mark as loaded to prevent re-loading
// Set the draft ID
setDraftFocusGroupId(draftToEdit.id || draftToEdit._id);
const draftId = draftToEdit.id || draftToEdit._id;
setDraftFocusGroupId(draftId);
console.log("Setting draft ID from draftToEdit:", draftId);
// Load form data if available
if (draftToEdit.name) {
@ -537,12 +707,17 @@ export default function FocusGroupModerator({ draftToEdit, onDraftSaved }: Focus
if (draftToEdit.duration) {
form.setValue('duration', draftToEdit.duration.toString());
}
if (draftToEdit.llm_model) {
form.setValue('llm_model', draftToEdit.llm_model);
}
// Load discussion guide if available
if (draftToEdit.discussionGuide) {
setDiscussionGuide(draftToEdit.discussionGuide);
// If we have a discussion guide, start on the review tab
setActiveTab('review');
// If we have a discussion guide and no navigation state override, start on the review tab
if (!navigationState.focusGroupTab || navigationState.previousRoute !== '/focus-groups') {
setActiveTab('review');
}
}
// Load selected participants if available
@ -550,12 +725,85 @@ export default function FocusGroupModerator({ draftToEdit, onDraftSaved }: Focus
setSelectedParticipants(draftToEdit.participants);
}
// Set lastSavedData to current draft state to prevent immediate auto-save
const currentDraftData = {
name: draftToEdit.name || '',
description: draftToEdit.description || draftToEdit.objective || '',
objective: draftToEdit.description || draftToEdit.objective || '',
topic: draftToEdit.topic || '',
duration: draftToEdit.duration || 60,
llm_model: draftToEdit.llm_model || 'gemini-2.5-pro',
participants: draftToEdit.participants || [],
participants_count: (draftToEdit.participants || []).length,
status: 'draft',
date: draftToEdit.date || new Date().toISOString(),
uploadedAssets: []
};
setLastSavedData(currentDraftData);
console.log("Set lastSavedData to current draft:", currentDraftData);
toast.success("Draft focus group loaded", {
description: "Continue editing your focus group setup"
});
// Allow auto-save after loading is complete
setTimeout(() => {
isLoadingDraftRef.current = false;
}, 1000); // Give it a second to settle
}
}, [draftToEdit, form]);
// Effect to handle pre-selected participants from persona list
useEffect(() => {
if (preSelectedParticipants.length > 0) {
console.log("Pre-selected participants received:", preSelectedParticipants);
setSelectedParticipants(preSelectedParticipants);
// Auto-switch to participants tab to show the pre-selected personas
setActiveTab('participants');
}
}, [preSelectedParticipants]);
// Handle navigation state to set the correct tab when returning from persona details
useEffect(() => {
if (navigationState.focusGroupTab && navigationState.previousRoute === '/focus-groups') {
// Use setTimeout to ensure this runs after other tab-setting logic
setTimeout(() => {
setActiveTab(navigationState.focusGroupTab);
// Clear navigation state after using it
clearNavigationState();
}, 0);
}
}, [navigationState.focusGroupTab, draftToEdit, clearNavigationState]); // Also depend on draftToEdit so this runs after draft loading
// Initialize refs on mount for new focus groups (not editing drafts)
useEffect(() => {
if (!draftToEdit) {
setTimeout(() => {
isLoadingDraftRef.current = false;
}, 500); // Allow initial render to complete
}
}, [draftToEdit]);
// Save Status Indicator Component
const SaveStatusIndicator = () => {
if (autoSaveStatus === 'idle') return null;
const statusConfig = {
saving: { text: 'Saving...', className: 'text-blue-600 bg-blue-50' },
saved: { text: 'All changes saved', className: 'text-green-600 bg-green-50' },
error: { text: 'Save failed - retrying...', className: 'text-red-600 bg-red-50' }
};
const config = statusConfig[autoSaveStatus];
return (
<div className={`fixed top-16 left-1/2 transform -translate-x-1/2 z-50 px-3 py-1 rounded-md text-sm font-medium border shadow-sm ${config.className}`}>
{config.text}
</div>
);
};
// Function to generate a discussion guide via the API
const generateDiscussionGuide = async (values: z.infer<typeof formSchema>, focusGroupId?: string): Promise<string> => {
// Reset states
@ -902,41 +1150,12 @@ true;
console.log("New selection:", newSelection);
// Auto-save participant selection if we have a draft
if (draftFocusGroupId && discussionGuide) {
saveDraftParticipants(newSelection);
}
// Auto-save will be triggered by the useEffect watching selectedParticipants
return newSelection;
});
};
// Function to auto-save participant changes to draft
const saveDraftParticipants = async (participants: string[]) => {
if (!draftFocusGroupId) return;
try {
const values = form.getValues();
const draftData = {
name: values.focusGroupName,
status: 'draft',
participants: participants,
participants_count: participants.length,
date: new Date().toISOString(),
duration: parseInt(values.duration),
topic: values.discussionTopics.split(',')[0].trim().toLowerCase().replace(/\s+/g, '-'),
description: values.researchBrief,
objective: values.researchBrief,
discussionGuide: discussionGuide
};
await focusGroupsApi.update(draftFocusGroupId, draftData);
console.log("Participant selection auto-saved to draft");
} catch (error) {
console.error("Failed to auto-save participant selection:", error);
// Don't show toast for auto-save failures to avoid spam
}
};
const handleAssetUpload = (files: FileList | null) => {
if (files && files.length > 0) {
@ -1012,15 +1231,34 @@ true;
// Stable callback for saving discussion guide changes
const handleSaveDiscussionGuide = useCallback(async (updatedGuide: any) => {
console.log('📝 handleSaveDiscussionGuide called with:', updatedGuide);
setDiscussionGuide(updatedGuide);
toast.success('Discussion guide updated', {
description: 'Your changes have been saved.'
});
// Only update the discussion guide state if we're not currently editing
// This prevents re-renders that would cause focus loss during editing
if (!isEditingGuide) {
setDiscussionGuide(updatedGuide);
toast.success('Discussion guide updated', {
description: 'Your changes have been saved.'
});
} else {
// During editing, just update the ref so the latest version is available
discussionGuideRef.current = updatedGuide;
console.log('📝 Skipping discussionGuide state update during editing to preserve focus');
}
}, [isEditingGuide]);
// Handle editing state changes from DiscussionGuideViewer
const handleEditingStateChange = useCallback((editing: boolean) => {
console.log('📝 Discussion guide editing state changed:', editing);
setIsEditingGuide(editing);
// When editing ends, update the discussion guide state with the latest version
if (!editing && discussionGuideRef.current) {
console.log('📝 Updating discussionGuide state after editing ended');
setDiscussionGuide(discussionGuideRef.current);
}
}, []);
// Stable dummy callbacks for optional props
const handleSectionSelect = useCallback(() => {}, []);
const handleSetPosition = useCallback(() => {}, []);
const handleStartFocusGroup = async () => {
// Validate form data
@ -1105,11 +1343,15 @@ true;
};
return (
<div className="glass-panel rounded-xl p-6">
<div className="flex items-center gap-2 mb-6">
<MessageSquare className="h-5 w-5 text-primary" />
<h2 className="font-sf text-xl font-semibold">AI Focus Group Moderator</h2>
</div>
<>
{/* Auto-save Status Indicator */}
<SaveStatusIndicator />
<div className="glass-panel rounded-xl p-6">
<div className="flex items-center gap-2 mb-6">
<MessageSquare className="h-5 w-5 text-primary" />
<h2 className="font-sf text-xl font-semibold">AI Focus Group Moderator</h2>
</div>
{/* Progress Bar - Consistent top placement for discussion guide generation */}
{isGenerating && (
@ -1201,7 +1443,7 @@ true;
render={({ field }) => (
<FormItem>
<FormLabel>Duration (minutes)</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select duration" />
@ -1229,7 +1471,7 @@ true;
render={({ field }) => (
<FormItem>
<FormLabel>AI Model</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select AI model" />
@ -1339,9 +1581,9 @@ true;
onSave={handleSaveDiscussionGuide}
onDownload={handleDownloadDiscussionGuide}
onSectionSelect={handleSectionSelect}
onSetPosition={handleSetPosition}
isDownloading={isDownloadingGuide}
focusGroupId={draftFocusGroupId}
onEditingChange={handleEditingStateChange}
/>
) : (
<div className="bg-slate-50 p-4 rounded border text-center text-slate-600">
@ -1657,8 +1899,7 @@ true;
}}
selected={selectedParticipants.includes(personaId)}
onSelectionToggle={() => handleParticipantSelection(personaId)}
showModalInsteadOfNavigate={true}
onOpenPersonaModal={handleOpenPersonaModal}
onViewDetails={handlePersonaViewDetails}
/>
);
})}
@ -1819,13 +2060,7 @@ true;
</Dialog>
</TabsContent>
</Tabs>
{/* Persona Details Modal */}
<PersonaDetailsModal
persona={selectedPersonaForModal}
isOpen={isPersonaModalOpen}
onClose={handleClosePersonaModal}
/>
</div>
</>
);
}

View file

@ -21,8 +21,7 @@ interface UserCardProps {
onSelectionToggle?: (e: React.MouseEvent) => void;
showAddToFolderButton?: boolean;
onAddToFolder?: (e: React.MouseEvent) => void;
showModalInsteadOfNavigate?: boolean;
onOpenPersonaModal?: (persona: Persona) => void;
onViewDetails?: (persona: Persona) => void;
}
export default function UserCard({
@ -33,8 +32,7 @@ export default function UserCard({
onSelectionToggle,
showAddToFolderButton = false,
onAddToFolder,
showModalInsteadOfNavigate = false,
onOpenPersonaModal
onViewDetails
}: UserCardProps) {
const navigate = useNavigate();
const [isEditing, setIsEditing] = useState(false);
@ -92,8 +90,8 @@ export default function UserCard({
const handleViewDetailsClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (showModalInsteadOfNavigate && onOpenPersonaModal) {
onOpenPersonaModal(currentPersona);
if (onViewDetails) {
onViewDetails(currentPersona);
} else {
handleViewDetails(e);
}

View file

@ -521,7 +521,7 @@ export default function UserCreator({ targetFolderId, targetFolderName }: UserCr
// Navigate back to the synthetic users page after successful creation
setTimeout(() => {
navigate('/synthetic-users');
navigate('/synthetic-users?mode=view');
}, 300);
} catch (error: unknown) {
console.error("Error creating personas:", error);

View file

@ -381,49 +381,52 @@ export default function AIRecruiterForm({ onSubmit, isGenerating }: AIRecruiterF
</div>
)}
{/* LLM Model Selection */}
<FormField
control={form.control}
name="llm_model"
render={({ field }) => (
<FormItem>
<FormLabel>AI Model</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
{/* AI Model and Persona Count - Half Width Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* LLM Model Selection */}
<FormField
control={form.control}
name="llm_model"
render={({ field }) => (
<FormItem>
<FormLabel>AI Model</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select AI model" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="gemini-2.5-pro">Gemini 2.5 Pro</SelectItem>
<SelectItem value="gpt-4.1">GPT-4.1</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Choose which AI model to use for generating personas
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Number of Personas to Generate */}
<FormField
control={form.control}
name="personaCount"
render={({ field }) => (
<FormItem>
<FormLabel>Number of Personas to Generate</FormLabel>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select AI model" />
</SelectTrigger>
<Input type="number" min="1" max="20" {...field} />
</FormControl>
<SelectContent>
<SelectItem value="gemini-2.5-pro">Gemini 2.5 Pro</SelectItem>
<SelectItem value="gpt-4.1">GPT-4.1</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Choose which AI model to use for generating personas
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Number of Personas to Generate */}
<FormField
control={form.control}
name="personaCount"
render={({ field }) => (
<FormItem>
<FormLabel>Number of Personas to Generate</FormLabel>
<FormControl>
<Input type="number" min="1" max="20" {...field} />
</FormControl>
<FormDescription>
How many synthetic users do you need for your research?
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormDescription>
How many synthetic users do you need for your research?
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex flex-col items-end">
<Button

View file

@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useRef, useCallback } from 'react';
import {
ChevronDown,
ChevronUp,
@ -39,12 +39,15 @@ const CollapsibleDiscussionGuide: React.FC<CollapsibleDiscussionGuideProps> = ({
className,
onEditingChange
}) => {
console.log('📋 CollapsibleDiscussionGuide render', {
isOpen,
hasModerator: !!moderatorStatus,
guideTitle: discussionGuide?.title,
timestamp: new Date().toISOString()
});
// Track editing state to prevent re-renders
const isEditingRef = useRef(false);
// Wrapper for onEditingChange to track editing state
const handleEditingChange = useCallback((editing: boolean) => {
isEditingRef.current = editing;
onEditingChange?.(editing);
}, [onEditingChange]);
// Download state
const [isDownloadingGuide, setIsDownloadingGuide] = useState(false);
@ -135,7 +138,9 @@ const CollapsibleDiscussionGuide: React.FC<CollapsibleDiscussionGuideProps> = ({
onSave={onSave}
showProgress={true}
collapsible={true}
defaultExpanded={true}
focusGroupId={focusGroupId}
onEditingChange={handleEditingChange}
/>
</div>
</CardContent>
@ -147,35 +152,4 @@ const CollapsibleDiscussionGuide: React.FC<CollapsibleDiscussionGuideProps> = ({
);
};
// Simplified memo comparison function
const CollapsibleDiscussionGuideMemo = React.memo(CollapsibleDiscussionGuide, (prevProps, nextProps) => {
// Check all props to determine if we should re-render
const propsToCompare = ['discussionGuide', 'onSectionSelect', 'onSave', 'focusGroupId', 'isOpen', 'onToggle', 'className'];
let hasNonModeratorChanges = false;
for (const prop of propsToCompare) {
if (prevProps[prop] !== nextProps[prop]) {
hasNonModeratorChanges = true;
break;
}
}
// If only moderatorStatus changed, skip re-render
if (prevProps.moderatorStatus !== nextProps.moderatorStatus && !hasNonModeratorChanges) {
return true; // Skip re-render
}
// If no props changed at all, skip re-render
if (!hasNonModeratorChanges && prevProps.moderatorStatus === nextProps.moderatorStatus) {
return true; // Skip re-render
}
// Props changed, re-render
console.log('🔄 Will re-render (props changed)', {
changedProps: propsToCompare.filter(prop => prevProps[prop] !== nextProps[prop]),
timestamp: new Date().toISOString()
});
return false; // Re-render
});
export default CollapsibleDiscussionGuideMemo;
export default CollapsibleDiscussionGuide;

View file

@ -1,954 +0,0 @@
import React, { useState, useCallback, useEffect, useRef } from 'react';
import {
GripVertical,
Plus,
Trash2,
Edit3,
Check,
X,
Clock,
MessageCircle,
Activity,
ChevronDown,
ChevronRight,
Save
} from 'lucide-react';
import {
DndContext,
closestCenter,
closestCorners,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import {
useSortable,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { cn } from '@/lib/utils';
interface DiscussionGuideItem {
id: string;
type: string;
content: string;
time_limit?: number;
probes?: string[];
metadata?: Record<string, any>;
}
interface DiscussionGuideSection {
id: string;
title: string;
content?: string;
questions?: DiscussionGuideItem[];
activities?: DiscussionGuideItem[];
subsections?: any[];
metadata?: Record<string, any>;
}
interface StructuredDiscussionGuide {
title: string;
total_duration: number;
sections: DiscussionGuideSection[];
metadata?: Record<string, any>;
}
interface DiscussionGuideEditorProps {
discussionGuide: StructuredDiscussionGuide;
onChange: (guide: StructuredDiscussionGuide) => void;
onSave: () => void;
onCancel: () => void;
}
const DiscussionGuideEditor: React.FC<DiscussionGuideEditorProps> = React.memo(({
discussionGuide,
onChange,
onSave,
onCancel
}) => {
const [editingItem, setEditingItem] = useState<string | null>(null);
const [openSections, setOpenSections] = useState<Set<string>>(new Set());
// Drag and drop sensors
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
// Track which item is being edited
// Helper function to update the guide
const updateGuide = useCallback((updater: (guide: StructuredDiscussionGuide) => StructuredDiscussionGuide) => {
const newGuide = updater({ ...discussionGuide });
onChange(newGuide);
}, [discussionGuide, onChange]);
// Toggle section open/closed
const toggleSection = (sectionId: string) => {
const newOpenSections = new Set(openSections);
if (newOpenSections.has(sectionId)) {
newOpenSections.delete(sectionId);
} else {
newOpenSections.add(sectionId);
}
setOpenSections(newOpenSections);
};
// Update guide title
const updateTitle = (newTitle: string) => {
updateGuide(guide => ({
...guide,
title: newTitle
}));
};
// Update total duration
const updateTotalDuration = (newDuration: number) => {
updateGuide(guide => ({
...guide,
total_duration: newDuration
}));
};
// Add new section
const addSection = () => {
const newSection: DiscussionGuideSection = {
id: `section_${Date.now()}`,
title: 'New Section',
questions: [],
activities: []
};
updateGuide(guide => ({
...guide,
sections: [...guide.sections, newSection]
}));
};
// Update section
const updateSection = useCallback((sectionId: string, updates: Partial<DiscussionGuideSection>) => {
updateGuide(guide => ({
...guide,
sections: guide.sections.map(section =>
section.id === sectionId ? { ...section, ...updates } : section
)
}));
}, [updateGuide]);
// Delete section
const deleteSection = useCallback((sectionId: string) => {
updateGuide(guide => ({
...guide,
sections: guide.sections.filter(section => section.id !== sectionId)
}));
}, [updateGuide]);
// Move section
const moveSection = useCallback((fromIndex: number, toIndex: number) => {
updateGuide(guide => {
const newSections = [...guide.sections];
const [movedSection] = newSections.splice(fromIndex, 1);
newSections.splice(toIndex, 0, movedSection);
return { ...guide, sections: newSections };
});
}, [updateGuide]);
// Move item within section
const moveItem = useCallback((sectionId: string, itemType: 'question' | 'activity', fromIndex: number, toIndex: number) => {
updateGuide(guide => ({
...guide,
sections: guide.sections.map(section => {
if (section.id === sectionId) {
const itemsKey = itemType === 'question' ? 'questions' : 'activities';
const items = section[itemsKey] || [];
const newItems = arrayMove(items, fromIndex, toIndex);
return { ...section, [itemsKey]: newItems };
}
return section;
})
}));
}, [updateGuide]);
// Handle drag end for all draggable items
const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) {
return;
}
const activeId = String(active.id);
const overId = String(over.id);
// Check if dragging sections
if (activeId.startsWith('section-') && overId.startsWith('section-')) {
const oldIndex = discussionGuide.sections.findIndex(section => `section-${section.id}` === activeId);
const newIndex = discussionGuide.sections.findIndex(section => `section-${section.id}` === overId);
if (oldIndex !== -1 && newIndex !== -1) {
moveSection(oldIndex, newIndex);
}
}
// Check if dragging items within a section
else if (activeId.includes('-item-') && overId.includes('-item-')) {
// Parse IDs to extract section, type, and item info
const activeMatch = activeId.match(/^(.*?)-(question|activity)-item-(.*)$/);
const overMatch = overId.match(/^(.*?)-(question|activity)-item-(.*)$/);
if (activeMatch && overMatch) {
const [, activeSectionId, activeItemType] = activeMatch;
const [, overSectionId, overItemType] = overMatch;
// Only allow reordering within the same section and same item type
if (activeSectionId === overSectionId && activeItemType === overItemType) {
const section = discussionGuide.sections.find(s => s.id === activeSectionId);
if (section) {
const itemsKey = activeItemType === 'question' ? 'questions' : 'activities';
const items = section[itemsKey] || [];
const oldIndex = items.findIndex(item => `${activeSectionId}-${activeItemType}-item-${item.id}` === activeId);
const newIndex = items.findIndex(item => `${overSectionId}-${overItemType}-item-${item.id}` === overId);
if (oldIndex !== -1 && newIndex !== -1) {
moveItem(activeSectionId, activeItemType as 'question' | 'activity', oldIndex, newIndex);
}
}
}
}
}
}, [discussionGuide.sections, moveSection, moveItem]);
// Add item to section
const addItem = useCallback((sectionId: string, itemType: 'question' | 'activity') => {
const newItem: DiscussionGuideItem = {
id: `${itemType}_${Date.now()}`,
type: itemType === 'question' ? 'open_question' : 'moderator_statement',
content: `New ${itemType}`,
probes: itemType === 'question' ? [] : undefined
};
updateGuide(guide => ({
...guide,
sections: guide.sections.map(section =>
section.id === sectionId
? {
...section,
[itemType === 'question' ? 'questions' : 'activities']: [
...(section[itemType === 'question' ? 'questions' : 'activities'] || []),
newItem
]
}
: section
)
}));
}, [updateGuide]);
// Update item
const updateItem = useCallback((sectionId: string, itemId: string, updates: Partial<DiscussionGuideItem>, itemType: 'question' | 'activity') => {
updateGuide(guide => ({
...guide,
sections: guide.sections.map(section =>
section.id === sectionId
? {
...section,
[itemType === 'question' ? 'questions' : 'activities']: section[itemType === 'question' ? 'questions' : 'activities']?.map(item =>
item.id === itemId ? { ...item, ...updates } : item
)
}
: section
)
}));
}, [updateGuide]);
// Delete item
const deleteItem = useCallback((sectionId: string, itemId: string, itemType: 'question' | 'activity') => {
updateGuide(guide => ({
...guide,
sections: guide.sections.map(section =>
section.id === sectionId
? {
...section,
[itemType === 'question' ? 'questions' : 'activities']: section[itemType === 'question' ? 'questions' : 'activities']?.filter(item => item.id !== itemId)
}
: section
)
}));
}, [updateGuide]);
// Functions to manage editing state
const startEditingItem = useCallback((itemId: string) => {
setEditingItem(itemId);
}, []);
const stopEditingItem = useCallback(() => {
setEditingItem(null);
}, []);
// Helper function to check if content is default placeholder text
const isDefaultPlaceholderContent = (content: string, itemType: 'question' | 'activity'): boolean => {
return content === `New ${itemType}`;
};
// Sortable Item wrapper
const SortableItem: React.FC<{
id: string;
item: DiscussionGuideItem;
sectionId: string;
itemType: 'question' | 'activity';
isEditing: boolean;
initialContent: string;
initialProbes: string;
onStartEdit: () => void;
onSave: (updates: Partial<DiscussionGuideItem>) => void;
onCancel: () => void;
onDelete: () => void;
onUpdateItem: (updates: Partial<DiscussionGuideItem>) => void;
}> = ({ id, item, sectionId, itemType, isEditing, initialContent, initialProbes, onStartEdit, onSave, onCancel, onDelete, onUpdateItem }) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
zIndex: isDragging ? 1000 : 'auto',
cursor: isDragging ? 'grabbing' : 'grab',
};
return (
<div ref={setNodeRef} style={style}>
<EditableItem
item={item}
sectionId={sectionId}
itemType={itemType}
isEditing={isEditing}
initialContent={initialContent}
initialProbes={initialProbes}
onStartEdit={onStartEdit}
onSave={onSave}
onCancel={onCancel}
onDelete={onDelete}
onUpdateItem={onUpdateItem}
dragHandleProps={{ ...attributes, ...listeners }}
/>
</div>
);
};
// EditableItem component - moved outside to prevent re-mounting issues
const EditableItem: React.FC<{
item: DiscussionGuideItem;
sectionId: string;
itemType: 'question' | 'activity';
isEditing: boolean;
initialContent: string;
initialProbes: string;
onStartEdit: () => void;
onSave: (updates: Partial<DiscussionGuideItem>) => void;
onCancel: () => void;
onDelete: () => void;
onUpdateItem: (updates: Partial<DiscussionGuideItem>) => void;
dragHandleProps?: Record<string, unknown>;
}> = React.memo(({
item,
sectionId,
itemType,
isEditing,
initialContent,
initialProbes,
onStartEdit,
onSave,
onCancel,
onDelete,
onUpdateItem,
dragHandleProps
}) => {
// Local state for editing to prevent parent re-renders
const [localEditContent, setLocalEditContent] = useState(initialContent);
const [localEditProbes, setLocalEditProbes] = useState(initialProbes);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Check if this is a default placeholder item
const isPlaceholder = isDefaultPlaceholderContent(initialContent, itemType);
// Update local state when editing starts
useEffect(() => {
if (isEditing) {
// If this is a placeholder item, start with empty content so user can type immediately
setLocalEditContent(isPlaceholder ? '' : initialContent);
setLocalEditProbes(initialProbes);
// Focus the textarea when editing starts
setTimeout(() => {
textareaRef.current?.focus();
}, 0);
}
}, [isEditing, initialContent, initialProbes, isPlaceholder]);
const handleSave = () => {
const updates = {
content: localEditContent,
probes: localEditProbes.trim() ? localEditProbes.split('\n').filter(p => p.trim()) : undefined
};
onSave(updates);
};
const handleCancel = () => {
onCancel();
};
return (
<Card key={item.id} className="mb-3">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<div
className="flex-shrink-0 cursor-grab hover:cursor-grab active:cursor-grabbing hover:text-slate-700 transition-colors p-1.5 rounded hover:bg-slate-200 border border-transparent hover:border-slate-300"
{...dragHandleProps}
title="Drag to reorder this item"
>
<GripVertical className="h-4 w-4 text-slate-500 hover:text-slate-700" />
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<Badge variant="outline" className="text-xs">
{itemType === 'activity' ? (
<>
<Activity className="h-3 w-3 mr-1" />
{item.type.replace('_', ' ')}
</>
) : (
<>
<MessageCircle className="h-3 w-3 mr-1" />
{item.type.replace('_', ' ')}
</>
)}
</Badge>
<Select
value={item.type}
onValueChange={(value) => onUpdateItem({ type: value })}
>
<SelectTrigger className="w-40 h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{itemType === 'question' ? (
<>
<SelectItem value="open_question">Open Question</SelectItem>
<SelectItem value="probe_question">Probe Question</SelectItem>
<SelectItem value="follow_up">Follow Up</SelectItem>
<SelectItem value="ranking">Ranking</SelectItem>
<SelectItem value="comparison">Comparison</SelectItem>
</>
) : (
<>
<SelectItem value="moderator_statement">Moderator Statement</SelectItem>
<SelectItem value="instruction">Instruction</SelectItem>
<SelectItem value="creative_exercise">Creative Exercise</SelectItem>
<SelectItem value="discussion_prompt">Discussion Prompt</SelectItem>
</>
)}
</SelectContent>
</Select>
{item.time_limit && (
<div className="flex items-center gap-1 text-xs text-slate-500">
<Clock className="h-3 w-3" />
<Input
type="number"
value={item.time_limit}
onChange={(e) => onUpdateItem({ time_limit: parseInt(e.target.value) })}
className="w-16 h-6 text-xs"
/>
min
</div>
)}
</div>
{isEditing ? (
<div className="space-y-3">
<Textarea
ref={textareaRef}
value={localEditContent}
onChange={(e) => {
setLocalEditContent(e.target.value);
}}
placeholder={isPlaceholder ? initialContent : "Enter content..."}
className="min-h-[80px]"
/>
{itemType === 'question' && (
<div>
<label className="text-sm font-medium text-slate-700 mb-1 block">
Probe Questions (one per line)
</label>
<Textarea
value={localEditProbes}
onChange={(e) => setLocalEditProbes(e.target.value)}
placeholder="Enter probe questions, one per line..."
className="min-h-[60px]"
/>
</div>
)}
<div className="flex gap-2">
<Button size="sm" onClick={handleSave}>
<Check className="h-3 w-3 mr-1" />
Save
</Button>
<Button size="sm" variant="outline" onClick={handleCancel}>
<X className="h-3 w-3 mr-1" />
Cancel
</Button>
</div>
</div>
) : (
<div>
<p className="text-sm text-slate-700 mb-2">{item.content}</p>
{item.probes && item.probes.length > 0 && (
<div className="space-y-1">
<p className="text-xs font-medium text-slate-600">Probes:</p>
<ul className="text-xs text-slate-600 space-y-1">
{item.probes.map((probe, idx) => (
<li key={idx} className="flex items-start gap-1">
<span className="text-slate-400"></span>
<span>{probe}</span>
</li>
))}
</ul>
</div>
)}
</div>
)}
</div>
<div className="flex-shrink-0 flex gap-1">
{!isEditing && (
<Button
size="sm"
variant="ghost"
onClick={onStartEdit}
className="h-8 w-8 p-0"
>
<Edit3 className="h-3 w-3" />
</Button>
)}
<Button
size="sm"
variant="ghost"
onClick={onDelete}
className="h-8 w-8 p-0 text-red-600 hover:text-red-700"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
</CardContent>
</Card>
);
}, (prevProps, nextProps) => {
// Custom comparison to prevent unnecessary re-renders
// Only re-render if key props change, not on every content edit
const shouldSkipRender = prevProps.item.id === nextProps.item.id &&
prevProps.isEditing === nextProps.isEditing &&
prevProps.initialContent === nextProps.initialContent &&
prevProps.initialProbes === nextProps.initialProbes &&
prevProps.sectionId === nextProps.sectionId &&
prevProps.itemType === nextProps.itemType &&
JSON.stringify(prevProps.item) === JSON.stringify(nextProps.item);
return shouldSkipRender;
});
// EditableSection component - moved outside to prevent re-mounting
const EditableSection: React.FC<{
section: DiscussionGuideSection;
sectionIndex: number;
isOpen: boolean;
editingItem: string | null;
onToggleSection: (sectionId: string) => void;
onUpdateSection: (sectionId: string, updates: Partial<DiscussionGuideSection>) => void;
onDeleteSection: (sectionId: string) => void;
onAddItem: (sectionId: string, itemType: 'question' | 'activity') => void;
onStartEditingItem: (itemId: string) => void;
onStopEditingItem: () => void;
onUpdateItem: (sectionId: string, itemId: string, updates: Partial<DiscussionGuideItem>, itemType: 'question' | 'activity') => void;
onDeleteItem: (sectionId: string, itemId: string, itemType: 'question' | 'activity') => void;
dragHandleProps?: Record<string, unknown>;
}> = React.memo(({
section,
sectionIndex,
isOpen,
editingItem,
onToggleSection,
onUpdateSection,
onDeleteSection,
onAddItem,
onStartEditingItem,
onStopEditingItem,
onUpdateItem,
onDeleteItem,
dragHandleProps
}) => {
const [editingTitle, setEditingTitle] = useState(false);
const [tempTitle, setTempTitle] = useState(section.title);
const handleTitleSave = () => {
onUpdateSection(section.id, { title: tempTitle });
setEditingTitle(false);
};
const handleTitleCancel = () => {
setTempTitle(section.title);
setEditingTitle(false);
};
return (
<Card key={section.id} className="mb-4">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 flex-1">
<div
className="cursor-grab hover:cursor-grab active:cursor-grabbing hover:text-slate-700 transition-colors p-1.5 rounded hover:bg-slate-200 border border-transparent hover:border-slate-300"
{...dragHandleProps}
title="Drag to reorder section"
>
<GripVertical className="h-5 w-5 text-slate-500 hover:text-slate-700" />
</div>
<div className="flex-1 flex items-center gap-2">
{editingTitle ? (
<div className="flex items-center gap-2 flex-1">
<Input
value={tempTitle}
onChange={(e) => setTempTitle(e.target.value)}
className="flex-1"
/>
<Button size="sm" onClick={handleTitleSave}>
<Check className="h-3 w-3" />
</Button>
<Button size="sm" variant="outline" onClick={handleTitleCancel}>
<X className="h-3 w-3" />
</Button>
</div>
) : (
<>
<h3
className="font-semibold text-lg cursor-pointer hover:text-blue-600"
onClick={() => setEditingTitle(true)}
>
{section.title}
</h3>
<Button
size="sm"
variant="ghost"
onClick={() => setEditingTitle(true)}
className="h-6 w-6 p-0"
>
<Edit3 className="h-3 w-3" />
</Button>
</>
)}
</div>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="ghost"
onClick={() => onDeleteSection(section.id)}
className="h-8 w-8 p-0 text-red-600 hover:text-red-700"
>
<Trash2 className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => onToggleSection(section.id)}
className="h-8 w-8 p-0"
>
{isOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</Button>
</div>
</div>
</CardHeader>
<Collapsible open={isOpen} onOpenChange={() => onToggleSection(section.id)}>
<CollapsibleContent>
<CardContent className="pt-0">
{section.content && (
<div className="mb-4">
<label className="text-sm font-medium text-slate-700 mb-1 block">
Section Description
</label>
<Textarea
value={section.content}
onChange={(e) => onUpdateSection(section.id, { content: e.target.value })}
className="min-h-[60px]"
/>
</div>
)}
{/* Activities */}
<div className="mb-4">
<div className="flex items-center justify-between mb-2">
<h4 className="font-medium text-slate-700 flex items-center gap-2">
<Activity className="h-4 w-4" />
Activities
</h4>
<Button size="sm" onClick={() => onAddItem(section.id, 'activity')}>
<Plus className="h-3 w-3 mr-1" />
Add Activity
</Button>
</div>
<SortableContext
items={section.activities?.map(activity => `${section.id}-activity-item-${activity.id}`) || []}
strategy={verticalListSortingStrategy}
>
<div className="space-y-2">
{section.activities?.map(activity =>
<SortableItem
key={activity.id}
id={`${section.id}-activity-item-${activity.id}`}
item={activity}
sectionId={section.id}
itemType="activity"
isEditing={editingItem === activity.id}
initialContent={activity.content}
initialProbes={''}
onStartEdit={() => onStartEditingItem(activity.id)}
onSave={(updates) => {
onUpdateItem(section.id, activity.id, updates, 'activity');
onStopEditingItem();
}}
onCancel={() => onStopEditingItem()}
onDelete={() => onDeleteItem(section.id, activity.id, 'activity')}
onUpdateItem={(updates) => onUpdateItem(section.id, activity.id, updates, 'activity')}
/>
)}
</div>
</SortableContext>
</div>
{/* Questions */}
<div>
<div className="flex items-center justify-between mb-2">
<h4 className="font-medium text-slate-700 flex items-center gap-2">
<MessageCircle className="h-4 w-4" />
Questions
</h4>
<Button size="sm" onClick={() => onAddItem(section.id, 'question')}>
<Plus className="h-3 w-3 mr-1" />
Add Question
</Button>
</div>
<SortableContext
items={section.questions?.map(question => `${section.id}-question-item-${question.id}`) || []}
strategy={verticalListSortingStrategy}
>
<div className="space-y-2">
{section.questions?.map(question =>
<SortableItem
key={question.id}
id={`${section.id}-question-item-${question.id}`}
item={question}
sectionId={section.id}
itemType="question"
isEditing={editingItem === question.id}
initialContent={question.content}
initialProbes={question.probes?.join('\n') || ''}
onStartEdit={() => onStartEditingItem(question.id)}
onSave={(updates) => {
onUpdateItem(section.id, question.id, updates, 'question');
onStopEditingItem();
}}
onCancel={() => onStopEditingItem()}
onDelete={() => onDeleteItem(section.id, question.id, 'question')}
onUpdateItem={(updates) => onUpdateItem(section.id, question.id, updates, 'question')}
/>
)}
</div>
</SortableContext>
</div>
</CardContent>
</CollapsibleContent>
</Collapsible>
</Card>
);
}, (prevProps, nextProps) => {
// Skip re-render if props haven't changed
const shouldSkipRender =
prevProps.section.id === nextProps.section.id &&
JSON.stringify(prevProps.section) === JSON.stringify(nextProps.section) &&
prevProps.sectionIndex === nextProps.sectionIndex &&
prevProps.isOpen === nextProps.isOpen &&
prevProps.editingItem === nextProps.editingItem;
return shouldSkipRender;
});
// Sortable Section wrapper
const SortableSection: React.FC<{
id: string;
section: DiscussionGuideSection;
sectionIndex: number;
isOpen: boolean;
editingItem: string | null;
onToggleSection: (sectionId: string) => void;
onUpdateSection: (sectionId: string, updates: Partial<DiscussionGuideSection>) => void;
onDeleteSection: (sectionId: string) => void;
onAddItem: (sectionId: string, itemType: 'question' | 'activity') => void;
onStartEditingItem: (itemId: string) => void;
onStopEditingItem: () => void;
onUpdateItem: (sectionId: string, itemId: string, updates: Partial<DiscussionGuideItem>, itemType: 'question' | 'activity') => void;
onDeleteItem: (sectionId: string, itemId: string, itemType: 'question' | 'activity') => void;
}> = ({ id, section, sectionIndex, isOpen, editingItem, onToggleSection, onUpdateSection, onDeleteSection, onAddItem, onStartEditingItem, onStopEditingItem, onUpdateItem, onDeleteItem }) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
zIndex: isDragging ? 1000 : 'auto',
cursor: isDragging ? 'grabbing' : 'grab',
};
return (
<div ref={setNodeRef} style={style}>
<EditableSection
section={section}
sectionIndex={sectionIndex}
isOpen={isOpen}
editingItem={editingItem}
onToggleSection={onToggleSection}
onUpdateSection={onUpdateSection}
onDeleteSection={onDeleteSection}
onAddItem={onAddItem}
onStartEditingItem={onStartEditingItem}
onStopEditingItem={onStopEditingItem}
onUpdateItem={onUpdateItem}
onDeleteItem={onDeleteItem}
dragHandleProps={{ ...attributes, ...listeners }}
/>
</div>
);
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div>
<label className="text-sm font-medium text-slate-700 mb-1 block">
Discussion Guide Title
</label>
<Input
value={discussionGuide.title}
onChange={(e) => updateTitle(e.target.value)}
className="w-64"
/>
</div>
<div>
<label className="text-sm font-medium text-slate-700 mb-1 block">
Total Duration (minutes)
</label>
<Input
type="number"
value={discussionGuide.total_duration}
onChange={(e) => updateTotalDuration(parseInt(e.target.value))}
className="w-24"
readOnly
disabled
title="Duration cannot be modified after generation"
/>
</div>
</div>
</div>
{/* Sections */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold">Sections</h2>
<Button onClick={addSection}>
<Plus className="h-4 w-4 mr-2" />
Add Section
</Button>
</div>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={discussionGuide.sections.map(section => `section-${section.id}`)}
strategy={verticalListSortingStrategy}
>
{discussionGuide.sections.map((section, index) =>
<SortableSection
key={section.id}
id={`section-${section.id}`}
section={section}
sectionIndex={index}
isOpen={openSections.has(section.id)}
editingItem={editingItem}
onToggleSection={toggleSection}
onUpdateSection={updateSection}
onDeleteSection={deleteSection}
onAddItem={addItem}
onStartEditingItem={startEditingItem}
onStopEditingItem={stopEditingItem}
onUpdateItem={updateItem}
onDeleteItem={deleteItem}
/>
)}
</SortableContext>
</DndContext>
</div>
</div>
);
});
export default DiscussionGuideEditor;

File diff suppressed because it is too large Load diff

View file

@ -59,6 +59,11 @@ const DiscussionPanel = ({
const fileInputRef = useRef<HTMLInputElement>(null);
// Track the last persona who responded for round-robin approach
const [lastRespondentIndex, setLastRespondentIndex] = useState(-1);
// Track when we're expecting new messages to arrive
const [isExpectingMessage, setIsExpectingMessage] = useState(false);
const previousMessageCountRef = useRef(0);
const loadingStartTimeRef = useRef<number | null>(null);
const minimumLoadingDurationRef = useRef(10000); // 10 seconds minimum
const messagesEndRef = useRef<HTMLDivElement>(null);
// Disabled auto-scroll by default
const [autoScroll, setAutoScroll] = useState(false);
@ -142,6 +147,45 @@ const DiscussionPanel = ({
};
}, [isAiModeActive, focusGroupId]);
// Initialize message count reference
useEffect(() => {
previousMessageCountRef.current = messages.length;
}, []);
// Detect when new messages arrive and clear loading state
useEffect(() => {
const currentMessageCount = messages.length;
const previousCount = previousMessageCountRef.current;
// If we're expecting a message and the count increased, check minimum duration
if (isExpectingMessage && currentMessageCount > previousCount) {
const now = Date.now();
const loadingStartTime = loadingStartTimeRef.current;
if (loadingStartTime && (now - loadingStartTime) >= minimumLoadingDurationRef.current) {
// Minimum duration has passed, safe to clear loading
setIsTyping(false);
setIsExpectingMessage(false);
loadingStartTimeRef.current = null;
} else if (loadingStartTime) {
// Wait for minimum duration to complete
const remainingTime = minimumLoadingDurationRef.current - (now - loadingStartTime);
setTimeout(() => {
setIsTyping(false);
setIsExpectingMessage(false);
loadingStartTimeRef.current = null;
}, Math.max(0, remainingTime));
} else {
// No start time recorded, clear immediately
setIsTyping(false);
setIsExpectingMessage(false);
}
}
// Update the reference for next comparison
previousMessageCountRef.current = currentMessageCount;
}, [messages.length, isExpectingMessage]);
// Get persona info by ID
const getPersona = (id: string) => {
return personas.find(p => p.id === id || p._id === id);
@ -238,6 +282,8 @@ const DiscussionPanel = ({
setUserInput('');
setCurrentMentions(null);
setIsTyping(true);
setIsExpectingMessage(true);
loadingStartTimeRef.current = Date.now();
try {
// Handle file upload if a file is selected
@ -328,14 +374,14 @@ const DiscussionPanel = ({
);
}, 500);
} else {
// Just stop the typing indicator without auto-responding
setTimeout(() => {
setIsTyping(false);
}, 1000);
// No mentions to process - let useEffect clear loading state when message appears
// (Don't manually clear isTyping here since message might be delayed by polling)
}
} catch (error) {
console.error('Error sending message:', error);
setIsTyping(false);
setIsExpectingMessage(false);
loadingStartTimeRef.current = null;
// Create fallback message with original text
const fallbackMessage: Message = {
@ -520,6 +566,8 @@ const DiscussionPanel = ({
try {
setIsTyping(true);
setIsExpectingMessage(true);
loadingStartTimeRef.current = Date.now();
toast.info("Advancing discussion...", {
description: "Moving to the next question in the discussion guide."
@ -621,9 +669,12 @@ const DiscussionPanel = ({
toast.error("Failed to advance discussion", {
description: error.message || "There was a problem advancing to the next question."
});
} finally {
// Clear loading state immediately on error since no message is expected
setIsTyping(false);
setIsExpectingMessage(false);
loadingStartTimeRef.current = null;
}
// Note: Don't clear loading in finally block - let message arrival handle it
};
// Start autonomous conversation
@ -825,6 +876,8 @@ const DiscussionPanel = ({
try {
setIsTyping(true);
setIsExpectingMessage(true);
loadingStartTimeRef.current = Date.now();
toast.info("Generating responses from mentioned participants...", {
description: `Generating responses from ${mentionedParticipantIds.length} mentioned participant(s).`
@ -875,9 +928,12 @@ const DiscussionPanel = ({
} catch (error) {
console.error('Error generating mentioned responses:', error);
toast.error('Failed to generate responses from mentioned participants');
} finally {
// Clear loading state immediately on error since no messages are expected
setIsTyping(false);
setIsExpectingMessage(false);
loadingStartTimeRef.current = null;
}
// Note: Don't clear loading in finally block - let message arrival handle it
};
// Generate an AI response using intelligent participant selection
@ -894,6 +950,8 @@ const DiscussionPanel = ({
try {
setIsTyping(true);
setIsExpectingMessage(true);
loadingStartTimeRef.current = Date.now();
// Use AI to decide which participant should respond next
toast.info("AI is selecting participant...", {
@ -1014,9 +1072,12 @@ const DiscussionPanel = ({
toast.error("Failed to generate AI response", {
description: "There was a problem connecting to the server."
});
} finally {
// Clear loading state immediately on error since no message is expected
setIsTyping(false);
setIsExpectingMessage(false);
loadingStartTimeRef.current = null;
}
// Note: Don't clear loading in finally block - let message arrival handle it
};
return (

View file

@ -15,6 +15,8 @@ const ModeSwitchMarker = ({ modeEvent }: ModeSwitchMarkerProps) => {
return 'AI Mode Started';
case 'manual_mode_started':
return 'Manual Moderation Enabled';
case 'ai_session_concluded':
return 'AI Discussion Concluded';
default:
return 'Mode Changed';
}
@ -26,6 +28,8 @@ const ModeSwitchMarker = ({ modeEvent }: ModeSwitchMarkerProps) => {
return 'text-blue-600';
case 'manual_mode_started':
return 'text-slate-600';
case 'ai_session_concluded':
return 'text-green-600';
default:
return 'text-gray-600';
}

View file

@ -72,7 +72,7 @@ export interface Note {
export interface ModeEvent {
id: string;
focus_group_id: string;
event_type: 'ai_mode_started' | 'manual_mode_started';
event_type: 'ai_mode_started' | 'manual_mode_started' | 'ai_session_concluded';
timestamp: Date;
user_id?: string | null;
created_at: Date;

View file

@ -0,0 +1,69 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
export interface NavigationState {
previousRoute?: string;
focusGroupId?: string;
focusGroupTab?: 'setup' | 'review' | 'participants';
isNewFocusGroup?: boolean;
focusGroupData?: any;
}
interface NavigationContextType {
navigationState: NavigationState;
setNavigationState: (state: NavigationState) => void;
clearNavigationState: () => void;
setPreviousRoute: (route: string, additionalData?: Partial<NavigationState>) => void;
}
const NavigationContext = createContext<NavigationContextType | undefined>(undefined);
const NAVIGATION_STORAGE_KEY = 'synthetic-society-navigation-state';
export const NavigationProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [navigationState, setNavigationState] = useState<NavigationState>(() => {
try {
const stored = localStorage.getItem(NAVIGATION_STORAGE_KEY);
return stored ? JSON.parse(stored) : {};
} catch {
return {};
}
});
useEffect(() => {
localStorage.setItem(NAVIGATION_STORAGE_KEY, JSON.stringify(navigationState));
}, [navigationState]);
const setPreviousRoute = (route: string, additionalData?: Partial<NavigationState>) => {
setNavigationState({
...navigationState,
previousRoute: route,
...additionalData
});
};
const clearNavigationState = () => {
setNavigationState({});
localStorage.removeItem(NAVIGATION_STORAGE_KEY);
};
return (
<NavigationContext.Provider
value={{
navigationState,
setNavigationState,
clearNavigationState,
setPreviousRoute
}}
>
{children}
</NavigationContext.Provider>
);
};
export const useNavigation = (): NavigationContextType => {
const context = useContext(NavigationContext);
if (!context) {
throw new Error('useNavigation must be used within a NavigationProvider');
}
return context;
};

View file

@ -5,6 +5,7 @@ import { GENERATED_PERSONAS_KEY } from '@/hooks/usePersonaStorage';
import { useParams, useLocation, useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import { personasApi } from '@/lib/api';
import { useNavigation } from '@/contexts/NavigationContext';
// Sample user data for fallback/demo purposes
const sampleUsers: Persona[] = [
@ -549,6 +550,7 @@ export function usePersonaDetails() {
const { id } = useParams<{ id: string }>();
const location = useLocation();
const navigate = useNavigate();
const { navigationState, clearNavigationState } = useNavigation();
const [currentPersona, setCurrentPersona] = useState<Persona | undefined>(undefined);
const [isFromReview, setIsFromReview] = useState(false);
const [isEditing, setIsEditing] = useState(false);
@ -621,9 +623,24 @@ export function usePersonaDetails() {
}, [id, location.search]);
const handleGoBack = () => {
if (isFromReview) {
// Check if we came from focus group editing
if (navigationState.previousRoute === '/focus-groups' && navigationState.focusGroupTab) {
// Navigate back to focus group editing with the specified tab
if (navigationState.isNewFocusGroup) {
// For new focus groups, go to focus-groups page in create mode with the specified tab
navigate(`/focus-groups?mode=create&tab=${navigationState.focusGroupTab}`);
} else if (navigationState.focusGroupId) {
// For existing focus groups, go to the specific focus group editing with the tab
navigate(`/focus-groups?mode=edit&id=${navigationState.focusGroupId}&tab=${navigationState.focusGroupTab}`);
} else {
// Fallback to focus groups page in create mode with participants tab
navigate('/focus-groups?mode=create&tab=participants');
}
} 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
navigate('/synthetic-users');
}
};

View file

@ -182,6 +182,11 @@
@apply ml-7 text-sm text-muted-foreground;
}
.sidebar-sub-item::before {
content: "•";
@apply text-slate-400 mr-2 -ml-3;
}
/* Persona card overlay styles */
.persona-card {
@apply relative overflow-hidden;

View file

@ -50,6 +50,11 @@ const FocusGroupSession = () => {
const [isEditingDiscussionGuide, setIsEditingDiscussionGuide] = useState(false);
const isEditingDiscussionGuideRef = useRef(false);
// Track discussion guide editing state to prevent focus loss
const [isEditingGuideContent, setIsEditingGuideContent] = useState(false);
const focusGroupRef = useRef(focusGroup);
focusGroupRef.current = focusGroup;
// Notes-related state
const [notes, setNotes] = useState<Note[]>([]);
@ -1202,6 +1207,12 @@ const FocusGroupSession = () => {
// Handler for saving discussion guide changes
const handleDiscussionGuideSave = useCallback(async (updatedGuide: any) => {
console.log('💾 handleDiscussionGuideSave called:', {
hasId: !!id,
isEditingGuideContent,
timestamp: new Date().toISOString()
});
if (!id) return;
try {
@ -1209,17 +1220,48 @@ const FocusGroupSession = () => {
discussionGuide: updatedGuide
});
// Update local state
setFocusGroup(prev => prev ? {
...prev,
discussionGuide: updatedGuide
} : null);
// Only update local state if we're not currently editing to prevent focus loss
if (!isEditingGuideContent) {
console.log('🔄 Updating focus group state (not editing)');
setFocusGroup(prev => prev ? {
...prev,
discussionGuide: updatedGuide
} : null);
} else {
// During editing, update the ref so we have the latest version
if (focusGroupRef.current) {
focusGroupRef.current = {
...focusGroupRef.current,
discussionGuide: updatedGuide
};
}
console.log('⚠️ Skipping focus group state update during editing to preserve focus');
}
} catch (error) {
console.error('Error saving discussion guide:', error);
throw error; // Re-throw to let the component handle the error display
}
}, [id]);
}, [id, isEditingGuideContent]);
// Handle editing state changes from DiscussionGuideViewer
const handleGuideEditingStateChange = useCallback((editing: boolean) => {
console.log('🔄 handleGuideEditingStateChange called:', {
editing,
timestamp: new Date().toISOString(),
currentIsEditingGuideContent: isEditingGuideContent
});
// Update both editing states
setIsEditingDiscussionGuide(editing); // For scroll prevention
setIsEditingGuideContent(editing); // For focus preservation
// When editing ends, update the focus group state with the latest version
if (!editing && focusGroupRef.current) {
console.log('📝 Updating focus group state after editing ended');
setFocusGroup(focusGroupRef.current);
}
}, [isEditingGuideContent]);
// Handler for toggling discussion guide
const handleToggleDiscussionGuide = useCallback(() => {
@ -1549,7 +1591,7 @@ const FocusGroupSession = () => {
focusGroupId={id || ''}
isOpen={isDiscussionGuideOpen}
onToggle={handleToggleDiscussionGuide}
onEditingChange={setIsEditingDiscussionGuide}
onEditingChange={handleGuideEditingStateChange}
/>
<div className="flex flex-col lg:flex-row gap-6 h-[calc(100vh-12rem)]">

View file

@ -1,6 +1,6 @@
import React, { useState, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate, useLocation } from 'react-router-dom';
import Navigation from '@/components/Navigation';
import FocusGroupModerator from '@/components/FocusGroupModerator';
import { Button } from '@/components/ui/button';
@ -99,6 +99,8 @@ const FocusGroups = () => {
const [isDeletingGroups, setIsDeletingGroups] = useState(false);
const [draftToEdit, setDraftToEdit] = useState<FocusGroup | null>(null);
const navigate = useNavigate();
const location = useLocation();
const [preSelectedParticipants, setPreSelectedParticipants] = useState<string[]>([]);
// Use a ref to track component mounted state
const isMounted = useRef(true);
@ -146,6 +148,20 @@ const FocusGroups = () => {
}
}
};
// Fetch a specific focus group for editing
const fetchFocusGroupForEdit = async (focusGroupId: string) => {
try {
const response = await focusGroupsApi.getById(focusGroupId);
if (response && response.data) {
setDraftToEdit(response.data);
setMode('create'); // Use create mode for editing
}
} catch (error) {
console.error('Error fetching focus group for edit:', error);
toastService.error("Failed to load focus group for editing");
}
};
useEffect(() => {
console.log('useEffect running - about to fetch focus groups');
@ -168,6 +184,50 @@ const FocusGroups = () => {
}
}, [mode]);
// Handle navigation state for pre-selected participants
useEffect(() => {
const state = location.state as { mode?: string; preSelectedParticipants?: string[] } | null;
if (state?.mode === 'create' && state?.preSelectedParticipants) {
setPreSelectedParticipants(state.preSelectedParticipants);
setMode('create');
// Clear the navigation state to prevent re-triggering
navigate(location.pathname, { replace: true, state: null });
}
}, [location.state, location.pathname, navigate]);
// Handle URL query parameters for navigation from persona details
useEffect(() => {
const searchParams = new URLSearchParams(location.search);
const urlMode = searchParams.get('mode');
const focusGroupId = searchParams.get('id');
const tab = searchParams.get('tab');
if (urlMode === 'create') {
// For new focus group creation, switch to create mode
setMode('create');
setDraftToEdit(null);
} else if (urlMode === 'edit' && focusGroupId) {
// For editing existing focus group, find and load the draft
const foundGroup = focusGroups.find(group =>
(group._id || group.id) === focusGroupId
);
if (foundGroup) {
setDraftToEdit(foundGroup);
setMode('create'); // Use create mode for editing
} else {
// If group not found, try to fetch it from API
fetchFocusGroupForEdit(focusGroupId);
}
}
// Clear URL parameters after processing to avoid re-triggering
if (urlMode || focusGroupId || tab) {
const newUrl = location.pathname;
navigate(newUrl, { replace: true });
}
}, [location.search, focusGroups, navigate, location.pathname]);
const filteredGroups = focusGroups.filter(group =>
group.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
group.topic.toLowerCase().includes(searchTerm.toLowerCase())
@ -466,9 +526,11 @@ const FocusGroups = () => {
) : (
<FocusGroupModerator
draftToEdit={draftToEdit}
preSelectedParticipants={preSelectedParticipants}
onDraftSaved={() => {
setDraftToEdit(null);
setMode('view');
setPreSelectedParticipants([]);
fetchFocusGroups();
}}
/>

View file

@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate, useSearchParams } from 'react-router-dom';
import Navigation from '@/components/Navigation';
import AIRecruiter from '@/components/AIRecruiter';
import UserCreator from '@/components/UserCreator';
@ -8,7 +8,7 @@ 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 } from 'lucide-react';
import { Search, Filter, Users, FolderPlus, Folder, MoreHorizontal, Plus, Check, X, Trash2, Download, MessageSquare } from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
@ -59,6 +59,7 @@ interface FilterState {
location: string[];
techSavviness: string[];
ethnicity: string[];
folderStatus: string[];
}
const SyntheticUsers = () => {
@ -70,6 +71,7 @@ const SyntheticUsers = () => {
}
}, []); // Empty dependency array because it has no external dependencies
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { loadPersonas } = usePersonaStorage();
const [mode, setMode] = useState<'view' | 'create'>('view');
@ -79,6 +81,14 @@ const SyntheticUsers = () => {
const [selectedFolder, setSelectedFolder] = useState<string>(DEFAULT_FOLDER_ID);
const [isCreatingFolder, setIsCreatingFolder] = useState(false);
const [newFolderName, setNewFolderName] = useState('');
// Handle URL parameters to set mode
useEffect(() => {
const modeParam = searchParams.get('mode');
if (modeParam === 'view' || modeParam === 'create') {
setMode(modeParam);
}
}, [searchParams]);
const [allPersonas, setAllPersonas] = useState<Persona[]>([]);
const [folders, setFolders] = useState<Folder[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
@ -100,6 +110,7 @@ const SyntheticUsers = () => {
location: [],
techSavviness: [],
ethnicity: [],
folderStatus: [],
});
// Working copy of filter state for the dialog
const [workingFilters, setWorkingFilters] = useState<FilterState>({
@ -109,6 +120,7 @@ const SyntheticUsers = () => {
location: [],
techSavviness: [],
ethnicity: [],
folderStatus: [],
});
// Progress monitoring state for persona summary generation
const [isSummaryGenerating, setIsSummaryGenerating] = useState(false);
@ -180,6 +192,7 @@ const SyntheticUsers = () => {
location: [],
techSavviness: [],
ethnicity: [],
folderStatus: [],
});
};
@ -203,6 +216,7 @@ const SyntheticUsers = () => {
location: [],
techSavviness: [],
ethnicity: [],
folderStatus: [],
});
};
@ -867,7 +881,17 @@ const SyntheticUsers = () => {
persona.techSavviness < 70 ? 'Medium (31-70)' :
'High (71-100)'
))) &&
true;
// 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.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))
);
// First check if the selected folder is "All Personas"
if (selectedFolder === DEFAULT_FOLDER_ID) {
@ -1343,6 +1367,23 @@ true;
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) => {
@ -1817,6 +1858,39 @@ true;
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>