semblance/src/components/FocusGroupModerator.tsx
michael 4bf325483e Fix tab not switching to Review after discussion guide generation
The useEffect that reverts to Setup tab on cancellation was incorrectly
firing after successful generation. Added check for discussionGuide to
only revert when generation was actually cancelled (no guide exists).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 08:51:37 -06:00

2396 lines
96 KiB
TypeScript

import React, { useState, useEffect, useCallback, useRef } from 'react';
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";
import {
MessageSquare,
Upload,
RefreshCw,
AlertCircle,
List,
Users,
FileText,
Play,
UploadCloud,
Loader2,
Search,
Filter,
Folder,
FolderPlus,
MoreHorizontal,
Plus,
Check,
X,
Download,
Info,
Copy
} from 'lucide-react';
import { toast } from 'sonner';
import { personasApi, focusGroupsApi, foldersApi } from '@/lib/api';
import { useCancellableGeneration } from '@/hooks/useCancellableGeneration';
import { getSocket } from '@/services/websocketServiceNew';
import ProgressModal from '@/components/ui/ProgressModal';
import DiscussionGuideViewer from './focus-group-session/DiscussionGuideViewer';
import AssetUploader from '@/components/AssetUploader';
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogFooter,
} from "@/components/ui/dialog";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu';
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import UserCard from "@/components/UserCard";
import { Persona } from "@/types/persona";
// Define folder interface (database-compatible)
interface Folder {
_id: string;
id?: string; // Legacy compatibility
name: string;
created_at?: string;
created_by?: string;
updated_at?: string;
}
// Default folder ID for "All Personas"
const DEFAULT_FOLDER_ID = 'all';
// Define filter state interface
interface FilterState {
age: string[];
gender: string[];
occupation: string[];
location: string[];
techSavviness: string[];
ethnicity: string[];
}
const formSchema = z.object({
researchBrief: z.string().min(10, {
message: "Research brief must be at least 10 characters.",
}),
focusGroupName: z.string().min(3, {
message: "Focus group name must be at least 3 characters.",
}),
discussionTopics: z.string().min(10, {
message: "Discussion topics are required.",
}),
duration: z.string().min(1, {
message: "Duration is required.",
}),
llm_model: z.string().optional(),
reasoning_effort: z.string().optional(),
verbosity: z.string().optional(),
});
// Sample discussion guide sections - We'll keep this but fetch real personas from the database
// Sample discussion guide sections
const sampleGuide = {
introduction: "Welcome to our focus group discussion. Today we'll be exploring your experiences and opinions on [product/service]. There are no right or wrong answers, we're just interested in your honest thoughts.",
warmup: "Let's start by introducing ourselves and sharing a bit about your background and daily routines relevant to this topic.",
exploration: "Now, let's dive deeper into your experiences with similar products. What features do you find most valuable? What frustrations have you encountered?",
creative: "We'll now show you some concepts and get your feedback. Please be honest and specific in your reactions.",
conclusion: "To wrap up, I'd like to hear your final thoughts on what we've discussed today and any additional insights you'd like to share."
};
interface FocusGroupModeratorProps {
draftToEdit?: any | null;
onDraftSaved?: () => void;
preSelectedParticipants?: string[];
}
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');
// Cancellable generation for discussion guide
const socket = getSocket();
const [guideGenerationState, guideGenerationControls] = useCancellableGeneration('discussion guide generation', socket);
const [isGuideProgressModalOpen, setIsGuideProgressModalOpen] = useState(false);
const [discussionGuide, setDiscussionGuide] = useState<string | any | null>(null);
const [draftFocusGroupId, setDraftFocusGroupId] = useState<string | null>(null);
// 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;
// Track if we've already loaded the draft to prevent re-loading
const draftLoadedRef = useRef(false);
// Helper function to determine if discussion guide is JSON format
const isJsonFormat = (guide: any): boolean => {
return guide && typeof guide === 'object' && guide.title && guide.sections;
};
const [selectedParticipants, setSelectedParticipants] = useState<string[]>([]);
const [backendAssets, setBackendAssets] = useState<any[]>([]);
const [isLoadingAssets, setIsLoadingAssets] = useState(false);
const [personas, setPersonas] = useState<any[]>([]);
const [isLoadingPersonas, setIsLoadingPersonas] = useState(false);
// Download state
const [isDownloadingGuide, setIsDownloadingGuide] = useState(false);
// Copy discussion guide state
const [isCopyGuideModalOpen, setIsCopyGuideModalOpen] = useState(false);
const [availableFocusGroups, setAvailableFocusGroups] = useState<any[]>([]);
const [isLoadingFocusGroups, setIsLoadingFocusGroups] = useState(false);
const [copyGuideSearchTerm, setCopyGuideSearchTerm] = useState('');
// Debug modal state changes
React.useEffect(() => {
console.log("isCopyGuideModalOpen state changed to:", isCopyGuideModalOpen);
}, [isCopyGuideModalOpen]);
// Debug component renders
React.useEffect(() => {
console.log("FocusGroupModerator rendered - Modal state:", isCopyGuideModalOpen, "Available focus groups:", availableFocusGroups.length);
});
// Folder state
const [folders, setFolders] = useState<Folder[]>([]);
const [selectedFolder, setSelectedFolder] = useState<string>(DEFAULT_FOLDER_ID);
const [isCreatingFolder, setIsCreatingFolder] = useState(false);
const [newFolderName, setNewFolderName] = useState('');
const [folderToRename, setFolderToRename] = useState<Folder | null>(null);
const [renameFolderName, setRenameFolderName] = useState('');
// Filter state
const [searchTerm, setSearchTerm] = useState('');
const [isFilterOpen, setIsFilterOpen] = useState(false);
const [activeFilters, setActiveFilters] = useState<FilterState>({
age: [],
gender: [],
occupation: [],
location: [],
techSavviness: [],
ethnicity: [],
});
const [workingFilters, setWorkingFilters] = useState<FilterState>({
age: [],
gender: [],
occupation: [],
location: [],
techSavviness: [],
ethnicity: [],
});
// 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 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
const getFilterOptions = (personas: any[]) => {
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(),
};
};
// Get filtered filter options based on current selections
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 = personas.filter(persona => {
// For folder filtering (persona-centric storage)
let matchesFolder = true;
if (selectedFolder !== DEFAULT_FOLDER_ID) {
matchesFolder = false;
// Check if persona belongs to selected folder using persona-centric storage
// Check persona.folder_ids array (new approach)
if (persona.folder_ids && Array.isArray(persona.folder_ids)) {
matchesFolder = persona.folder_ids.includes(selectedFolder);
}
// Legacy support for single folder_id properties
if (!matchesFolder && (persona.folder_id === selectedFolder || persona.folderId === selectedFolder)) {
matchesFolder = true;
}
}
if (!matchesFolder) return false;
// 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);
};
// Apply filters function
const applyFilters = () => {
setIsFilterOpen(false);
setTimeout(() => {
setActiveFilters({...workingFilters});
}, 0);
};
// Reset working filters
const handleResetFilters = () => {
setWorkingFilters({
age: [],
gender: [],
occupation: [],
location: [],
techSavviness: [],
ethnicity: [],
});
};
// 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 database
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
}));
setFolders(processedFolders);
return processedFolders;
} catch (error) {
console.error("Error fetching folders:", error);
toast.error("Failed to load folders");
setFolders([]);
return [];
}
};
// Folder management functions
const createNewFolder = async () => {
if (!newFolderName.trim()) {
toast.error("Please enter a folder name");
return;
}
try {
const response = await foldersApi.create({
name: newFolderName.trim()
});
// Refresh folders from server
await fetchFolders();
setNewFolderName('');
setIsCreatingFolder(false);
toast.success(`Folder "${newFolderName}" created`);
} catch (error) {
console.error("Error creating folder:", error);
toast.error("Failed to create folder");
}
};
const cancelFolderCreation = () => {
setNewFolderName('');
setIsCreatingFolder(false);
};
const startRenameFolder = (folder: Folder) => {
setFolderToRename(folder);
setRenameFolderName(folder.name);
};
const completeRenameFolder = async () => {
if (!folderToRename || !renameFolderName.trim()) {
setFolderToRename(null);
return;
}
try {
await foldersApi.update(folderToRename._id, {
name: renameFolderName.trim()
});
// Refresh folders from server
await fetchFolders();
setFolderToRename(null);
toast.success(`Folder renamed to "${renameFolderName}"`);
} catch (error) {
console.error("Error renaming folder:", error);
toast.error("Failed to rename folder");
setFolderToRename(null);
}
};
const cancelRenameFolder = () => {
setFolderToRename(null);
setRenameFolderName('');
};
// Note: Synchronization is no longer needed with persona-centric storage
// Folder membership is stored in persona.folder_ids array server-side
// Fetch personas and folders from the database when component mounts
useEffect(() => {
const fetchPersonas = async () => {
setIsLoadingPersonas(true);
try {
const response = await personasApi.getAll();
console.log("Fetched personas for FocusGroupModerator:", response.data);
if (Array.isArray(response.data) && response.data.length > 0) {
setPersonas(response.data);
} else {
console.warn("No personas returned from API or invalid format", response.data);
toast.warning("No participants available");
}
} catch (error) {
console.error("Error fetching personas:", error);
toast.error("Failed to load participants");
} finally {
setIsLoadingPersonas(false);
}
};
const loadData = async () => {
// Load both folders and personas from database
await Promise.all([
fetchFolders(),
fetchPersonas()
]);
};
loadData();
}, []);
// Note: Folder management is now handled server-side with persona-centric storage
// No localStorage saving or manual synchronization needed
console.log('About to initialize form with useForm hook');
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
researchBrief: "",
focusGroupName: "",
discussionTopics: "",
duration: "60",
llm_model: "gemini-3-pro-preview",
reasoning_effort: "medium",
verbosity: "medium",
},
});
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-3-pro-preview',
reasoning_effort: values.reasoning_effort || 'medium',
verbosity: values.verbosity || 'medium',
participants: selectedParticipants,
participants_count: selectedParticipants.length,
status: 'draft',
date: new Date().toISOString(),
uploadedAssets: backendAssets.map(a => a.filename || a.original_name || 'unknown')
};
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);
};
// Function to fetch backend assets
const fetchBackendAssets = async (focusGroupId: string) => {
try {
setIsLoadingAssets(true);
const response = await focusGroupsApi.getAssets(focusGroupId);
setBackendAssets(response.data.assets || []);
} catch (error) {
console.error("Error fetching backend assets:", error);
toast.error("Failed to load asset information");
} finally {
setIsLoadingAssets(false);
}
};
// Function to fetch available focus groups with discussion guides for copying
const fetchAvailableFocusGroups = async () => {
console.log("fetchAvailableFocusGroups called");
try {
setIsLoadingFocusGroups(true);
const response = await focusGroupsApi.getAll();
console.log("Fetched focus groups:", response.data);
// Filter to only include focus groups that have discussion guides
const focusGroupsWithGuides = response.data.filter((fg: any) =>
fg.discussionGuide &&
fg.discussionGuide !== null &&
fg.discussionGuide !== '' &&
// Exclude the current focus group being edited (if any)
fg._id !== draftFocusGroupId
);
console.log("Focus groups with guides:", focusGroupsWithGuides);
setAvailableFocusGroups(focusGroupsWithGuides);
} catch (error) {
console.error("Error fetching focus groups:", error);
toast.error("Failed to load available focus groups");
} finally {
setIsLoadingFocusGroups(false);
}
};
// Function to copy discussion guide from selected focus group
const handleCopyDiscussionGuide = async (sourceFocusGroupId: string) => {
try {
// Find the selected focus group
const sourceFocusGroup = availableFocusGroups.find(fg => fg._id === sourceFocusGroupId);
if (!sourceFocusGroup || !sourceFocusGroup.discussionGuide) {
toast.error("Selected focus group does not have a discussion guide");
return;
}
// Set the discussion guide state with the copied guide
setDiscussionGuide(sourceFocusGroup.discussionGuide);
// If we have a draft focus group ID, update it with the copied discussion guide
if (draftFocusGroupId) {
try {
const values = form.getValues();
const updateData = {
name: values.focusGroupName,
status: 'draft',
participants: selectedParticipants,
participants_count: selectedParticipants.length,
duration: parseInt(values.duration || '60'),
topic: values.discussionTopics || '',
description: values.researchBrief || '',
objective: values.researchBrief || '',
llm_model: values.llm_model || 'gemini-3-pro-preview',
reasoning_effort: values.reasoning_effort || 'medium',
verbosity: values.verbosity || 'medium',
discussionGuide: sourceFocusGroup.discussionGuide
};
await focusGroupsApi.update(draftFocusGroupId, updateData);
console.log("Draft focus group updated with copied discussion guide");
} catch (error) {
console.error("Failed to update focus group with copied discussion guide:", error);
toast.error("Failed to save copied discussion guide", {
description: "Discussion guide copied, but draft save failed",
});
}
}
// Close the modal
setIsCopyGuideModalOpen(false);
// Switch to review tab
setActiveTab('review');
// Show success toast
toast.success("Discussion guide copied successfully", {
description: `Copied from "${sourceFocusGroup.name}"`,
});
} catch (error) {
console.error("Error copying discussion guide:", error);
toast.error("Failed to copy discussion guide", {
description: "An error occurred while copying the discussion guide",
});
}
};
// Function to update asset name
const updateAssetName = async (focusGroupId: string, filename: string, newName: string) => {
try {
await focusGroupsApi.updateAssetName(focusGroupId, filename, newName);
// Update local state
setBackendAssets(prev => prev.map(asset =>
asset.filename === filename
? { ...asset, user_assigned_name: newName }
: asset
));
toast.success("Asset name updated");
} catch (error) {
console.error("Error updating asset name:", error);
toast.error("Failed to update asset name");
}
};
// 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>('');
// 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]);
// Asset uploads are now handled immediately via AssetUploader component
// 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
const draftId = draftToEdit.id || draftToEdit._id;
setDraftFocusGroupId(draftId);
console.log("Setting draft ID from draftToEdit:", draftId);
// Load backend assets for this focus group
if (draftId) {
fetchBackendAssets(draftId);
}
// Load form data if available
if (draftToEdit.name) {
form.setValue('focusGroupName', draftToEdit.name);
}
if (draftToEdit.description || draftToEdit.objective) {
form.setValue('researchBrief', draftToEdit.description || draftToEdit.objective || '');
}
if (draftToEdit.topic) {
form.setValue('discussionTopics', draftToEdit.topic);
}
if (draftToEdit.duration) {
form.setValue('duration', draftToEdit.duration.toString());
}
if (draftToEdit.llm_model) {
form.setValue('llm_model', draftToEdit.llm_model);
}
if (draftToEdit.reasoning_effort) {
form.setValue('reasoning_effort', draftToEdit.reasoning_effort);
}
if (draftToEdit.verbosity) {
form.setValue('verbosity', draftToEdit.verbosity);
}
// Load discussion guide if available
if (draftToEdit.discussionGuide) {
setDiscussionGuide(draftToEdit.discussionGuide);
// 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
if (draftToEdit.participants && Array.isArray(draftToEdit.participants)) {
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-3-pro-preview',
reasoning_effort: draftToEdit.reasoning_effort || 'medium',
verbosity: draftToEdit.verbosity || 'medium',
participants: draftToEdit.participants || [],
participants_count: (draftToEdit.participants || []).length,
status: 'draft',
date: draftToEdit.date || new Date().toISOString(),
uploadedAssets: backendAssets.map(a => a.filename || a.original_name || 'unknown')
};
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;
// Ensure initial form state is captured after loading
const initialFormState = JSON.stringify(form.getValues());
prevWatchedFieldsRef.current = initialFormState;
}, 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;
// Ensure initial form state is captured for new focus groups
const initialFormState = JSON.stringify(form.getValues());
prevWatchedFieldsRef.current = initialFormState;
}, 500); // Allow initial render to complete
}
}, [draftToEdit, form]);
// 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> => {
// Start cancellable generation and open progress modal
guideGenerationControls.startGeneration();
setIsGuideProgressModalOpen(true);
try {
// Prepare data for API request
const requestData = {
name: values.focusGroupName,
description: values.researchBrief,
objective: values.researchBrief,
topic: values.discussionTopics,
duration: parseInt(values.duration),
llm_model: values.llm_model,
reasoning_effort: values.reasoning_effort,
verbosity: values.verbosity
};
// Call API to generate discussion guide, with focus group ID if available
const response = focusGroupId
? await focusGroupsApi.generateDiscussionGuideForGroup(focusGroupId, requestData)
: await focusGroupsApi.generateDiscussionGuide(requestData);
// Set task ID if available
if (response.data?.task_id) {
guideGenerationControls.setTaskId(response.data.task_id);
}
// Check if we got a successful response with a discussion guide
if (response.data && response.data.discussionGuide) {
guideGenerationControls.completeGeneration();
return response.data.discussionGuide;
} else {
throw new Error("Failed to generate discussion guide");
}
} catch (error) {
// Check if this was a cancellation
if (error.response?.status === 499) {
return '';
}
console.error("Error generating discussion guide:", error);
guideGenerationControls.failGeneration(error.message || 'Failed to generate discussion guide');
// Extract error message from axios error response
let errorMessage = 'Unknown error occurred';
if (error?.response?.data?.error) {
errorMessage = error.response.data.error;
} else if (error?.message) {
errorMessage = error.message;
}
if (errorMessage.includes('500') || errorMessage.includes('internal error') || errorMessage.includes('Internal Server Error')) {
toast.error("AI service temporarily unavailable", {
description: "The discussion guide generator is experiencing issues. Please try again in a few minutes.",
action: {
label: "Retry",
onClick: () => generateDiscussionGuide(values)
}
});
} else {
toast.error("Failed to generate discussion guide", {
description: errorMessage,
action: {
label: "Retry",
onClick: () => generateDiscussionGuide(values)
}
});
}
// Don't provide fallback template - throw the error to prevent showing dummy guide
throw error;
}
// Note: Don't set isGenerating to false here - let the progress bar handle it
};
const handleGuideProgressComplete = () => {
setIsGuideProgressModalOpen(false);
guideGenerationControls.resetGeneration();
};
// Switch to Setup tab when discussion guide generation is cancelled (not completed successfully)
useEffect(() => {
if (!guideGenerationState.isGenerating && !guideGenerationState.isCancelling &&
guideGenerationState.taskId === null && activeTab === 'review' && !discussionGuide) {
// Only revert to setup if no guide was generated (i.e., cancellation, not success)
setActiveTab('setup');
}
}, [guideGenerationState.isGenerating, guideGenerationState.isCancelling, guideGenerationState.taskId, activeTab, discussionGuide]);
async function onSubmit(values: z.infer<typeof formSchema>) {
try {
// Use existing focus group ID or create new draft for discussion guide generation
let focusGroupId = draftFocusGroupId;
if (!focusGroupId) {
const draftData = {
name: values.focusGroupName,
status: 'draft',
participants: selectedParticipants,
participants_count: selectedParticipants.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,
llm_model: values.llm_model,
reasoning_effort: values.reasoning_effort,
verbosity: values.verbosity,
};
const savedDraft = await focusGroupsApi.create(draftData);
focusGroupId = savedDraft.data.focus_group_id || savedDraft.data.id || savedDraft.data._id;
setDraftFocusGroupId(focusGroupId);
console.log("Draft focus group created for discussion guide generation:", savedDraft, "with ID:", focusGroupId);
}
// Assets are now uploaded immediately via AssetUploader component
// No need to handle asset uploads here
// Update focus group with current form values before generating guide
// This ensures the backend uses the latest model selection
if (focusGroupId) {
try {
const preUpdateData = {
name: values.focusGroupName,
participants: selectedParticipants,
participants_count: selectedParticipants.length,
duration: parseInt(values.duration),
topic: values.discussionTopics.split(',')[0].trim().toLowerCase().replace(/\s+/g, '-'),
description: values.researchBrief,
objective: values.researchBrief,
llm_model: values.llm_model,
reasoning_effort: values.reasoning_effort,
verbosity: values.verbosity
};
await focusGroupsApi.update(focusGroupId, preUpdateData);
console.log("Focus group updated with latest form values before guide generation");
console.log(`🔄 Updated focus group ${focusGroupId} with model: ${values.llm_model}`);
} catch (error) {
console.error("Failed to update focus group before guide generation:", error);
// Continue anyway, as the generateDiscussionGuide will use form values as fallback
}
}
try {
// Generate discussion guide based on form input (after database is updated)
const guide = await generateDiscussionGuide(values, focusGroupId);
// Check if generation was cancelled (returns empty string or object)
if (!guide || (typeof guide === 'string' && guide.trim() === '')) {
console.log('Discussion guide generation was cancelled');
return; // Exit early, don't process or show success toasts
}
setDiscussionGuide(guide);
// Update the focus group with the discussion guide
try {
const updateData = {
name: values.focusGroupName,
status: 'draft',
participants: selectedParticipants,
participants_count: selectedParticipants.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,
llm_model: values.llm_model,
reasoning_effort: values.reasoning_effort,
verbosity: values.verbosity,
discussionGuide: guide
};
await focusGroupsApi.update(focusGroupId, updateData);
console.log("Focus group updated with discussion guide");
toast.success("Progress saved as draft", {
description: "Your focus group setup has been automatically saved",
});
} catch (error) {
console.error("Failed to update focus group with discussion guide:", error);
toast.error("Failed to save draft", {
description: "Discussion guide generated, but draft save failed",
});
}
// Move to review tab after successful generation
setActiveTab('review');
toast.success("Discussion guide generated", {
description: "Review and edit before proceeding",
});
} catch (guideError) {
console.error("Discussion guide generation failed:", guideError);
// Don't set discussion guide or move to review tab
// Show error message with instruction to go back to setup tab and try again
toast.error("Discussion guide generation failed", {
description: "Please go back to the setup tab and try generating again. Check your inputs and try a different AI model if the issue persists.",
duration: 8000, // Show longer so user can read the instruction
});
// Stay on current tab (setup) so user can try again
return;
}
} catch (error) {
console.error("Error in focus group creation flow:", error);
toast.error("Focus group creation failed", {
description: error.message || "An unexpected error occurred",
});
}
}
// Filtered personas based on search term, folder selection, and active filters
const filteredPersonas = (() => {
const filtered = personas.filter(persona => {
// Text search matching
const matchesSearch = (
persona.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
(persona.occupation && persona.occupation.toLowerCase().includes(searchTerm.toLowerCase())) ||
(persona.location && 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)'
))) &&
true;
// Folder filtering (persona-centric storage)
let matchesFolder = true;
// Only filter by folder if not the default "All Personas" folder
if (selectedFolder !== DEFAULT_FOLDER_ID) {
matchesFolder = false; // Start with false and set to true if it matches
// Check if persona belongs to selected folder using persona-centric storage
// Primary approach: Check persona.folder_ids array
if (persona.folder_ids && Array.isArray(persona.folder_ids)) {
matchesFolder = persona.folder_ids.includes(selectedFolder);
}
// Legacy support: Check single folder ID properties
if (!matchesFolder) {
if (persona.folder_id === selectedFolder || persona.folderId === selectedFolder) {
matchesFolder = true;
}
}
}
return matchesSearch && matchesFilters && matchesFolder;
});
// Debug log filtered results
console.log(`Filtered personas: ${filtered.length}/${personas.length}`);
console.log(`Selected folder: ${selectedFolder === DEFAULT_FOLDER_ID ? 'All Personas' :
folders.find(f => f._id === selectedFolder || f.id === selectedFolder)?.name || selectedFolder}`);
if (selectedFolder !== DEFAULT_FOLDER_ID) {
const folder = folders.find(f => f._id === selectedFolder || f.id === selectedFolder);
if (folder) {
// Count personas that belong to this folder (persona-centric approach)
const personasInFolder = personas.filter(p => {
// Check folder_ids array
if (p.folder_ids && Array.isArray(p.folder_ids)) {
return p.folder_ids.includes(selectedFolder);
}
// Check legacy single folder properties
return p.folder_id === selectedFolder || p.folderId === selectedFolder;
});
console.log(`Folder details: ${folder.name}, ID: ${folder._id}, Contains: ${personasInFolder.length} personas`);
console.log(`Personas in this folder:`, personasInFolder.map(p => p.name));
}
}
return filtered;
})();
const handleParticipantSelection = (id: string) => {
console.log("Toggling selection for participant ID:", id);
setSelectedParticipants(prev => {
const isSelected = prev.includes(id);
console.log("Current selection:", {
id,
isCurrentlySelected: isSelected,
currentSelections: [...prev]
});
// Toggle selection
const newSelection = isSelected
? prev.filter(pId => pId !== id)
: [...prev, id];
console.log("New selection:", newSelection);
// Auto-save will be triggered by the useEffect watching selectedParticipants
return newSelection;
});
};
// Asset upload is now handled by AssetUploader component
// This function is no longer needed
// Function to save the focus group to the database
const saveFocusGroup = async () => {
try {
// Get form values
const values = form.getValues();
const focusGroupData = {
name: values.focusGroupName,
status: 'in-progress',
participants: selectedParticipants,
participants_count: selectedParticipants.length,
date: new Date().toISOString(),
duration: parseInt(values.duration),
topic: values.discussionTopics.split(',')[0].trim().toLowerCase().replace(/\s+/g, '-'),
discussionGuide: discussionGuide
};
// Use the API from lib/api.ts to save the focus group
const response = await focusGroupsApi.create(focusGroupData);
const result = response.data;
console.log("Focus group created successfully:", result);
return result.focus_group_id;
} catch (error) {
console.error('Error saving focus group:', error);
throw error;
}
};
// Function to download discussion guide as markdown
const handleDownloadDiscussionGuide = useCallback(async () => {
if (!discussionGuideRef.current) {
toast.error("No discussion guide available", {
description: "Please generate a discussion guide first"
});
return;
}
setIsDownloadingGuide(true);
try {
// Use the client-side download utility
const { downloadDiscussionGuideAsMarkdown } = await import('@/utils/discussionGuideMarkdown');
const formValues = form.getValues();
downloadDiscussionGuideAsMarkdown(discussionGuideRef.current, formValues.focusGroupName);
toast.success("Discussion guide downloaded", {
description: "The guide has been saved to your downloads folder"
});
} catch (error) {
console.error('Error downloading discussion guide:', error);
toast.error("Download failed", {
description: "Unable to download the discussion guide. Please try again."
});
} finally {
setIsDownloadingGuide(false);
}
}, [form]); // Only depend on form, use ref for discussionGuide
// Stable callback for saving discussion guide changes
const handleSaveDiscussionGuide = useCallback(async (updatedGuide: any) => {
console.log('📝 handleSaveDiscussionGuide called with:', updatedGuide);
// 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 handleStartFocusGroup = async () => {
// Validate form data
if (!form.getValues().focusGroupName) {
toast.error("Missing focus group name", {
description: "Please provide a name for the focus group",
});
return;
}
if (!discussionGuide) {
toast.error("Missing discussion guide", {
description: "Please generate a discussion guide first",
});
return;
}
if (selectedParticipants.length < 1) {
toast.error("Not enough participants", {
description: "Please select at least one participant for the focus group",
});
return;
}
console.log("Starting focus group with participants:", selectedParticipants);
try {
// Show loading toast
toast.loading("Creating focus group...");
// Save the focus group to the database first
let focusGroupId;
if (draftFocusGroupId) {
// Update existing draft to in-progress status
const values = form.getValues();
const updatedFocusGroupData = {
name: values.focusGroupName,
status: 'in-progress',
participants: selectedParticipants,
participants_count: selectedParticipants.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
};
const response = await focusGroupsApi.update(draftFocusGroupId, updatedFocusGroupData);
focusGroupId = draftFocusGroupId;
console.log("Draft focus group updated to in-progress:", response);
// Notify parent component that draft was saved
if (onDraftSaved) {
onDraftSaved();
}
} else {
// Create new focus group
focusGroupId = await saveFocusGroup();
}
// Dismiss loading toast and show success
toast.dismiss();
toast.success("Focus group created successfully", {
description: "The AI moderator is now running the session",
});
// Navigate to the focus group session
navigate(`/focus-groups/${focusGroupId}`);
} catch (error: any) {
// Dismiss loading toast
toast.dismiss();
// Show specific error message if available
const errorMessage = error?.message || "Unknown error";
console.error("Failed to start focus group:", error);
toast.error("Failed to create focus group", {
description: "Please try again or check your connection",
});
}
};
return (
<>
{/* 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 Modal for Discussion Guide Generation */}
<ProgressModal
isOpen={isGuideProgressModalOpen}
onClose={() => setIsGuideProgressModalOpen(false)}
isActive={guideGenerationState.isGenerating}
isComplete={guideGenerationState.isComplete}
hasError={guideGenerationState.hasError}
isCancelling={guideGenerationState.isCancelling}
taskId={guideGenerationState.taskId}
title="Generating Discussion Guide"
description="Creating your discussion guide based on the research objectives. This typically takes 30-60 seconds."
onCancel={guideGenerationControls.cancelGeneration}
onComplete={handleGuideProgressComplete}
/>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-3 mb-6">
<TabsTrigger value="setup">Setup</TabsTrigger>
<TabsTrigger value="review">Review & Edit</TabsTrigger>
<TabsTrigger value="participants">Participants</TabsTrigger>
</TabsList>
<TabsContent value="setup">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="focusGroupName"
render={({ field }) => (
<FormItem>
<FormLabel>Focus Group Name</FormLabel>
<FormControl>
<Input placeholder="e.g., Mobile App UX Evaluation" {...field} />
</FormControl>
<FormDescription>
Give your focus group a descriptive name
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<FormField
control={form.control}
name="researchBrief"
render={({ field }) => (
<FormItem>
<FormLabel>Research Brief</FormLabel>
<FormControl>
<Textarea
placeholder="Describe your research objectives..."
className="h-36"
{...field}
/>
</FormControl>
<FormDescription>
Provide context about what you want to learn
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-6">
<FormField
control={form.control}
name="discussionTopics"
render={({ field }) => (
<FormItem>
<FormLabel>Discussion Topics</FormLabel>
<FormControl>
<Textarea
placeholder="List main topics to cover, separated by commas"
className="h-24"
{...field}
/>
</FormControl>
<FormDescription>
E.g., User experience, feature preferences, pain points
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="duration"
render={({ field }) => (
<FormItem>
<FormLabel>Duration (minutes)</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select duration" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="30">30 minutes</SelectItem>
<SelectItem value="45">45 minutes</SelectItem>
<SelectItem value="60">60 minutes</SelectItem>
<SelectItem value="90">90 minutes</SelectItem>
<SelectItem value="120">120 minutes</SelectItem>
</SelectContent>
</Select>
<FormDescription>
How long should the focus group session last?
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="llm_model"
render={({ field }) => (
<FormItem>
<FormLabel>AI Model</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select AI model" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="gemini-3-pro-preview">Gemini 3 Pro (Slow, best for most tasks)</SelectItem>
<SelectItem value="gpt-4.1">GPT-4.1 (Fast, best for speed)</SelectItem>
<SelectItem value="gpt-5">GPT-5 (Slow, best for complex tasks)</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Choose which AI model to use for generating responses, discussion guides, and thematic analysis
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* GPT-5 specific parameters - only show when GPT-5 is selected */}
{form.watch("llm_model") === "gpt-5" && (
<>
{/* Reasoning Effort Parameter */}
<FormField
control={form.control}
name="reasoning_effort"
render={({ field }) => (
<FormItem>
<FormLabel>Reasoning Effort</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select reasoning effort" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="minimal">Minimal - Fast responses</SelectItem>
<SelectItem value="low">Low - Quick thinking</SelectItem>
<SelectItem value="medium">Medium - Balanced (default)</SelectItem>
<SelectItem value="high">High - Deep reasoning</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Controls how much time GPT-5 spends thinking before responding
</FormDescription>
<div className="text-xs text-amber-600 font-medium mt-1">
Controls how much time GPT-5 spends thinking before responding
</div>
<FormMessage />
</FormItem>
)}
/>
{/* Verbosity Parameter */}
<FormField
control={form.control}
name="verbosity"
render={({ field }) => (
<FormItem>
<FormLabel>Response Verbosity</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select verbosity level" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="low">Low - Concise responses</SelectItem>
<SelectItem value="medium">Medium - Balanced length (default)</SelectItem>
<SelectItem value="high">High - Detailed responses</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Controls how detailed and lengthy GPT-5's responses will be
</FormDescription>
<div className="text-xs text-amber-600 font-medium mt-1">
Controls how much time GPT-5 spends thinking before responding
</div>
<FormMessage />
</FormItem>
)}
/>
</>
)}
</div>
</div>
{/* Stimulus Uploader */}
<div>
<label className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 mb-2 block">
Upload Your Stimulus (if any)
</label>
<AssetUploader
focusGroupId={draftFocusGroupId}
disabled={!draftFocusGroupId}
onUploadComplete={(assets) => {
setBackendAssets(assets);
// Trigger auto-save to update focus group metadata
}}
onUploadError={(error) => {
console.error('Asset upload error:', error);
// Error handling is already done in AssetUploader component
}}
onAssetsChange={(assets) => {
setBackendAssets(assets);
}}
maxAssets={10}
maxFileSize={10}
allowedTypes={['image/*', 'application/pdf', 'video/*', 'text/*', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document']}
label="Upload Your Stimulus"
description="Provide any files you wish the moderator to use in the focus group session. This could include mockups, designs, documents or other materials for discussion. The moderator will write a discussion guide partially around these assets. You can edit this in the next step."
enableRenaming={true}
/>
</div>
</form>
</Form>
{/* Action buttons outside of form to prevent form submission issues */}
<div className="flex justify-end gap-3 mt-6">
<Button
type="button"
variant="outline"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
console.log("Copy Discussion Guide button clicked");
setIsCopyGuideModalOpen(true);
fetchAvailableFocusGroups();
}}
disabled={guideGenerationState.isGenerating}
className="min-w-32"
>
<Copy className="mr-2 h-4 w-4" />
Copy Discussion Guide
</Button>
<Button
type="button"
onClick={(e) => {
e.preventDefault();
console.log("Generate Discussion Guide button clicked");
const formData = form.getValues();
form.handleSubmit(onSubmit)(e);
}}
disabled={guideGenerationState.isGenerating}
className="min-w-32"
>
<MessageSquare className="mr-2 h-4 w-4" />
{guideGenerationState.isGenerating ? "Generating..." : "Generate Discussion Guide"}
</Button>
</div>
</TabsContent>
<TabsContent value="review">
<div className="space-y-6">
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<h3 className="font-sf text-lg font-medium">AI-Generated Discussion Guide</h3>
{discussionGuide && (
<Badge variant="outline" className="text-xs">
{isJsonFormat(discussionGuide) ? 'Structured JSON' : 'Legacy Text'}
</Badge>
)}
</div>
</div>
<div className="prose max-w-none">
{discussionGuide ? (
<DiscussionGuideViewer
discussionGuide={discussionGuide}
showProgress={false}
collapsible={true}
defaultExpanded={true}
className="border-0"
onSave={handleSaveDiscussionGuide}
onDownload={handleDownloadDiscussionGuide}
onSectionSelect={handleSectionSelect}
isDownloading={isDownloadingGuide}
focusGroupId={draftFocusGroupId}
onEditingChange={handleEditingStateChange}
/>
) : (
<div className="bg-slate-50 p-4 rounded border text-center text-slate-600">
{guideGenerationState.hasError ? (
<div>
<p className="mb-2">Discussion guide generation failed.</p>
<p className="text-sm">Go back to the <strong>Setup</strong> tab and try generating again. Check your inputs and try a different AI model if the issue persists.</p>
</div>
) : (
<p>No discussion guide generated yet. Complete the setup and click "Generate Discussion Guide" to create one.</p>
)}
</div>
)}
</div>
</CardContent>
</Card>
{backendAssets.length > 0 && (
<Card>
<CardContent className="p-6">
<h3 className="font-sf text-lg font-medium mb-4">Creative Assets</h3>
<div className="space-y-3">
<p className="text-sm text-slate-600">
Assets that will be referenced in the discussion guide:
</p>
<div className="space-y-2">
{backendAssets.map((asset, index) => {
const displayName = asset.user_assigned_name || `Asset ${index + 1}`;
return (
<div key={asset.filename} className="flex items-center gap-3 p-3 border rounded-lg bg-slate-50">
{/* Asset preview */}
<div className="w-10 h-10 bg-slate-200 rounded flex items-center justify-center flex-shrink-0">
{asset.mime_type?.startsWith('image/') ? (
<img
src={focusGroupsApi.getAssetUrl(draftFocusGroupId, asset.filename)}
alt={displayName}
className="max-h-full max-w-full object-contain rounded"
/>
) : (
<FileText className="h-6 w-6 text-slate-600" />
)}
</div>
{/* Asset name */}
<div className="flex-grow">
<p className="font-medium text-sm">"{displayName}"</p>
<p className="text-xs text-slate-500">Will appear in discussion guide</p>
</div>
</div>
);
})}
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
<p className="text-sm text-blue-700">
<strong>Note:</strong> To rename assets, go back to the Setup tab and click the edit icon next to each asset.
</p>
</div>
</div>
</CardContent>
</Card>
)}
<div className="flex justify-between">
<Button
variant="outline"
onClick={() => setActiveTab('setup')}
>
Back to Setup
</Button>
<Button
onClick={() => setActiveTab('participants')}
>
Select Participants
<Users className="ml-2 h-4 w-4" />
</Button>
</div>
</div>
</TabsContent>
<TabsContent value="participants">
<div className="flex flex-col md:flex-row gap-6">
{/* Folder sidebar */}
<div className="w-full md:w-64 space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium">Folders</h3>
<Button
variant="ghost"
size="sm"
onClick={() => {
console.log("Clicked 'Create new folder' button");
setIsCreatingFolder(true);
}}
className="h-7 w-7 p-0"
>
<FolderPlus className="h-4 w-4" />
</Button>
</div>
<div className="space-y-1">
<button
onClick={() => {
console.log("Clicked 'All Personas' folder");
console.log("All personas count:", personas.length);
setSelectedFolder(DEFAULT_FOLDER_ID);
// Log filtered personas after state update
setTimeout(() => {
console.log(`Will show all ${personas.length} personas`);
}, 0);
}}
className={`w-full flex items-center space-x-2 px-3 py-2 text-sm rounded-md text-left transition-colors ${
selectedFolder === DEFAULT_FOLDER_ID
? 'bg-primary/10 text-primary font-medium'
: 'hover:bg-slate-100'
}`}
>
<Folder className="h-4 w-4" />
<span>All Personas</span>
</button>
{folders.map(folder => (
<div
key={folder._id}
className="flex items-center justify-between group"
>
{folderToRename && folderToRename._id === folder._id ? (
<div className="flex-1 flex items-center px-3 py-2 space-x-2">
<Folder className="h-4 w-4" />
<Input
value={renameFolderName}
onChange={e => setRenameFolderName(e.target.value)}
placeholder="Folder name"
className="h-7 text-sm"
autoFocus
onKeyDown={e => {
if (e.key === 'Enter') {
completeRenameFolder();
} else if (e.key === 'Escape') {
cancelRenameFolder();
}
}}
/>
<Button
size="sm"
variant="ghost"
onClick={() => {
console.log(`Confirming folder rename: "${folderToRename?.name}" to "${renameFolderName}"`);
completeRenameFolder();
}}
className="h-7 w-7 p-0"
>
<Check className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => {
console.log(`Cancelling rename of folder: "${folderToRename?.name}"`);
cancelRenameFolder();
}}
className="h-7 w-7 p-0"
>
<X className="h-4 w-4" />
</Button>
</div>
) : (
<>
<button
onClick={() => {
console.log(`Clicked folder: ${folder.name} (ID: ${folder._id})`);
// Count personas in this folder (persona-centric approach)
const personasInFolder = personas.filter(p => {
// Check folder_ids array
if (p.folder_ids && Array.isArray(p.folder_ids)) {
return p.folder_ids.includes(folder._id);
}
// Check legacy single folder properties
return p.folder_id === folder._id || p.folderId === folder._id;
});
console.log(`Current persona count in folder: ${personasInFolder.length}`);
console.log("All personas count:", personas.length);
setSelectedFolder(folder._id);
// Log filtered personas after state update
setTimeout(() => {
console.log(`Will show ${personasInFolder.length} personas after filtering`);
console.log("Filtered personas:", personasInFolder.map(p => p.name));
}, 0);
}}
className={`flex-1 flex items-center space-x-2 px-3 py-2 text-sm rounded-md text-left transition-colors ${
selectedFolder === folder._id
? 'bg-primary/10 text-primary font-medium'
: 'hover:bg-slate-100'
}`}
>
<Folder className="h-4 w-4" />
<span>{folder.name}</span>
<span className="text-muted-foreground text-xs ml-auto">
{personas.filter(p => {
if (p.folder_ids && Array.isArray(p.folder_ids)) {
return p.folder_ids.includes(folder._id);
}
return p.folder_id === folder._id || p.folderId === folder._id;
}).length}
</span>
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 opacity-0 group-hover:opacity-100"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => {
console.log(`Initiating rename for folder: ${folder.name} (ID: ${folder.id})`);
startRenameFolder(folder);
}}>
Rename
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
)}
</div>
))}
{isCreatingFolder && (
<div className="flex items-center px-3 py-2 space-x-2">
<div className="flex-1 flex items-center space-x-2">
<Folder className="h-4 w-4" />
<Input
value={newFolderName}
onChange={e => setNewFolderName(e.target.value)}
placeholder="Folder name"
className="h-7 text-sm"
autoFocus
onKeyDown={e => {
if (e.key === 'Enter') {
createNewFolder();
} else if (e.key === 'Escape') {
cancelFolderCreation();
}
}}
/>
</div>
<Button
size="sm"
variant="ghost"
onClick={() => {
console.log(`Confirming creation of new folder: "${newFolderName}"`);
createNewFolder();
}}
className="h-7 w-7 p-0"
>
<Check className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => {
console.log("Cancelling folder creation");
cancelFolderCreation();
}}
className="h-7 w-7 p-0"
>
<X className="h-4 w-4" />
</Button>
</div>
)}
</div>
</div>
{/* Main content */}
<div className="flex-1">
<Card className="mb-4">
<CardContent className="p-6">
<div className="flex flex-col space-y-6">
<div className="flex items-center justify-between">
<h3 className="font-sf text-lg font-medium">Select Participants</h3>
<div className="flex items-center">
<Users className="h-5 w-5 mr-2 text-muted-foreground" />
<span className="text-sm font-medium">
{selectedParticipants.length} of {filteredPersonas.length} selected
</span>
</div>
</div>
{/* Search and filter bar */}
<div className="flex flex-col sm:flex-row gap-4">
<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>
<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>
{isLoadingPersonas ? (
<div className="flex justify-center items-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
) : filteredPersonas.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-2 gap-4">
{filteredPersonas.map((persona) => {
// Ensure we have a valid ID (use MongoDB _id if available)
const personaId = persona._id || persona.id;
return (
<UserCard
key={personaId}
user={{
id: personaId,
_id: persona._id,
name: persona.name,
age: persona.age,
gender: persona.gender,
occupation: persona.occupation,
location: persona.location || 'Unknown',
techSavviness: persona.techSavviness || 50,
personality: persona.personality || 'No description available',
oceanTraits: persona.oceanTraits,
qualitativeAttributes: persona.qualitativeAttributes,
topPersonalityTraits: persona.topPersonalityTraits,
aiSynthesizedBio: persona.aiSynthesizedBio,
}}
selected={selectedParticipants.includes(personaId)}
onSelectionToggle={() => handleParticipantSelection(personaId)}
onViewDetails={handlePersonaViewDetails}
/>
);
})}
</div>
) : (
<div className="text-center py-12">
<p className="text-muted-foreground">No personas available matching your criteria.</p>
</div>
)}
</div>
</CardContent>
</Card>
<div className="flex justify-between">
<Button
variant="outline"
onClick={() => setActiveTab('review')}
>
Back to Review
</Button>
<Button
onClick={handleStartFocusGroup}
disabled={selectedParticipants.length < 1 || !discussionGuide}
>
<Play className="mr-2 h-4 w-4" />
Start Focus Group Session
</Button>
</div>
</div>
</div>
{/* Filter Dialog */}
<Dialog
open={isFilterOpen}
onOpenChange={(open) => {
if (!open) {
setIsFilterOpen(false);
} else {
setIsFilterOpen(open);
// When opening, set working filters to active filters
setWorkingFilters({...activeFilters});
}
}}
>
<DialogContent
className="max-w-4xl max-h-[80vh] overflow-y-auto"
>
<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.
</DialogDescription>
</DialogHeader>
<div className="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>
)}
{(() => {
// Get filter options from all personas (for default state)
const defaultFilterOptions = getFilterOptions(personas);
// Check if any filters are active
const noActiveFilters = Object.values(workingFilters).every(values => values.length === 0);
// Render filter section
const renderFilterSection = (
title: string,
category: keyof FilterState,
columns: number = 1
) => {
// Get options for this category based on other active filters
const filteredOptions = noActiveFilters ?
defaultFilterOptions[category] :
getFilteredOptions(category)[category];
// Include already selected options that might be filtered out
const selectedOptions = workingFilters[category];
// Combine filtered options with selected options
const combinedOptions = [...new Set([...filteredOptions, ...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 = filteredOptions.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', 3)}
{renderFilterSection('Age', 'age', 3)}
{renderFilterSection('Ethnicity', 'ethnicity', 2)}
{renderFilterSection('Location', 'location', 2)}
{renderFilterSection('Occupation', 'occupation', 2)}
{renderFilterSection('Tech Savviness', 'techSavviness', 3)}
{/* Boolean Attributes */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
</div>
</>
);
})()}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={handleResetFilters}
>
Reset
</Button>
<Button onClick={applyFilters}>
Apply Filters
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</TabsContent>
</Tabs>
{/* Copy Discussion Guide Modal - Outside of tabs so it's always available */}
{console.log("About to render Dialog OUTSIDE tabs - isCopyGuideModalOpen:", isCopyGuideModalOpen)}
<Dialog
open={isCopyGuideModalOpen}
onOpenChange={(open) => {
console.log("Dialog onOpenChange called with:", open);
setIsCopyGuideModalOpen(open);
}}
>
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Copy Discussion Guide</DialogTitle>
<DialogDescription>
Select a focus group to copy its discussion guide from. Only focus groups with existing discussion guides are shown.
</DialogDescription>
</DialogHeader>
<div className="py-4 space-y-4">
{/* Search box */}
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
<Input
placeholder="Search focus groups by name..."
className="pl-10"
value={copyGuideSearchTerm}
onChange={(e) => setCopyGuideSearchTerm(e.target.value)}
/>
</div>
{/* Loading state */}
{isLoadingFocusGroups && (
<div className="flex justify-center items-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<span className="ml-2 text-muted-foreground">Loading focus groups...</span>
</div>
)}
{/* Focus groups list */}
{!isLoadingFocusGroups && (
<div className="space-y-3 max-h-96 overflow-y-auto">
{availableFocusGroups
.filter(fg =>
!copyGuideSearchTerm ||
fg.name.toLowerCase().includes(copyGuideSearchTerm.toLowerCase())
)
.length > 0 ? (
availableFocusGroups
.filter(fg =>
!copyGuideSearchTerm ||
fg.name.toLowerCase().includes(copyGuideSearchTerm.toLowerCase())
)
.map((focusGroup) => (
<Card
key={focusGroup._id}
className="p-4 cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => handleCopyDiscussionGuide(focusGroup._id)}
>
<div className="space-y-2">
<div className="flex items-center justify-between">
<h3 className="font-medium text-sm">{focusGroup.name}</h3>
<Badge variant="outline" className="text-xs">
{focusGroup.participants_count || focusGroup.participants?.length || 0} participants
</Badge>
</div>
{focusGroup.description && (
<p className="text-xs text-muted-foreground line-clamp-2">
{focusGroup.description}
</p>
)}
<div className="flex items-center gap-4 text-xs text-muted-foreground">
{focusGroup.created_at && (
<span>Created {new Date(focusGroup.created_at).toLocaleDateString()}</span>
)}
{focusGroup.duration && (
<span>{focusGroup.duration} minutes</span>
)}
{focusGroup.status && (
<Badge variant="secondary" className="text-xs">
{focusGroup.status}
</Badge>
)}
</div>
</div>
</Card>
))
) : (
<div className="text-center py-8">
<FileText className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<p className="text-muted-foreground">
{copyGuideSearchTerm
? "No focus groups match your search criteria."
: "No focus groups with discussion guides found."}
</p>
{copyGuideSearchTerm && (
<Button
variant="link"
size="sm"
onClick={() => setCopyGuideSearchTerm('')}
className="mt-2"
>
Clear search
</Button>
)}
</div>
)}
</div>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsCopyGuideModalOpen(false)}
>
Cancel
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</>
);
}