cohorta/src/pages/FocusGroupSession.tsx
Vadym Samoilenko 9d2f1f2c7d
Some checks failed
Deploy to Production / deploy (push) Failing after 0s
feat: complete AIMPRESS visual rebrand — warm palette, new landing, real dashboard
- Replace cyan/violet design tokens with warm dark slate + orange (#E89B3C) palette
- Add Space Grotesk display font; new utilities: .outline-display, .orange-band, .corner-card, .persona-orb
- New brand components: Logo (hexagonal SVG), Header (pill nav + glass blur), Footer (4-col), PublicLayout, AppLayout, UserDropdown
- Rewrite Index.tsx as full sales funnel: Hero → Stats → Orange band → How it works → Pricing (API) → FAQ → Final CTA
- Rewrite Dashboard.tsx with real API data: credits balance, MTD spend, personas count, focus groups count, active tasks, recent transactions
- Rewrite auth pages (Login, Register, VerifyEmail, NotFound, Billing) with two-column orange-panel layout
- Replace hardcoded mock numbers in Dashboard with billingApi / personasApi / focusGroupsApi / usageApi calls
- Delete legacy components: Navigation.tsx, Hero.tsx, FeatureCard.tsx
- Add nested layout routing in App.tsx: PublicLayout for guests, AppLayout for protected routes
- Color sweep inner pages: replace all purple-500/600 with primary token
- Purge all semblance / Oliver / optical-dev references; rename semblance_app_documentation.md → cohorta_app_documentation.md; update backend scripts to cohorta_db

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 19:44:02 +01:00

2376 lines
92 KiB
TypeScript
Executable file

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 { waitForTaskResult } from '@/lib/taskPolling';
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 { Progress } from '@/components/ui/progress';
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, adminApi } from '@/lib/api';
import { useQuery } from '@tanstack/react-query';
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, user } = useAuth();
const [messages, setMessages] = useState<Message[]>([]);
const [modeEvents, setModeEvents] = useState<ModeEvent[]>([]);
const [themes, setThemes] = useState<Theme[]>([]);
const [focusGroup, setFocusGroup] = useState<FocusGroup | null>(null);
const [participants, setParticipants] = useState<Persona[]>([]);
const [activeTab, setActiveTab] = useState('chat');
const [moderatorStatus, setModeratorStatus] = useState<any>(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<Note[]>([]);
// Model settings state
const [showModelSettings, setShowModelSettings] = useState(false);
const [selectedModel, setSelectedModel] = useState<string>('');
const [selectedReasoningEffort, setSelectedReasoningEffort] = useState<string>('medium');
const [selectedVerbosity, setSelectedVerbosity] = useState<string>('medium');
const [isUpdatingModel, setIsUpdatingModel] = useState(false);
const [isNoteModalOpen, setIsNoteModalOpen] = useState(false);
const [sessionStartTime, setSessionStartTime] = useState<Date | null>(null);
// Participant filtering state
const [selectedParticipantIds, setSelectedParticipantIds] = useState<string[]>([]);
// 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<string, any>;
isLoading?: boolean;
}>({ isOpen: false });
// Quota exceeded banner state
const [quotaExceeded, setQuotaExceeded] = useState<{ scope: string; limit_usd: number; used_usd: number } | null>(null);
const [quotaWarning, setQuotaWarning] = useState<{ scope: string; pct: number; limit_usd: number; used_usd: number } | null>(null);
// Admin-only: fetch focus group cost summary
const { data: fgCostData } = useQuery({
queryKey: ['admin', 'fg-cost', id],
queryFn: () => adminApi.usageSummary({ focus_group_id: id, group_by: 'focus_group' }).then(r => r.data),
staleTime: 60_000,
enabled: !!id && user?.role === 'admin',
});
const fgCostTotal = fgCostData?.totals?.total_cost ?? 0;
const fgTokensTotal = (fgCostData?.totals?.prompt_tokens ?? 0) + (fgCostData?.totals?.completion_tokens ?? 0);
// Track the last known AI status from API to avoid false positive changes
const lastAiStatusRef = useRef<boolean>(false);
// Track session status for ending detection
const [sessionStatus, setSessionStatus] = useState<string>('');
const lastSessionStatusRef = useRef<string>('');
const sessionEndingProcessedRef = useRef<boolean>(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<string | null>(null);
// Get token for WebSocket auth
const getAccessToken = useCallback(() => {
return token || '';
}, [token]);
// Listen for quota_exceeded events dispatched by the API interceptor
useEffect(() => {
const handler = (e: Event) => {
const detail = (e as CustomEvent).detail;
setQuotaExceeded(detail);
};
window.addEventListener('quota_exceeded', handler);
const warnHandler = (e: Event) => {
const detail = (e as CustomEvent).detail;
setQuotaWarning(detail);
};
window.addEventListener('quota_warning', warnHandler);
return () => {
window.removeEventListener('quota_exceeded', handler);
window.removeEventListener('quota_warning', warnHandler);
};
}, []);
// Initialize singleton socket (GPT-5 fix: avoid useMemo issues)
useEffect(() => {
if (useWebSocketEnabled) {
initSocket(getAccessToken);
}
}, [useWebSocketEnabled, getAccessToken]);
// GPT-5 FIX: Join room on mount and reconnect
useEffect(() => {
if (!useWebSocketEnabled || !id) return;
const tryJoin = () => {
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;
// Handle message updates
const onMessageUpdate = (e: CustomEvent) => {
const data = e.detail;
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) {
return prev;
}
return [...prev, newMessage];
});
};
// Handle AI status updates - GPT-5 fix: functional state updates
const onAiStatusUpdate = (e: CustomEvent) => {
const data = e.detail;
// 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;
setModeratorStatus(data.moderator_status);
};
// Handle theme updates
const onThemeUpdate = (e: CustomEvent) => {
const data = e.detail;
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;
setFocusGroup(prev => prev ? { ...prev, ...data } : null);
};
// Handle mode event updates
const onModeEventUpdate = (e: CustomEvent) => {
const data = e.detail;
// 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) {
return prev; // Don't add duplicate
}
return [...prev, modeEvent];
});
};
// Handle room join confirmations
const onJoinedFocusGroup = (_e: CustomEvent) => {
};
// Add 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);
// Cleanup window event listeners
return () => {
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]);
// Poll for new messages during AI mode (reliability fallback for cross-loop WebSocket emit issues)
useEffect(() => {
if (!isAiModeActive || !id) return;
const interval = window.setInterval(() => {
fetchMessages();
}, 3000);
return () => window.clearInterval(interval);
}, [isAiModeActive, 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);
// 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
}));
// 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;
// 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 || 'gpt-5.4'
};
setFocusGroup(focusGroupData);
setSelectedModel(focusGroupData.llm_model || 'gpt-5.4');
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)) {
// Otherwise use real personas from the database
const groupParticipants = allPersonas.filter(persona => {
const personaId = persona._id || persona.id;
return focusGroupData.participants.includes(personaId);
});
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) => {
if (!id || !focusGroup) {
return;
}
setIsUpdatingModel(true);
try {
const updateData: any = { llm_model: newModel };
// Include reasoning_effort/verbosity for models that support it
if (newModel === 'gpt-5.4' || newModel === 'gpt-5.4-mini') {
updateData.reasoning_effort = reasoningEffort || selectedReasoningEffort;
updateData.verbosity = verbosity || selectedVerbosity;
}
const response = await focusGroupsApi.update(id, updateData);
if (response && response.data) {
setFocusGroup(prev => prev ? {
...prev,
llm_model: newModel,
reasoning_effort: (newModel === 'gpt-5.4' || newModel === 'gpt-5.4-mini') ? (reasoningEffort || selectedReasoningEffort) : prev?.reasoning_effort,
verbosity: (newModel === 'gpt-5.4' || newModel === 'gpt-5.4-mini') ? (verbosity || selectedVerbosity) : prev?.verbosity
} : null);
toastService.success('AI Model Updated', {
description: `Focus group will now use ${
newModel === 'gpt-5.4' ? 'GPT-5.4' :
newModel === 'gpt-5.4-mini' ? 'GPT-5.4 Mini' : newModel
} for AI responses`
});
setShowModelSettings(false);
}
} 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(() => {
// 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;
// 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 || 'gpt-5.4'
};
setFocusGroup(focusGroupData);
setSelectedModel(focusGroupData.llm_model || 'gpt-5.4');
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)) {
// Otherwise use real personas from the database
const groupParticipants = allPersonas.filter(persona => {
const personaId = persona._id || persona.id;
return focusGroupData.participants.includes(personaId);
});
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) {
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
messageRefreshInterval = window.setInterval(() => {
// Skip polling when editing discussion guide to prevent focus loss
if (!isEditingDiscussionGuideRef.current) {
fetchMessages();
fetchModeratorStatus();
}
}, 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.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'
);
}
} 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'
});
} 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) {
return prevMessages;
}
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);
}
} 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) {
// 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) => {
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) {
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
};
}
}
} 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) => {
// 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) {
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<string, any>) => {
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 => {
// 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];
const matches = Array.from(content.matchAll(pattern));
if (matches.length > 0) {
// Get the first capture group from the first match
const filename = matches[0][1];
if (filename && filename.includes('.')) {
return filename;
}
}
}
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);
const taskId = response.data?.task_id;
if (taskId) themeGenerationControls.setTaskId(taskId);
if (response.status === 202 && taskId) {
if (themeGenerationControls.setTaskId) themeGenerationControls.setTaskId(taskId);
const taskResult = await waitForTaskResult(taskId);
if (taskResult.status === 'completed') {
const themes = taskResult.result?.themes;
if (themes && themes.length > 0) {
setThemes(prevThemes => [...prevThemes, ...themes]);
setTimeout(() => {
themeGenerationControls.completeGeneration();
toastService.success(`Generated ${themes.length} key themes`, {
description: "New themes have been added to the analysis."
});
}, 3000);
} else {
setTimeout(() => {
themeGenerationControls.completeGeneration();
toastService.warning("No new themes were generated", {
description: "Try again when the discussion has more content."
});
}, 3000);
}
} else if (taskResult.status === 'failed') {
themeGenerationControls.failGeneration(taskResult.error || 'Failed');
toastService.error("Failed to generate key themes", {
description: taskResult.error || "There was an error analyzing the discussion."
});
}
// cancelled: silently stop
return;
} else if (response.data && response.data.themes) {
// Fallback: sync response
setThemes(prevThemes => [...prevThemes, ...response.data.themes]);
setTimeout(() => {
themeGenerationControls.completeGeneration();
toastService.success(`Generated ${response.data.themes.length} key themes`, {
description: "New themes have been added to the analysis."
});
}, 3000);
} else {
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 (
<div className="min-h-screen bg-slate-50 pt-20 pb-16 px-4">
<div className="max-w-7xl mx-auto text-center py-12">
<div className="flex justify-center items-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
<p className="mt-4 text-slate-600">Loading focus group...</p>
</div>
</div>
);
}
// Show error state if not found after loading
if (!focusGroup) {
return (
<div className="min-h-screen bg-slate-50 pt-20 pb-16 px-4">
<div className="max-w-7xl mx-auto text-center py-12">
<h1 className="text-2xl font-bold">Focus group not found</h1>
<p className="mt-2 text-slate-600">We couldn't find the focus group you're looking for.</p>
<Button
onClick={() => navigate('/focus-groups')}
className="mt-4"
>
<ArrowLeft className="mr-2 h-4 w-4" /> Back to Focus Groups
</Button>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-slate-50">
{/* WebSocket Connection Status Bar */}
{useWebSocketEnabled && isStatusBarVisible && (
<div className={`w-full transition-all duration-300 ${
wsConnected
? 'bg-green-500'
: wsConnecting
? 'bg-yellow-500'
: 'bg-red-500'
}`}>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between py-2 text-white text-sm font-medium">
<div className="flex items-center space-x-2">
<div className={`w-2 h-2 rounded-full ${
wsConnected
? 'bg-white animate-pulse'
: wsConnecting
? 'bg-white animate-spin'
: 'bg-white'
}`} />
<span>
{wsConnected
? 'Real-time updates active - Changes appear instantly'
: wsConnecting
? 'Connecting to real-time updates...'
: 'Real-time updates unavailable - Using periodic refresh'}
</span>
{wsError && (
<span className="text-xs opacity-75 ml-2" title={wsError}>
(Connection error)
</span>
)}
</div>
<button
onClick={() => setIsStatusBarVisible(false)}
className="ml-4 text-white hover:text-gray-200 transition-colors"
title="Hide status bar"
aria-label="Hide connection status"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
)}
{/* Status Bar Toggle Button (when hidden) */}
{useWebSocketEnabled && !isStatusBarVisible && (
<div className="fixed top-20 right-4 z-40">
<button
onClick={() => setIsStatusBarVisible(true)}
className={`px-3 py-1 rounded-full text-white text-xs font-medium shadow-lg transition-all duration-200 hover:shadow-xl ${
wsConnected
? 'bg-green-500 hover:bg-green-600'
: wsConnecting
? 'bg-yellow-500 hover:bg-yellow-600'
: 'bg-red-500 hover:bg-red-600'
}`}
title={
wsConnected
? 'WebSocket connected - Show status bar'
: wsConnecting
? 'WebSocket connecting - Show status bar'
: 'WebSocket disconnected - Show status bar'
}
>
<div className="flex items-center space-x-1">
<div className={`w-2 h-2 rounded-full bg-white ${wsConnected ? 'animate-pulse' : ''}`} />
<span>{wsConnected ? 'Live' : wsConnecting ? 'Connecting' : 'Offline'}</span>
</div>
</button>
</div>
)}
<main className="pt-20 pb-16 px-4 sm:px-6 lg:px-8 max-w-7xl mx-auto">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-4">
<div className="flex items-center">
<Button
variant="ghost"
onClick={() => navigate('/focus-groups')}
className="mr-2"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="font-sf text-2xl font-bold text-slate-900">{focusGroup.name}</h1>
<p className="text-slate-600">{new Date(focusGroup.date).toLocaleString()}</p>
<div className="flex items-center mt-1">
<Bot className="h-3 w-3 text-slate-500 mr-1" />
<Badge variant="secondary" className="text-xs">
{focusGroup.llm_model === 'gpt-5.4-mini' ? 'GPT-5.4 Mini' : 'GPT-5.4'}
</Badge>
</div>
{user?.role === 'admin' && fgCostTotal > 0 && (
<div className="flex items-center gap-1 mt-1">
<Badge variant="outline" className="text-xs font-mono">
${fgCostTotal.toFixed(4)} &bull; {(fgTokensTotal / 1000).toFixed(1)}k tok
</Badge>
</div>
)}
</div>
</div>
<div className="flex items-center space-x-4 mt-4 sm:mt-0">
<Button
variant="outline"
onClick={() => setIsDiscussionGuideOpen(true)}
className={isDiscussionGuideOpen ? 'bg-amber-50 text-amber-700 border-amber-200' : ''}
>
<ClipboardList className="mr-2 h-4 w-4" />
{isDiscussionGuideOpen ? 'Guide Open' : 'Edit Discussion Guide'}
</Button>
<Button
variant="outline"
onClick={() => setShowAutonomousDashboard(!showAutonomousDashboard)}
className={showAutonomousDashboard ? 'bg-blue-50 text-blue-600' : ''}
>
<BarChart className="mr-2 h-4 w-4" />
AI Dashboard
</Button>
<Button
variant="outline"
onClick={() => setShowModelSettings(true)}
>
<Settings className="mr-2 h-4 w-4" />
AI Model
</Button>
<Button variant="outline" onClick={downloadTranscript}>
<Download className="mr-2 h-4 w-4" />
Download Transcript
</Button>
</div>
</div>
{/* Quota warning banner */}
{quotaWarning && (
<div className="mx-4 mt-2 p-3 bg-amber-50 border border-amber-200 rounded-md flex items-center justify-between">
<div className="flex items-center gap-3 flex-1">
<span className="text-sm text-amber-700">
Usage at {Math.round(quotaWarning.pct * 100)}% of {quotaWarning.scope} quota
(${quotaWarning.used_usd.toFixed(4)} of ${quotaWarning.limit_usd.toFixed(2)})
</span>
<Progress value={quotaWarning.pct * 100} className="w-24 h-2" />
</div>
<button className="text-xs text-amber-500 hover:text-amber-700 ml-2" onClick={() => setQuotaWarning(null)}>&#x2715;</button>
</div>
)}
{/* Quota exceeded banner */}
{quotaExceeded && (
<div className="mx-0 mt-2 mb-2 p-3 bg-red-50 border border-red-200 rounded-md flex items-center justify-between">
<span className="text-sm text-red-700">
Quota exceeded ({quotaExceeded.scope}): ${quotaExceeded.used_usd.toFixed(4)} of ${quotaExceeded.limit_usd.toFixed(2)} used.
</span>
<button className="text-xs text-red-500 hover:text-red-700" onClick={() => setQuotaExceeded(null)}>&#x2715;</button>
</div>
)}
{/* Progress Modal for Key Themes Generation */}
<ProgressModal
isOpen={isThemeProgressModalOpen}
onClose={() => 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 */}
<CollapsibleDiscussionGuide
discussionGuide={focusGroup.discussionGuide}
moderatorStatus={moderatorStatus}
onSectionSelect={handleSectionSelect}
onSetPosition={handleSetPosition}
onSave={handleDiscussionGuideSave}
focusGroupId={id || ''}
isOpen={isDiscussionGuideOpen}
onToggle={handleToggleDiscussionGuide}
onEditingChange={handleGuideEditingStateChange}
/>
<div className="flex flex-col lg:flex-row gap-6 h-[calc(100vh-12rem)]">
<ParticipantPanel
participants={participants}
selectedParticipantIds={selectedParticipantIds}
onToggleParticipantFilter={toggleParticipantFilter}
/>
<div className="flex-1 flex flex-col">
<Tabs defaultValue="chat" value={activeTab} onValueChange={setActiveTab} className="w-full h-full flex flex-col">
<TabsList className="grid grid-cols-4 mb-4">
<TabsTrigger value="chat" className="flex items-center">
<MessageCircle className="h-4 w-4 mr-2" />
Discussion
</TabsTrigger>
<TabsTrigger value="themes" className="flex items-center">
<Lightbulb className="h-4 w-4 mr-2" />
Key Themes
</TabsTrigger>
<TabsTrigger value="notes" className="flex items-center">
<StickyNote className="h-4 w-4 mr-2" />
Notes
</TabsTrigger>
<TabsTrigger value="analytics" className="flex items-center">
<BarChart className="h-4 w-4 mr-2" />
Analytics
</TabsTrigger>
</TabsList>
<TabsContent value="chat" className="m-0 flex-1 flex flex-col h-0">
{messages.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 space-y-4">
<p className="text-lg text-slate-600">No messages yet. Start the session to begin the discussion.</p>
<Button
onClick={startSession}
size="lg"
className="flex items-center gap-2"
>
<PlayCircle className="h-5 w-5" />
Start Session
</Button>
</div>
) : (
<DiscussionPanel
messages={messages}
modeEvents={modeEvents}
personas={participants}
isSpeaking={false}
focusGroupId={id || ''}
isAiModeActive={isAiModeActive}
selectedParticipantIds={selectedParticipantIds}
onToggleHighlight={toggleHighlight}
onAdvanceDiscussion={() => null}
onNewMessage={handleNewMessage}
onStatusChange={reloadFocusGroup}
isEditingDiscussionGuide={isEditingDiscussionGuide}
/>
)}
</TabsContent>
<TabsContent value="themes" className="m-0">
<ThemesPanel
themes={themes}
messages={messages}
personas={participants}
focusGroupId={id || ''}
onThemesGenerated={handleThemesGenerated}
onThemeDelete={handleThemeDelete}
onQuoteClick={handleQuoteClick}
onGenerateKeyThemes={generateKeyThemes}
/>
</TabsContent>
<TabsContent value="notes" className="m-0" style={{ height: 'calc(100% - 3.5rem)' }}>
<div className="h-full">
<NotesPanel
focusGroupId={id || ''}
focusGroupName={focusGroup?.name}
onNoteClick={handleNoteClick}
/>
</div>
</TabsContent>
<TabsContent value="analytics" className="m-0">
<AnalyticsPanel
messages={messages}
themes={themes}
personas={participants}
/>
</TabsContent>
</Tabs>
</div>
</div>
</main>
{/* Floating Note Button */}
{messages.length > 0 && (
<div className="fixed bottom-6 right-6 z-40">
<Button
onClick={handleOpenNoteModal}
className="rounded-full h-12 w-12 p-0 shadow-lg"
title="Take a quick note"
>
<StickyNote className="h-5 w-5" />
</Button>
</div>
)}
{/* Quick Note Modal */}
<QuickNoteModal
isOpen={isNoteModalOpen}
onClose={() => setIsNoteModalOpen(false)}
focusGroupId={id || ''}
associatedMessageId={getMostRecentMessageId()}
sectionInfo={getCurrentSectionInfo()}
messageTimestamp={getMessageTimestamp()}
onNoteSaved={handleNoteSaved}
/>
{/* Set Position Confirmation Dialog */}
<Dialog open={setPositionDialog.isOpen} onOpenChange={(open) => setSetPositionDialog(prev => ({ ...prev, isOpen: open }))}>
<DialogContent>
<DialogHeader>
<DialogTitle>Set Moderator Position</DialogTitle>
<DialogDescription>
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.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
disabled={setPositionDialog.isLoading}
onClick={() => setSetPositionDialog({ isOpen: false })}
>
Cancel
</Button>
<Button
disabled={setPositionDialog.isLoading}
onClick={async () => {
if (!id || !setPositionDialog.sectionId || !setPositionDialog.itemId || !setPositionDialog.content) return;
// Set loading state
setSetPositionDialog(prev => ({ ...prev, isLoading: true }));
try {
// Set moderator position
await focusGroupAiApi.setModeratorPosition(id, setPositionDialog.sectionId, setPositionDialog.itemId);
// Detect if this item has an image attached (regardless of item type)
let attachedAssets: string[] = [];
let activatesVisualContext = false;
let enhancedMessageText = setPositionDialog.content; // Start with original text
// Check for visual asset in metadata instead of parsing content
const visualAsset = setPositionDialog.metadata?.visual_asset;
const hasImageAttached = !!visualAsset?.filename;
const assetFilename = visualAsset?.filename;
if (hasImageAttached && setPositionDialog.content && assetFilename) {
if (assetFilename) {
attachedAssets = [assetFilename];
activatesVisualContext = true;
// Generate AI description and enhance the question
try {
const descriptionResponse = await (async () => {
const res = await focusGroupsApi.describeAsset(id, assetFilename);
if (res.status === 202 && res.data?.task_id) {
const taskId = res.data.task_id;
const taskResult = await waitForTaskResult(taskId);
if (taskResult.status === 'completed') {
return { ...res, data: { ...res.data, description: taskResult.result?.description } };
} else if (taskResult.status === 'failed') {
throw new Error(taskResult.error || 'Description failed');
} else {
return { ...res, data: { ...res.data, description: null } }; // cancelled
}
}
return res;
})();
if (descriptionResponse.data.description) {
// Enhance the question text with the AI description using display reference
const displayRef = visualAsset?.display_reference || 'the asset';
enhancedMessageText = setPositionDialog.content.replace(
displayRef,
`${displayRef} - ${descriptionResponse.data.description}`
);
}
} catch (descError) {
console.error('⚠️ MANUAL MODE: Failed to generate AI description:', descError);
console.error('⚠️ Error response data:', descError.response?.data);
console.error('⚠️ Error status:', descError.response?.status);
console.error('⚠️ Error headers:', descError.response?.headers);
console.error('⚠️ Full axios error:', {
message: descError.message,
code: descError.code,
status: descError.response?.status,
statusText: descError.response?.statusText,
url: descError.config?.url,
method: descError.config?.method
});
// Show user notification about fallback
toastService.warning('AI description failed', {
description: 'Using original question text. Image will still be available to participants.'
});
}
}
}
// Send the moderator message to API - it will be added back via websocket/polling with server timestamp
try {
const msgResponse = await focusGroupsApi.sendMessage(id, {
senderId: 'moderator',
text: enhancedMessageText,
type: 'question',
attached_assets: attachedAssets,
activates_visual_context: activatesVisualContext,
visualAsset: hasImageAttached && visualAsset ? {
filename: assetFilename,
displayReference: visualAsset.display_reference
} : undefined
});
} catch (msgError) {
console.error('❌ Failed to save message to API:', msgError);
toastService.warning('Message not saved', {
description: 'Failed to save the moderator message to the server.'
});
}
// Close dialog first for immediate feedback
setSetPositionDialog({ isOpen: false });
toastService.success('Moderator position set', {
description: `Position set to "${setPositionDialog.itemTitle}" in "${setPositionDialog.sectionTitle}"`
});
} catch (error) {
console.error('Error setting moderator position:', error);
setSetPositionDialog(prev => ({ ...prev, isLoading: false }));
toastService.error('Failed to set moderator position', {
description: 'There was an error setting the moderator position.'
});
}
}}
className="flex items-center gap-2"
>
{setPositionDialog.isLoading && (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
)}
{setPositionDialog.isLoading ? 'Generating detailed image description...' : 'Confirm'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Model Settings Dialog */}
<Dialog open={showModelSettings} onOpenChange={setShowModelSettings}>
<DialogContent>
<DialogHeader>
<DialogTitle>AI Model Settings</DialogTitle>
<DialogDescription>
Choose which AI model to use for generating responses, discussion guides, and thematic analysis in this focus group.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="flex items-center space-x-2">
<Bot className="h-4 w-4 text-slate-500" />
<span className="text-sm font-medium">Current Model:</span>
<Badge variant="secondary">
{focusGroup?.llm_model === 'gpt-5.4-mini' ? 'GPT-5.4 Mini' : 'GPT-5.4'}
</Badge>
</div>
<div>
<label className="text-sm font-medium">Select AI Model:</label>
<Select value={selectedModel} onValueChange={(value) => {
setSelectedModel(value);
}}>
<SelectTrigger className="mt-1">
<SelectValue placeholder="Select AI model" />
</SelectTrigger>
<SelectContent>
<SelectItem value="gpt-5.4">GPT-5.4 (Recommended)</SelectItem>
<SelectItem value="gpt-5.4-mini">GPT-5.4 Mini (Faster, lower cost)</SelectItem>
</SelectContent>
</Select>
</div>
{/* Reasoning/verbosity parameters for gpt-5.4 models */}
{(selectedModel === "gpt-5.4" || selectedModel === "gpt-5.4-mini") && (
<>
{/* Reasoning Effort Parameter */}
<div>
<label className="text-sm font-medium">Reasoning Effort:</label>
<Select value={selectedReasoningEffort} onValueChange={setSelectedReasoningEffort}>
<SelectTrigger className="mt-1">
<SelectValue placeholder="Select reasoning effort" />
</SelectTrigger>
<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>
<p className="text-xs text-slate-600 mt-1">
Controls how much time GPT-5.4 spends thinking before responding
</p>
<p className="text-xs text-amber-600 font-medium mt-1">
Controls how thoroughly GPT-5.4 thinks and how detailed responses are
</p>
</div>
{/* Verbosity Parameter */}
<div>
<label className="text-sm font-medium">Response Verbosity:</label>
<Select value={selectedVerbosity} onValueChange={setSelectedVerbosity}>
<SelectTrigger className="mt-1">
<SelectValue placeholder="Select verbosity level" />
</SelectTrigger>
<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>
<p className="text-xs text-slate-600 mt-1">
Controls how detailed and lengthy GPT-5.4's responses will be
</p>
<p className="text-xs text-amber-600 font-medium mt-1">
Controls how thoroughly GPT-5.4 thinks and how detailed responses are
</p>
</div>
</>
)}
<div className="text-xs text-slate-600">
<p><strong>GPT-5.4:</strong> Recommended model. Best quality for complex analysis and persona responses.</p>
<p><strong>GPT-5.4 Mini:</strong> Faster and lower cost. Great for most tasks with good quality.</p>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowModelSettings(false)}
disabled={isUpdatingModel}
>
Cancel
</Button>
<Button
onClick={() => {
updateFocusGroupModel(selectedModel, selectedReasoningEffort, selectedVerbosity);
}}
disabled={isUpdatingModel || (selectedModel === focusGroup?.llm_model &&
(!(selectedModel === 'gpt-5.4' || selectedModel === 'gpt-5.4-mini') ||
(selectedReasoningEffort === (focusGroup?.reasoning_effort || 'medium') &&
selectedVerbosity === (focusGroup?.verbosity || 'medium'))))}
>
{isUpdatingModel && (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
)}
{isUpdatingModel ? 'Updating...' : 'Update Model'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Autonomous Dashboard */}
<AutonomousDashboard
focusGroupId={id!}
personas={participants}
isVisible={showAutonomousDashboard}
onToggle={() => setShowAutonomousDashboard(!showAutonomousDashboard)}
/>
</div>
);
};
export default FocusGroupSession;