import { useState, useEffect, useRef, useCallback } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { useAuth } from '@/contexts/AuthContext'; import { ArrowLeft, Download, MessageCircle, Lightbulb, ClipboardList, BarChart, PlayCircle, StickyNote, Settings, Bot } from 'lucide-react'; import { toastService } from '@/lib/toast'; import { Button } from '@/components/ui/button'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Badge } from '@/components/ui/badge'; import Navigation from '@/components/Navigation'; import ParticipantPanel from '@/components/focus-group-session/ParticipantPanel'; import DiscussionPanel from '@/components/focus-group-session/DiscussionPanel'; import ThemesPanel from '@/components/focus-group-session/ThemesPanel'; import AnalyticsPanel from '@/components/focus-group-session/AnalyticsPanel'; import AutonomousDashboard from '@/components/focus-group-session/AutonomousDashboard'; import CollapsibleDiscussionGuide from '@/components/focus-group-session/CollapsibleDiscussionGuide'; import NotesPanel from '@/components/focus-group-session/NotesPanel'; import QuickNoteModal from '@/components/focus-group-session/QuickNoteModal'; import { FocusGroup, Message, Theme, Note, QuoteData, ModeEvent } from '@/components/focus-group-session/types'; import { Persona } from '@/types/persona'; import api, { focusGroupsApi, personasApi, focusGroupAiApi } from '@/lib/api'; import { useCancellableGeneration } from '@/hooks/useCancellableGeneration'; import { getSocket } from '@/services/websocketServiceNew'; import ProgressModal from '@/components/ui/ProgressModal'; // GPT-5 FIX: Use new singleton WebSocket service import { initSocket, joinFocusGroup, leaveFocusGroup } from '@/services/websocketServiceNew'; import { convertWebSocketMessage, convertWebSocketTheme, WS_EVENTS, shouldUseWebSocket } from '@/services/websocketService'; const FocusGroupSession = () => { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const { token } = useAuth(); const [messages, setMessages] = useState([]); const [modeEvents, setModeEvents] = useState([]); const [themes, setThemes] = useState([]); const [focusGroup, setFocusGroup] = useState(null); const [participants, setParticipants] = useState([]); const [activeTab, setActiveTab] = useState('chat'); const [moderatorStatus, setModeratorStatus] = useState(null); const [showAutonomousDashboard, setShowAutonomousDashboard] = useState(false); const [isAiModeActive, setIsAiModeActive] = useState(false); const [isLoading, setIsLoading] = useState(true); const [isDiscussionGuideOpen, setIsDiscussionGuideOpen] = useState(false); 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([]); // Model settings state const [showModelSettings, setShowModelSettings] = useState(false); const [selectedModel, setSelectedModel] = useState(''); const [selectedReasoningEffort, setSelectedReasoningEffort] = useState('medium'); const [selectedVerbosity, setSelectedVerbosity] = useState('medium'); const [isUpdatingModel, setIsUpdatingModel] = useState(false); const [isNoteModalOpen, setIsNoteModalOpen] = useState(false); const [sessionStartTime, setSessionStartTime] = useState(null); // Participant filtering state const [selectedParticipantIds, setSelectedParticipantIds] = useState([]); // Cancellable generation for key themes const socket = getSocket(); const [themeGenerationState, themeGenerationControls] = useCancellableGeneration('key themes generation', socket); const [isThemeProgressModalOpen, setIsThemeProgressModalOpen] = useState(false); // WebSocket status bar visibility const [isStatusBarVisible, setIsStatusBarVisible] = useState(true); // AI mode generation state - removed since we show loading continuously during AI mode // Set position dialog state const [setPositionDialog, setSetPositionDialog] = useState<{ isOpen: boolean; sectionId?: string; itemId?: string; content?: string; sectionTitle?: string; itemTitle?: string; itemType?: string; metadata?: Record; isLoading?: boolean; }>({ isOpen: false }); // Track the last known AI status from API to avoid false positive changes const lastAiStatusRef = useRef(false); // Track session status for ending detection const [sessionStatus, setSessionStatus] = useState(''); const lastSessionStatusRef = useRef(''); const sessionEndingProcessedRef = useRef(false); // WebSocket connection state tracking for notifications const wsConnectionStateRef = useRef<{ wasConnected: boolean; wasConnecting: boolean; initialConnection: boolean; hasShownFallbackNotification: boolean; }>({ wasConnected: false, wasConnecting: false, initialConnection: true, hasShownFallbackNotification: false }); // GPT-5 FIX: WebSocket singleton service const useWebSocketEnabled = shouldUseWebSocket(); // Simple WebSocket connection state (GPT-5 simplified approach) const [wsConnected, setWsConnected] = useState(false); const [wsConnecting, setWsConnecting] = useState(false); const [wsError, setWsError] = useState(null); // Get token for WebSocket auth const getAccessToken = useCallback(() => { return token || ''; }, [token]); // Initialize singleton socket (GPT-5 fix: avoid useMemo issues) useEffect(() => { if (useWebSocketEnabled) { console.log('🔧 [GPT-5 Session] Initializing WebSocket'); initSocket(getAccessToken); } }, [useWebSocketEnabled, getAccessToken]); // GPT-5 FIX: Join room on mount and reconnect useEffect(() => { if (!useWebSocketEnabled || !id) return; const tryJoin = () => { console.log('🔧 [GPT-5 Session] Joining focus group:', id); joinFocusGroup(id); }; // Join room (the singleton service handles connection state) tryJoin(); }, [id, useWebSocketEnabled]); // GPT-5 FIX: Simple connection state management useEffect(() => { if (!useWebSocketEnabled) return; // Set initial connecting state setWsConnecting(true); setWsConnected(false); setWsError(null); // Simple timeout to assume connection (the singleton service handles the actual connection) const connectionTimeout = setTimeout(() => { setWsConnected(true); setWsConnecting(false); }, 1000); return () => { clearTimeout(connectionTimeout); }; }, [useWebSocketEnabled]); // GPT-5 FIX: Handle WebSocket events via window events (decoupled from React lifecycle) useEffect(() => { if (!useWebSocketEnabled) return; console.log('🔧 [GPT-5 Session] Setting up window event listeners'); // Handle message updates const onMessageUpdate = (e: CustomEvent) => { const data = e.detail; console.log('🔧 [GPT-5 Session] message_update:', data); // Debug focus group filtering if (data.focus_group_id) { console.log('🔧 [GPT-5] Message focus_group_id:', data.focus_group_id); console.log('🔧 [GPT-5] Current focus group from URL:', id); } const newMessage = convertWebSocketMessage(data.message); if (!newMessage) { console.error('🔧 [GPT-5] convertWebSocketMessage returned null'); return; } setMessages(prev => { // Check for duplicates const exists = prev.find(m => m.id === newMessage.id); if (exists) { console.log('🔧 [GPT-5] Message already exists, skipping'); return prev; } console.log('🔧 [GPT-5] Adding new message, count:', prev.length + 1); return [...prev, newMessage]; }); }; // Handle AI status updates - GPT-5 fix: functional state updates const onAiStatusUpdate = (e: CustomEvent) => { const data = e.detail; console.log('🔧 [GPT-5 Session] ai_status_update:', data); // GPT-5 fix: Use functional updates to prevent stale closures during AI mode setIsAiModeActive(prev => data.status.status === 'ai_mode'); setSessionStatus(prev => data.status.status); }; // Handle moderator status updates const onModeratorStatusUpdate = (e: CustomEvent) => { const data = e.detail; console.log('🔧 [GPT-5 Session] moderator_status_update:', data); setModeratorStatus(data.moderator_status); }; // Handle theme updates const onThemeUpdate = (e: CustomEvent) => { const data = e.detail; console.log('🔧 [GPT-5 Session] theme_update:', data); const theme = convertWebSocketTheme(data.theme); setThemes(prev => { const updated = [...prev]; const existingIndex = updated.findIndex(t => t.id === theme.id); if (existingIndex >= 0) { updated[existingIndex] = theme; } else { updated.push(theme); } return updated; }); }; // Handle focus group updates const onFocusGroupUpdate = (e: CustomEvent) => { const data = e.detail; console.log('🔧 [GPT-5 Session] focus_group_update:', data); setFocusGroup(prev => prev ? { ...prev, ...data } : null); }; // Handle mode event updates const onModeEventUpdate = (e: CustomEvent) => { const data = e.detail; console.log('🔧 [GPT-5 Session] mode_event_update:', data); // Convert timestamp to Date object const modeEvent = { ...data, timestamp: new Date(data.timestamp), created_at: new Date(data.created_at) }; // Check for duplicates before adding setModeEvents(prev => { const existingIndex = prev.findIndex(event => event.id === modeEvent.id); if (existingIndex >= 0) { console.log('🔧 [GPT-5 Session] Mode event already exists, skipping:', modeEvent.id); return prev; // Don't add duplicate } console.log('🔧 [GPT-5 Session] Adding new mode event:', modeEvent.id); return [...prev, modeEvent]; }); }; // Handle room join confirmations const onJoinedFocusGroup = (e: CustomEvent) => { const data = e.detail; console.log('🔧 [GPT-5 Session] joined_focus_group:', data); }; // Add window event listeners console.log('🔧 [GPT-5 Session] ADDING window event listeners'); window.addEventListener('ws:message_update', onMessageUpdate as any); window.addEventListener('ws:ai_status_update', onAiStatusUpdate as any); window.addEventListener('ws:moderator_status_update', onModeratorStatusUpdate as any); window.addEventListener('ws:theme_update', onThemeUpdate as any); window.addEventListener('ws:focus_group_update', onFocusGroupUpdate as any); window.addEventListener('ws:mode_event_update', onModeEventUpdate as any); window.addEventListener('ws:joined_focus_group', onJoinedFocusGroup as any); console.log('🔧 [GPT-5 Session] ADDED all window event listeners'); // Cleanup window event listeners return () => { console.log('🔧 [GPT-5 Session] Cleaning up window event listeners'); window.removeEventListener('ws:message_update', onMessageUpdate as any); window.removeEventListener('ws:ai_status_update', onAiStatusUpdate as any); window.removeEventListener('ws:moderator_status_update', onModeratorStatusUpdate as any); window.removeEventListener('ws:theme_update', onThemeUpdate as any); window.removeEventListener('ws:focus_group_update', onFocusGroupUpdate as any); window.removeEventListener('ws:mode_event_update', onModeEventUpdate as any); window.removeEventListener('ws:joined_focus_group', onJoinedFocusGroup as any); // Leave room on unmount if (id) leaveFocusGroup(id); }; }, [useWebSocketEnabled, id]); // Simple dependencies - no complex handler deps! // WebSocket connection state notifications useEffect(() => { if (!useWebSocketEnabled || !id) return; const state = wsConnectionStateRef.current; // Handle successful WebSocket connection if (wsConnected && !state.wasConnected) { if (!state.initialConnection) { // Reconnection after disconnection toastService.success('Real-time updates restored', { description: 'WebSocket connection re-established. You\'ll now receive instant updates.', duration: 4000 }); } else { // Initial successful connection toastService.success('Live updates enabled', { description: 'Connected to real-time updates. Changes will appear instantly.', duration: 3000 }); } state.wasConnected = true; state.initialConnection = false; } // Handle WebSocket disconnection if (!wsConnected && !wsConnecting && state.wasConnected && !state.initialConnection) { toastService.warning('Connection lost', { description: 'Real-time updates unavailable. Attempting to reconnect...', duration: 5000 }); state.wasConnected = false; // Show status bar when connection is lost so user can see the issue setIsStatusBarVisible(true); } // Handle WebSocket connection errors if (wsError && !wsConnecting && !wsConnected && !state.initialConnection) { toastService.error('Connection failed', { description: 'Unable to establish real-time connection. Using periodic updates instead.', duration: 6000 }); // Show status bar when there's an error so user can see the issue setIsStatusBarVisible(true); } state.wasConnecting = wsConnecting; }, [wsConnected, wsConnecting, wsError, useWebSocketEnabled, id]); // Notification for polling fallback when WebSocket is disabled useEffect(() => { if (!useWebSocketEnabled && id && focusGroup) { const state = wsConnectionStateRef.current; if (!state.hasShownFallbackNotification) { toastService.info('Using periodic updates', { description: 'Real-time updates are not available. Data will refresh automatically every few seconds.', duration: 4000 }); state.hasShownFallbackNotification = true; } } }, [useWebSocketEnabled, id, focusGroup]); // Fetch moderator status (fallback for when WebSocket is disabled) const fetchModeratorStatus = async () => { if (!id) return; try { const response = await focusGroupAiApi.getModeratorStatus(id); if (response?.data?.status) { const newStatus = response.data.status; // Debug logging for moderator status changes if (moderatorStatus) { const positionChanged = moderatorStatus.current_section_id !== newStatus.current_section_id || moderatorStatus.current_item_id !== newStatus.current_item_id || moderatorStatus.progress !== newStatus.progress; if (positionChanged) { // Moderator position changed - update moderator position state } } else { // Initial load - no logging needed for routine status updates } // Don't update moderator status if editing discussion guide to prevent re-renders if (!isEditingDiscussionGuideRef.current) { setModeratorStatus(newStatus); } else { // Skip moderator status update while editing discussion guide } } } catch (error) { console.error("Error fetching moderator status:", error); // Don't show error toast for this as it's not critical } }; // Check if AI mode is active and get session status const checkAiModeStatus = async () => { if (!id) return { aiActive: false, sessionStatus: '' }; try { // Ensure we have a valid API method before calling if (typeof focusGroupsApi?.getById !== 'function') { console.error('focusGroupsApi.getById is not a function:', typeof focusGroupsApi?.getById); return { aiActive: isAiModeActive, sessionStatus: sessionStatus }; } const response = await focusGroupsApi.getById(id); // Validate response object exists and has expected structure if (!response || typeof response !== 'object') { console.error('Invalid response object received:', response); return { aiActive: isAiModeActive, sessionStatus: sessionStatus }; } // Status check logging reduced to prevent console spam // Validate response data structure if (!response.data || typeof response.data !== 'object') { console.warn('Focus group response missing data property:', response); return { aiActive: isAiModeActive, sessionStatus: sessionStatus }; } // Validate status field and check for expected values const status = response.data.status; if (typeof status === 'undefined') { console.warn('Focus group response missing status field:', response.data); return { aiActive: isAiModeActive, sessionStatus: sessionStatus }; } // Check for the expected ai_mode status const isActive = status === 'ai_mode'; // Removed individual generation checking since we show loading continuously during AI mode // DEBUG: Log the status check details // AI Mode Status Check // Warn about unexpected status values that might indicate backend issues if (status === 'autonomous_active') { console.warn('Detected legacy "autonomous_active" status - backend may need updating to "ai_mode"'); } else if (!['ai_mode', 'active', 'completed', 'paused', 'draft', 'in-progress'].includes(status)) { console.warn('Unexpected focus group status value:', status); } // Return the current status without updating state here // State will be updated by the calling code to avoid race conditions return { aiActive: isActive, sessionStatus: status }; } catch (error) { console.error("Error checking AI mode status:", error); // Add more detailed error logging with context const errorDetails = { focusGroupId: id, currentAiModeStatus: isAiModeActive, errorType: 'unknown', timestamp: new Date().toISOString() }; if (error.response) { errorDetails.errorType = 'api_error'; errorDetails.status = error.response.status; errorDetails.data = error.response.data; console.error('API error response:', error.response.status, error.response.data); // Specific handling for common API errors if (error.response.status === 404) { console.warn('Focus group not found - may have been deleted'); } else if (error.response.status === 500) { console.error('Server error during status check - backend issue'); } } else if (error.request) { errorDetails.errorType = 'network_error'; console.error('Network error - no response received, check connectivity'); } else { errorDetails.errorType = 'request_setup'; errorDetails.message = error.message; console.error('Request setup error:', error.message); } console.debug('Status check error details:', errorDetails); // Don't change the current state on error - return current state return { aiActive: isAiModeActive, sessionStatus: sessionStatus, isGenerating: false }; } }; // Handle session ending with concluding statement const handleSessionEnding = async (newStatus: string, previousStatus: string) => { if (!id || sessionEndingProcessedRef.current) return; // Define statuses that indicate a session has ended const endedStatuses = ['completed', 'paused']; const activeStatuses = ['ai_mode', 'autonomous_active', 'active', 'in-progress']; const wasActive = activeStatuses.includes(previousStatus); const isNowEnded = endedStatuses.includes(newStatus); // Trigger concluding statement if session transitioned from active to ended if (wasActive && isNowEnded) { sessionEndingProcessedRef.current = true; try { // Determine the reason based on the status change let reason = 'session_ended'; if (newStatus === 'completed') { reason = 'auto_complete'; } else if (newStatus === 'paused') { reason = 'manual_stop'; } // Call the new endSession API const response = await focusGroupAiApi.endSession(id, reason); if (response?.data) { // Show success toast toastService.success("Session concluded", { description: "The focus group session has ended with a concluding statement from the moderator." }); // Refresh messages to show the concluding statement setTimeout(() => { fetchMessages(); }, 1000); } } catch (error) { console.error('❌ Error ending session with concluding statement:', error); toastService.error("Error ending session", { description: "Failed to add concluding statement, but the session has ended." }); } } }; // Fetch messages for the focus group const fetchMessages = async () => { if (!id) return; try { const response = await focusGroupsApi.getMessages(id); console.log('🔍 [FetchMessages] Raw API response:', response?.data); // Handle both old (array) and new (object with messages/mode_events) response formats let messagesData: any[] = []; let modeEventsData: any[] = []; if (response && response.data) { if (Array.isArray(response.data)) { // Old format - just messages messagesData = response.data; modeEventsData = []; } else if (response.data.messages || response.data.mode_events) { // New format - messages and mode events (both could be present or just one) messagesData = response.data.messages || []; modeEventsData = response.data.mode_events || []; } else { // Fallback - treat the whole response as messages array messagesData = Array.isArray(response.data) ? response.data : []; modeEventsData = []; } } // Convert dates and format messages const formattedMessages = messagesData.map((msg: any) => ({ id: msg._id || msg.id || `msg-${Date.now()}`, senderId: msg.senderId, text: msg.text, timestamp: new Date(msg.timestamp || msg.created_at), type: msg.type || 'response', highlighted: msg.highlighted || false, visualAsset: msg.visualAsset // Include visual asset metadata for image display })); console.log('🔍 [FetchMessages] Formatted messages with visual assets:', formattedMessages.filter(m => m.visualAsset).map(m => ({ id: m.id, senderId: m.senderId, hasVisualAsset: !!m.visualAsset, visualAsset: m.visualAsset })) ); // Convert dates and format mode events const formattedModeEvents = modeEventsData.map((event: any) => ({ id: event._id || event.id || `event-${Date.now()}`, focus_group_id: event.focus_group_id, event_type: event.event_type, timestamp: new Date(event.timestamp || event.created_at), user_id: event.user_id, created_at: new Date(event.created_at) })); // Always update mode events setModeEvents(formattedModeEvents); // Always process messages, use functional setState to access current state if (formattedMessages.length > 0) { setMessages(prevMessages => { if (prevMessages.length === 0) { // No existing messages, use the fetched ones return formattedMessages; } else { // Merge messages, keeping any local ones that aren't in the API response // and preserving the highlighted state for existing messages // Create a map of existing messages by ID for quick lookup const existingMsgsMap = new Map(); prevMessages.forEach(msg => existingMsgsMap.set(msg.id, msg)); // Process all messages from the API const processedMessages = formattedMessages.map(apiMsg => { // If we already have this message locally, preserve its highlighted state if (existingMsgsMap.has(apiMsg.id)) { const existingMsg = existingMsgsMap.get(apiMsg.id); return { ...apiMsg, highlighted: existingMsg.highlighted // Keep local highlight state }; } return apiMsg; // New message, use API data }); // Add new messages from API that don't exist locally const newMsgIds = new Set(processedMessages.map(msg => msg.id)); const localOnlyMessages = prevMessages.filter(msg => !newMsgIds.has(msg.id)); const mergedMessages = [...processedMessages, ...localOnlyMessages]; // Sort by timestamp return mergedMessages.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); } }); } else if (formattedMessages.length === 0) { // API returned empty - only clear messages if we don't have any yet setMessages(prevMessages => { if (prevMessages.length === 0) { return []; } else { return prevMessages; // Keep existing messages } }); } // Generate basic themes from highlighted messages (run this regardless of new messages) // Find highlighted messages and create themes from them const highlightedMessages = formattedMessages.filter(msg => msg.highlighted); // Create themes from highlighted messages const extractedThemes = highlightedMessages.length > 0 ? highlightedMessages.map(msg => ({ id: `theme-${msg.id}`, text: msg.text.substring(0, 40) + (msg.text.length > 40 ? '...' : ''), count: 1, messages: [msg.id], source: 'highlight' as const })) : []; // Also fetch any AI-generated themes try { const aiThemesResponse = await focusGroupAiApi.getKeyThemes(id); if (aiThemesResponse?.data?.themes && Array.isArray(aiThemesResponse.data.themes)) { const generatedThemes = aiThemesResponse.data.themes; setThemes([...extractedThemes, ...generatedThemes]); } else { setThemes(extractedThemes); } } catch (error) { console.error("Error fetching AI-generated themes:", error); setThemes(extractedThemes); } } catch (error) { console.error("Error fetching messages:", error); // Keep existing messages if API fails if (messages.length === 0) { toastService.error("Failed to fetch messages", { description: "Please try again later or restart the session." }); } } }; // Function to reload the focus group data const reloadFocusGroup = async () => { if (!id) return false; try { const allPersonasResponse = await personasApi.getAll(); const allPersonas = allPersonasResponse.data || []; const response = await focusGroupsApi.getById(id); if (response && response.data) { const data = response.data; console.log("Focus group data from API:", data); // Process focus group data const focusGroupData = { id: data._id || data.id, name: data.name, status: data.status || 'in-progress', participants: data.participants || [], date: data.date || new Date().toISOString(), duration: data.duration || 60, topic: data.topic || 'general', discussionGuide: data.discussionGuide || '', llm_model: data.llm_model || 'gemini-3-pro-preview' }; setFocusGroup(focusGroupData); setSelectedModel(focusGroupData.llm_model || 'gemini-3-pro-preview'); setSelectedReasoningEffort(focusGroupData.reasoning_effort || 'medium'); setSelectedVerbosity(focusGroupData.verbosity || 'medium'); // Handle participants if (data.participants_data && Array.isArray(data.participants_data)) { // Use participants data if available setParticipants(data.participants_data.map((p: any) => ({ ...p, id: p._id || p.id }))); } else if (focusGroupData.participants && Array.isArray(focusGroupData.participants)) { console.log("Matching participants from DB:", { focusGroupParticipants: focusGroupData.participants, allPersonas: allPersonas.map(p => ({ id: p._id || p.id, name: p.name })) }); // Otherwise use real personas from the database const groupParticipants = allPersonas.filter(persona => { const personaId = persona._id || persona.id; return focusGroupData.participants.includes(personaId); }); console.log("Matched participants:", groupParticipants.map(p => p.name)); setParticipants(groupParticipants); } // Load messages and moderator status await fetchMessages(); await fetchModeratorStatus(); // Immediately check and update AI mode status const statusResult = await checkAiModeStatus(); setIsAiModeActive(statusResult.aiActive); setSessionStatus(statusResult.sessionStatus); // Initialize the refs with the initial API result lastAiStatusRef.current = statusResult.aiActive; lastSessionStatusRef.current = statusResult.sessionStatus; return true; } return false; } catch (error) { console.error("Error fetching focus group:", error); return false; } }; // Function to update the LLM model for this focus group const updateFocusGroupModel = async (newModel: string, reasoningEffort?: string, verbosity?: string) => { console.log('🔧 updateFocusGroupModel called with:', { id, focusGroup: !!focusGroup, newModel, reasoningEffort, verbosity }); if (!id || !focusGroup) { console.log('❌ updateFocusGroupModel: Missing id or focusGroup', { id, focusGroup: !!focusGroup }); return; } setIsUpdatingModel(true); try { const updateData: any = { llm_model: newModel }; // Only include GPT-5 parameters if the model is GPT-5 if (newModel === 'gpt-5') { updateData.reasoning_effort = reasoningEffort || selectedReasoningEffort; updateData.verbosity = verbosity || selectedVerbosity; } console.log('🔧 Making API call to update focus group model:', { id, updateData }); const response = await focusGroupsApi.update(id, updateData); console.log('🔧 API response:', response); if (response && response.data) { setFocusGroup(prev => prev ? { ...prev, llm_model: newModel, reasoning_effort: newModel === 'gpt-5' ? (reasoningEffort || selectedReasoningEffort) : prev?.reasoning_effort, verbosity: newModel === 'gpt-5' ? (verbosity || selectedVerbosity) : prev?.verbosity } : null); toastService.success('AI Model Updated', { description: `Focus group will now use ${ newModel === 'gemini-3-pro-preview' ? 'Gemini 3 Pro' : newModel === 'gpt-4.1' ? 'GPT-4.1' : newModel === 'gpt-5' ? 'GPT-5' : newModel } for AI responses` }); setShowModelSettings(false); console.log('✅ Model update successful'); } else { console.log('❌ API response missing data:', response); } } catch (error) { console.error('❌ Error updating focus group model:', error); toastService.error('Failed to update AI model', { description: 'There was an error updating the AI model. Please try again.' }); } finally { setIsUpdatingModel(false); } }; useEffect(() => { console.log("Looking for focus group with ID:", id); // Fetch all personas from the database first const fetchAllPersonas = async () => { try { const response = await personasApi.getAll(); return response.data || []; } catch (error) { console.error("Error fetching personas:", error); return []; } }; // First try to fetch from API const fetchFocusGroup = async (allPersonas: any[]) => { try { const response = await focusGroupsApi.getById(id as string); if (response && response.data) { const data = response.data; console.log("Focus group data from API:", data); // Process focus group data const focusGroupData = { id: data._id || data.id, name: data.name, status: data.status || 'in-progress', participants: data.participants || [], date: data.date || new Date().toISOString(), duration: data.duration || 60, topic: data.topic || 'general', discussionGuide: data.discussionGuide || '', llm_model: data.llm_model || 'gemini-3-pro-preview' }; setFocusGroup(focusGroupData); setSelectedModel(focusGroupData.llm_model || 'gemini-3-pro-preview'); setSelectedReasoningEffort(focusGroupData.reasoning_effort || 'medium'); setSelectedVerbosity(focusGroupData.verbosity || 'medium'); // Handle participants if (data.participants_data && Array.isArray(data.participants_data)) { // Use participants data if available setParticipants(data.participants_data.map((p: any) => ({ ...p, id: p._id || p.id }))); } else if (focusGroupData.participants && Array.isArray(focusGroupData.participants)) { console.log("Matching participants from DB:", { focusGroupParticipants: focusGroupData.participants, allPersonas: allPersonas.map(p => ({ id: p._id || p.id, name: p.name })) }); // Otherwise use real personas from the database const groupParticipants = allPersonas.filter(persona => { const personaId = persona._id || persona.id; return focusGroupData.participants.includes(personaId); }); console.log("Matched participants:", groupParticipants.map(p => p.name)); setParticipants(groupParticipants); } // Always load messages from the database fetchMessages(); fetchModeratorStatus(); setIsLoading(false); // Stop loading once focus group is loaded return true; } return false; } catch (error) { console.error("Error fetching focus group:", error); return false; } }; // Set up dynamic polling based on AI mode status let messageRefreshInterval: number | undefined; let aiStatusCheckInterval: number | undefined; // Try API first with real personas fetchAllPersonas().then(allPersonas => { fetchFocusGroup(allPersonas).then(success => { if (success) { // Set up polling if WebSocket is disabled OR if WebSocket connection failed const wsConnectionFailed = wsError && (wsError.includes('unavailable') || wsError.includes('websocket error')); const shouldUsePolling = !useWebSocketEnabled || (useWebSocketEnabled && wsConnectionFailed); if (shouldUsePolling) { console.log(useWebSocketEnabled ? '📡 WebSocket connection failed, falling back to polling' : '📡 WebSocket disabled, using polling for updates'); const startDynamicPolling = () => { const pollMessages = () => { fetchMessages(); fetchModeratorStatus(); // Don't check AI status here - let the dedicated 15s interval handle it // This prevents state oscillation from competing interval calls // Clear existing interval if (messageRefreshInterval) { window.clearInterval(messageRefreshInterval); } // Set new interval based on current AI mode status const interval = isAiModeActive ? 3000 : 10000; // 3s when AI active, 10s when inactive // DEBUG: Log polling setup console.log('📡 Setting up message polling:', { aiModeActive: isAiModeActive, pollInterval: interval, timestamp: new Date().toISOString() }); messageRefreshInterval = window.setInterval(() => { // Skip polling when editing discussion guide to prevent focus loss if (!isEditingDiscussionGuideRef.current) { console.log('📡 Polling for messages...', new Date().toISOString()); fetchMessages(); fetchModeratorStatus(); } else { console.log('📡 Skipping poll - editing discussion guide'); } }, interval); }; // Start polling pollMessages(); // Also check AI status every 15 seconds to adjust polling if needed aiStatusCheckInterval = window.setInterval(async () => { const previousApiStatus = lastAiStatusRef.current; const previousSessionStatus = lastSessionStatusRef.current; const statusResult = await checkAiModeStatus(); // Update the refs with the new API results lastAiStatusRef.current = statusResult.aiActive; lastSessionStatusRef.current = statusResult.sessionStatus; // Update state after getting the new status setIsAiModeActive(statusResult.aiActive); setSessionStatus(statusResult.sessionStatus); // Check for session ending (status transition) if (previousSessionStatus && previousSessionStatus !== statusResult.sessionStatus) { await handleSessionEnding(statusResult.sessionStatus, previousSessionStatus); } // Only restart interval if AI mode actually changed if (previousApiStatus !== statusResult.aiActive && messageRefreshInterval) { window.clearInterval(messageRefreshInterval); const newInterval = statusResult.aiActive ? 3000 : 10000; messageRefreshInterval = window.setInterval(() => { // Skip polling when editing discussion guide to prevent focus loss if (!isEditingDiscussionGuideRef.current) { fetchMessages(); fetchModeratorStatus(); } }, newInterval); } }, 15000); // Check every 15 seconds }; startDynamicPolling(); } else { console.log('📡 WebSocket enabled, skipping polling setup'); } } else { console.error("Focus group not found with ID:", id); setIsLoading(false); // Stop loading since we've determined it's not found toastService.error("Focus group not found", { description: `Could not find focus group with ID: ${id}` }); // Don't navigate immediately, let user see the error message } }); }); // Clean up intervals on component unmount return () => { if (messageRefreshInterval) { window.clearInterval(messageRefreshInterval); } if (aiStatusCheckInterval) { window.clearInterval(aiStatusCheckInterval); } }; }, [id, navigate, useWebSocketEnabled, wsError]); // Helper function to get the first discussion item from the discussion guide const getFirstDiscussionItem = (discussionGuide: any) => { // Check if we have a valid structured discussion guide if (!discussionGuide || !discussionGuide.sections || !Array.isArray(discussionGuide.sections)) { return { content: "Welcome to our focus group session! Let's begin our discussion.", sectionId: 'welcome', itemId: 'welcome-message' }; } // Get the first section const firstSection = discussionGuide.sections[0]; if (!firstSection) { return { content: "Welcome to our focus group session! Let's begin our discussion.", sectionId: 'welcome', itemId: 'welcome-message' }; } // Helper to get first item from a container (section or subsection) const getFirstItemFromContainer = (container: any) => { // Check for questions first if (container.questions && Array.isArray(container.questions) && container.questions.length > 0) { return { content: container.questions[0].content, itemId: container.questions[0].id, type: 'question' }; } // Then check for activities if (container.activities && Array.isArray(container.activities) && container.activities.length > 0) { return { content: container.activities[0].content, itemId: container.activities[0].id, type: 'activity' }; } return null; }; // Try to get first item from the first section let firstItem = getFirstItemFromContainer(firstSection); // If the first section doesn't have direct questions/activities, check subsections if (!firstItem && firstSection.subsections && Array.isArray(firstSection.subsections)) { for (const subsection of firstSection.subsections) { firstItem = getFirstItemFromContainer(subsection); if (firstItem) break; } } // Return the first item if found, otherwise a fallback if (firstItem) { return { content: firstItem.content, sectionId: firstSection.id, itemId: firstItem.itemId }; } // Fallback if no questions or activities found return { content: `Welcome to our focus group session on "${firstSection.title || 'our topic'}". Let's begin our discussion.`, sectionId: firstSection.id, itemId: 'section-intro' }; }; const startSession = async () => { if (id) { try { toastService.info("Starting focus group session...", { description: "The session is now ready for AI moderation." }); // Only initialize moderator position if it doesn't already exist // This preserves progress if someone stops and restarts the session try { const currentStatus = await focusGroupAiApi.getModeratorStatus(id); const hasExistingPosition = currentStatus?.data?.status?.moderator_position; if (!hasExistingPosition) { await focusGroupAiApi.setModeratorPosition(id, focusGroup?.discussionGuide?.sections?.[0]?.id || 'welcome' ); console.log('🚀 Moderator position initialized to start of discussion guide (first time)'); } else { console.log('📍 Preserving existing moderator position:', hasExistingPosition); } } catch (error) { console.warn('Failed to check/initialize moderator position:', error); } // Set the focus group status to active await focusGroupsApi.update(id, { status: 'active' }); // Create initial moderator message from the discussion guide try { const firstDiscussionItem = getFirstDiscussionItem(focusGroup?.discussionGuide); // Send the message to the API - it will be added back via websocket/polling with server timestamp const msgResponse = await focusGroupsApi.sendMessage(id, { senderId: 'moderator', text: firstDiscussionItem.content, type: 'question' }); console.log('🚀 Initial moderator message created:', { content: firstDiscussionItem.content, sectionId: firstDiscussionItem.sectionId, itemId: firstDiscussionItem.itemId }); } catch (messageError) { console.warn('Failed to create initial moderator message:', messageError); // Don't fail the entire session start if message creation fails // The session can still proceed without the initial message } toastService.success("Focus group session started", { description: "The discussion has begun. Use the control panel below to moderate." }); } catch (error) { console.error("Error starting session:", error); toastService.error("Error starting session", { description: "There was a problem connecting to the server." }); } } }; // This function is kept for backward compatibility with other features // but we now use the direct AI response generation in DiscussionPanel const addModeratorMessage = async () => { try { if (!id) return; // Send to API - message will be added back via websocket/polling with server timestamp const msgResponse = await focusGroupsApi.sendMessage(id, { senderId: 'moderator', text: 'Can you share specific examples that you think handle this particularly well?', type: 'question' }); toastService.info("Added moderator message", { description: "You can now click 'Advance Discussion' to get AI-generated responses." }); } catch (error) { console.error("Error adding moderator message:", error); toastService.error("Failed to add moderator message", { description: "There was a problem connecting to the server." }); } }; // Handler for adding new messages to the conversation const handleNewMessage = (message: Message) => { setMessages(prevMessages => { // Check for duplicates const exists = prevMessages.find(m => m.id === message.id); if (exists) { console.log('🔧 [handleNewMessage] Message already exists, skipping:', message.id); return prevMessages; } console.log('🔧 [handleNewMessage] Adding new message:', message.id); return [...prevMessages, message]; }); }; const toggleHighlight = async (messageId: string) => { // First update the UI optimistically const updatedMessages = [...messages]; const messageIndex = updatedMessages.findIndex(msg => msg.id === messageId); if (messageIndex !== -1) { const message = updatedMessages[messageIndex]; const newHighlightState = !message.highlighted; // Update locally first for immediate feedback updatedMessages[messageIndex] = { ...message, highlighted: newHighlightState }; setMessages(updatedMessages); // Then save to database if we have a valid focus group ID if (id) { try { // Don't attempt to update local test messages in the database if (!messageId.startsWith('local-') && !messageId.startsWith('msg-')) { await focusGroupsApi.updateMessageHighlight(id, messageId, newHighlightState); } else { console.log('Skipping database update for local message:', messageId); } } catch (error) { console.error('Error updating message highlight state:', error); toastService.error('Failed to save highlight state', { description: 'The highlight may not persist if the page is refreshed.' }); } } } }; const getPersona = (id: string) => { // Handle both local-created IDs and MongoDB IDs return participants.find(p => p.id === id || p._id === id); }; const downloadTranscript = () => { const transcript = messages.map(msg => { let sender: string; if (msg.senderId === 'moderator') { sender = 'AI Moderator'; } else if (msg.senderId === 'facilitator') { sender = 'Human Facilitator'; } else { sender = getPersona(msg.senderId)?.name || 'Unknown'; } const time = msg.timestamp.toLocaleTimeString(); return `[${time}] ${sender}: ${msg.text}`; }).join('\n\n'); const element = document.createElement('a'); const file = new Blob([transcript], {type: 'text/plain'}); element.href = URL.createObjectURL(file); element.download = `focus-group-${id}-transcript.txt`; document.body.appendChild(element); element.click(); document.body.removeChild(element); toastService.success("Transcript downloaded", { description: "The focus group transcript has been saved to your device." }); }; const handleQuoteClick = (quote: string | QuoteData, messageId?: string) => { // Helper function to extract quote text without speaker attribution const extractQuoteText = (quote: string): string => { // Check if quote has attribution format [Name]: text const attributionMatch = quote.match(/^\[([^\]]+)\]:\s*(.*)$/); if (attributionMatch) { return attributionMatch[2].trim(); } return quote.trim(); }; // Helper function to normalize text for comparison const normalizeText = (text: string): string => { return text.toLowerCase() .replace(/[^\w\s]/g, ' ') // Replace punctuation with spaces .replace(/\s+/g, ' ') // Normalize whitespace .trim(); }; // Helper function to calculate similarity between two strings const calculateSimilarity = (str1: string, str2: string): number => { const normalized1 = normalizeText(str1); const normalized2 = normalizeText(str2); if (normalized1 === normalized2) return 1.0; // Check if one contains the other if (normalized1.includes(normalized2) || normalized2.includes(normalized1)) { return Math.min(normalized1.length, normalized2.length) / Math.max(normalized1.length, normalized2.length); } // Simple word overlap calculation const words1 = normalized1.split(' '); const words2 = normalized2.split(' '); const commonWords = words1.filter(word => words2.includes(word) && word.length > 2); if (words1.length === 0 || words2.length === 0) return 0; return commonWords.length / Math.max(words1.length, words2.length); }; // Handle both string and QuoteData formats const isQuoteData = typeof quote === 'object' && quote !== null; const quoteText = isQuoteData ? quote.text : extractQuoteText(quote as string); const originalQuote = isQuoteData ? quote.original : (quote as string); // Try multiple matching strategies in order of preference let matchingMessage = null; let matchReason = ''; // Strategy 0: Direct message ID lookup (highest priority for new format) if (messageId) { matchingMessage = messages.find(msg => msg.id === messageId); if (matchingMessage) { matchReason = 'direct_message_id_match'; } else { console.warn(`Message ID ${messageId} not found in current messages array`); } } // Strategy 1: Exact match with original quote (only if no message ID match) if (!matchingMessage) { matchingMessage = messages.find(msg => msg.text.includes(originalQuote)); if (matchingMessage) { matchReason = 'exact_full_match'; } } // Strategy 2: Exact match with quote text (without attribution) if (!matchingMessage) { matchingMessage = messages.find(msg => msg.text.includes(quoteText)); if (matchingMessage) { matchReason = 'exact_text_match'; } } // Strategy 3: Reverse exact match (quote contains message text) if (!matchingMessage) { matchingMessage = messages.find(msg => quoteText.includes(msg.text.trim())); if (matchingMessage) { matchReason = 'reverse_exact_match'; } } // Strategy 4: Case-insensitive partial match if (!matchingMessage) { const quoteLower = quoteText.toLowerCase(); matchingMessage = messages.find(msg => msg.text.toLowerCase().includes(quoteLower) || quoteLower.includes(msg.text.toLowerCase()) ); if (matchingMessage) { matchReason = 'case_insensitive_match'; } } // Strategy 5: Fuzzy matching with similarity threshold if (!matchingMessage) { const candidates = messages.map(msg => ({ message: msg, similarity: calculateSimilarity(quoteText, msg.text) })) .filter(candidate => candidate.similarity > 0.7) // 70% similarity threshold .sort((a, b) => b.similarity - a.similarity); if (candidates.length > 0) { matchingMessage = candidates[0].message; matchReason = `fuzzy_match_${Math.round(candidates[0].similarity * 100)}%`; } } // Strategy 6: Partial word matching (as last resort) if (!matchingMessage) { const quoteLower = normalizeText(quoteText); const words = quoteLower.split(' ').filter(word => word.length > 3); if (words.length > 0) { matchingMessage = messages.find(msg => { const msgNormalized = normalizeText(msg.text); return words.every(word => msgNormalized.includes(word)); }); if (matchingMessage) { matchReason = 'partial_word_match'; } } } if (matchingMessage) { // Log successful match for debugging console.log(`Quote match found using strategy: ${matchReason}`, { quoteType: isQuoteData ? 'QuoteData' : 'string', providedMessageId: messageId, extractedText: quoteText, matchedMessage: matchingMessage.text.substring(0, 100), matchedMessageId: matchingMessage.id, originalQuote: originalQuote.substring(0, 100) }); // Switch to discussion tab setActiveTab('chat'); // Scroll to and highlight the message setTimeout(() => { const messageElement = document.getElementById(`message-${matchingMessage.id}`); if (messageElement) { // Don't scroll if editing discussion guide if (!isEditingDiscussionGuide) { messageElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); } // Add a temporary highlight effect messageElement.style.backgroundColor = '#fbbf24'; messageElement.style.transition = 'background-color 0.3s ease'; setTimeout(() => { messageElement.style.backgroundColor = ''; }, 2000); } }, 100); } else { // Log failed match for debugging console.warn('Quote match failed', { quoteType: isQuoteData ? 'QuoteData' : 'string', providedMessageId: messageId, originalQuote: originalQuote.substring(0, 100), extractedText: quoteText.substring(0, 100), totalMessages: messages.length, messageSample: messages.slice(0, 3).map(m => ({ id: m.id, text: m.text.substring(0, 50) })) }); toastService.warning('Message not found', { description: 'Could not locate the original message for this quote. The quote may have been paraphrased by the AI.' }); } }; // Handler for when new themes are generated const handleThemesGenerated = (newThemes: Theme[]) => { setThemes(currentThemes => { // Get existing theme IDs to avoid duplicates const existingThemeIds = new Set(currentThemes.map(t => t.id)); // Filter out themes that already exist const uniqueNewThemes = newThemes.filter(t => !existingThemeIds.has(t.id)); return [...currentThemes, ...uniqueNewThemes]; }); }; // Handler for deleting a theme const handleThemeDelete = async (themeId: string) => { if (!id) return; // Find the theme to delete const theme = themes.find(t => t.id === themeId); if (!theme) return; try { // For generated themes, delete from database if ('source' in theme && theme.source === 'generated') { await focusGroupAiApi.deleteKeyTheme(id, themeId); } // Update local state by removing the theme setThemes(themes.filter(t => t.id !== themeId)); } catch (error) { console.error('Error deleting theme:', error); toastService.error('Failed to delete theme', { description: 'There was an error removing the theme. Please try again.' }); } }; // Handler for section selection in discussion guide const handleSectionSelect = useCallback(async (sectionId: string, itemId?: string) => { if (!id) return; try { await focusGroupAiApi.setModeratorPosition(id, sectionId, itemId); // Note: Moderator status will be updated automatically via WebSocket event toastService.success('Moderator position updated', { description: 'The moderator has been moved to the selected section.' }); } catch (error) { console.error('Error setting moderator position:', error); toastService.error('Failed to update moderator position', { description: 'There was an error updating the moderator position.' }); } }, [id]); // 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 { await focusGroupsApi.update(id, { discussionGuide: updatedGuide }); // 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, 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(() => { setIsDiscussionGuideOpen(prev => !prev); }, []); // Handler for setting moderator position const handleSetPosition = useCallback((sectionId: string, itemId: string, content: string, sectionTitle: string, itemTitle?: string, itemType?: string, metadata?: Record) => { setSetPositionDialog({ isOpen: true, sectionId, itemId, content, sectionTitle, itemTitle, itemType, metadata }); }, []); // Helper function to extract asset filename from creative review content const extractAssetFilename = (content: string): string | null => { console.log('🔍 EXTRACT ASSET FILENAME DEBUG - Input content:', content); // Look for patterns like "titled 'filename.jpg'" or similar const patterns = [ /'([^']*\.[a-zA-Z]{3,4})'/g, // 'filename.ext' /"([^"]*\.[a-zA-Z]{3,4})"/g, // "filename.ext" /titled\s+['"]([^'"]*\.[a-zA-Z]{3,4})['"](?:\.|,|\s|$)/gi, // titled 'filename.ext' /asset[:\s]+['"]?([^'"\s]*\.[a-zA-Z]{3,4})['"]?(?:\.|,|\s|$)/gi, // asset: filename.ext /image[:\s]+['"]?([^'"\s]*\.[a-zA-Z]{3,4})['"]?(?:\.|,|\s|$)/gi, // image: filename.ext /file[:\s]+['"]?([^'"\s]*\.[a-zA-Z]{3,4})['"]?(?:\.|,|\s|$)/gi, // file: filename.ext /\b([a-zA-Z0-9_-]+\.[a-zA-Z]{3,4})\b/g // standalone filename.ext ]; for (let i = 0; i < patterns.length; i++) { const pattern = patterns[i]; console.log(`🔍 Testing pattern ${i + 1}:`, pattern.source); const matches = Array.from(content.matchAll(pattern)); console.log(`🔍 Pattern ${i + 1} matches:`, matches.length, matches); if (matches.length > 0) { // Get the first capture group from the first match const filename = matches[0][1]; console.log(`🔍 Pattern ${i + 1} extracted filename:`, filename); if (filename && filename.includes('.')) { console.log('✅ EXTRACT ASSET FILENAME - Found:', filename); return filename; } } } console.warn('❌ EXTRACT ASSET FILENAME - No filename found in content'); return null; }; // Notes-related functions const getElapsedTime = (): number => { if (!sessionStartTime) return 0; return Date.now() - sessionStartTime.getTime(); }; const formatElapsedTime = (milliseconds: number): string => { const totalSeconds = Math.floor(milliseconds / 1000); const minutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60; return `${minutes}:${seconds.toString().padStart(2, '0')}`; }; const getCurrentSectionInfo = () => { if (!moderatorStatus) return undefined; return { sectionId: moderatorStatus.current_section_id, sectionTitle: moderatorStatus.current_section, itemId: moderatorStatus.current_item_id, itemTitle: moderatorStatus.current_item }; }; const getMostRecentMessageId = (): string | undefined => { if (messages.length === 0) return undefined; return messages[messages.length - 1].id; }; const getMessageTimestamp = (): Date => { const messageId = getMostRecentMessageId(); // If we have messages, use the most recent message timestamp if (messageId && messages.length > 0) { const associatedMessage = messages.find(msg => msg.id === messageId); if (associatedMessage) { return associatedMessage.timestamp; } } // Fallback: use focus group date, session start time, or current time as last resort if (focusGroup?.date) { return new Date(focusGroup.date); } if (sessionStartTime) { return sessionStartTime; } return new Date(); }; // Theme generation functions const generateKeyThemes = async () => { if (!id) return; // Reset states and open progress modal themeGenerationControls.startGeneration(); setIsThemeProgressModalOpen(true); toastService.info("Analyzing discussion for key themes...", { description: "This may take a moment as we process the entire conversation." }); try { const response = await focusGroupAiApi.generateKeyThemes(id); if (response.data && response.data.themes) { // Update themes state immediately setThemes(prevThemes => [...prevThemes, ...response.data.themes]); // Allow progress to animate for at least 3 seconds before completing setTimeout(() => { themeGenerationControls.completeGeneration(); toastService.success(`Generated ${response.data.themes.length} key themes`, { description: "New themes have been added to the analysis." }); }, 3000); } else { // Allow progress to animate for at least 3 seconds before completing setTimeout(() => { themeGenerationControls.completeGeneration(); toastService.warning("No new themes were generated", { description: "Try again when the discussion has more content." }); }, 3000); } } catch (error) { console.error('Error generating key themes:', error); themeGenerationControls.failGeneration('Failed to generate key themes'); toastService.error("Failed to generate key themes", { description: "There was an error analyzing the discussion. Please try again." }); } // Note: Don't reset generation here - let the progress modal handle it }; const handleThemeProgressComplete = () => { setIsThemeProgressModalOpen(false); themeGenerationControls.resetGeneration(); }; const handleOpenNoteModal = () => { // Initialize session start time if not already set if (!sessionStartTime) { setSessionStartTime(new Date()); } setIsNoteModalOpen(true); }; const handleNoteSaved = (note: Note) => { setNotes(prevNotes => [...prevNotes, note].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())); // Also add to NotesPanel if it exists if ((window as any).notesPanelAddNote) { (window as any).notesPanelAddNote(note); } }; const handleNoteClick = (associatedMessageId: string) => { // Find the message that matches this ID const matchingMessage = messages.find(msg => msg.id === associatedMessageId); if (matchingMessage) { // Switch to discussion tab setActiveTab('chat'); // Scroll to and highlight the message setTimeout(() => { const messageElement = document.getElementById(`message-${matchingMessage.id}`); if (messageElement) { // Don't scroll if editing discussion guide if (!isEditingDiscussionGuide) { messageElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); } // Add a temporary highlight effect messageElement.style.backgroundColor = '#fbbf24'; messageElement.style.transition = 'background-color 0.3s ease'; setTimeout(() => { messageElement.style.backgroundColor = ''; }, 2000); } }, 100); } else { toastService.info('Message not found', { description: 'Could not locate the original message for this note.' }); } }; // Initialize session start time when messages first appear useEffect(() => { if (messages.length > 0 && !sessionStartTime) { setSessionStartTime(new Date()); } }, [messages.length, sessionStartTime]); // Sync editing state with ref and fetch latest moderator status when editing stops useEffect(() => { isEditingDiscussionGuideRef.current = isEditingDiscussionGuide; if (!isEditingDiscussionGuide) { // Fetch the latest moderator status when editing stops fetchModeratorStatus(); } }, [isEditingDiscussionGuide]); // Toggle participant filter selection const toggleParticipantFilter = (participantId: string) => { setSelectedParticipantIds(prevSelected => { if (prevSelected.includes(participantId)) { // Remove participant from selection return prevSelected.filter(id => id !== participantId); } else { // Add participant to selection return [...prevSelected, participantId]; } }); }; // Show loading state while fetching if (isLoading) { return (

