Some checks failed
Deploy to Production / deploy (push) Failing after 0s
- 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>
2376 lines
92 KiB
TypeScript
Executable file
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)} • {(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)}>✕</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)}>✕</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;
|