Loading focus group...

); } // Show error state if not found after loading if (!focusGroup) { return (

Focus group not found

We couldn't find the focus group you're looking for.

); } return (
{/* WebSocket Connection Status Bar */} {useWebSocketEnabled && isStatusBarVisible && (
{wsConnected ? 'Real-time updates active - Changes appear instantly' : wsConnecting ? 'Connecting to real-time updates...' : 'Real-time updates unavailable - Using periodic refresh'} {wsError && ( (Connection error) )}
)} {/* Status Bar Toggle Button (when hidden) */} {useWebSocketEnabled && !isStatusBarVisible && (
)}

{focusGroup.name}

{new Date(focusGroup.date).toLocaleString()}

{focusGroup.llm_model === 'gpt-4.1' ? 'GPT-4.1' : focusGroup.llm_model === 'gpt-5' ? 'GPT-5' : 'Gemini 3 Pro'}
{/* Progress Modal for Key Themes Generation */} setIsThemeProgressModalOpen(false)} isActive={themeGenerationState.isGenerating} isComplete={themeGenerationState.isComplete} hasError={themeGenerationState.hasError} isCancelling={themeGenerationState.isCancelling} taskId={themeGenerationState.taskId} title="Extracting Key Themes" description="Analyzing the conversation to identify key themes and insights. This typically takes 30-60 seconds." onCancel={themeGenerationControls.cancelGeneration} onComplete={handleThemeProgressComplete} /> {/* Collapsible Discussion Guide Panel */}
Discussion Key Themes Notes Analytics {messages.length === 0 ? (

No messages yet. Start the session to begin the discussion.

) : ( null} onNewMessage={handleNewMessage} onStatusChange={reloadFocusGroup} isEditingDiscussionGuide={isEditingDiscussionGuide} /> )}
{/* Floating Note Button */} {messages.length > 0 && (
)} {/* Quick Note Modal */} setIsNoteModalOpen(false)} focusGroupId={id || ''} associatedMessageId={getMostRecentMessageId()} sectionInfo={getCurrentSectionInfo()} messageTimestamp={getMessageTimestamp()} onNoteSaved={handleNoteSaved} /> {/* Set Position Confirmation Dialog */} setSetPositionDialog(prev => ({ ...prev, isOpen: open }))}> Set Moderator Position Are you sure you want to set the moderator position to "{setPositionDialog.itemTitle}" in section "{setPositionDialog.sectionTitle}"? This will make the moderator ask this question in the chat. {/* Model Settings Dialog */} AI Model Settings Choose which AI model to use for generating responses, discussion guides, and thematic analysis in this focus group.
Current Model: {focusGroup?.llm_model === 'gpt-4.1' ? 'GPT-4.1' : focusGroup?.llm_model === 'gpt-5' ? 'GPT-5' : 'Gemini 3 Pro'}
{/* GPT-5 specific parameters - only show when GPT-5 is selected */} {selectedModel === "gpt-5" && ( <> {/* Reasoning Effort Parameter */}

Controls how much time GPT-5 spends thinking before responding

Controls how thoroughly GPT-5 thinks and how detailed responses are

{/* Verbosity Parameter */}

Controls how detailed and lengthy GPT-5's responses will be

Controls how thoroughly GPT-5 thinks and how detailed responses are

)}

Gemini 3 Pro: Google's advanced model, great for creative and analytical tasks.

GPT-4.1: OpenAI's latest model, excellent for conversational and reasoning tasks.

GPT-5: OpenAI's newest model with advanced reasoning and customizable response styles.

{/* Autonomous Dashboard */} setShowAutonomousDashboard(!showAutonomousDashboard)} />
); }; export default FocusGroupSession;