import React, { useState, useEffect, useLayoutEffect, useRef, useMemo, useCallback } from 'react'; import { MessageCircle, Reply, X, FileText, HelpCircle, BookmarkPlus, ClipboardPaste } from 'lucide-react'; import EnhancedChatInput from '../components/EnhancedChatInput'; import ThinkingIndicator from '../components/ThinkingIndicator'; import SuggestionsPanel from '../components/SuggestionsPanel'; import AppHeader from '../components/AppHeader'; import AdvisorStatusDropdown from '../components/AdvisorStatusDropdown'; import Sidebar from '../components/Sidebar'; import AdvisorCarousel from '../components/AdvisorCarousel'; import ClipboardPreviewModal from '../components/ClipboardPreviewModal'; import { useAppConfig } from '../contexts/AppConfigContext'; import { useTheme } from '../contexts/ThemeContext'; import '../styles/ChatPage.css'; import '../styles/EnhancedChatInput.css'; import { applyAdvisorStreamEvent } from '../utils/advisorStreamOrder'; import { pinElementToScrollerTop, findLatestUserMessage } from '../utils/pinLatestQuestion'; import { formatReferenceSnippet, wrapReferencesForAdvisorContext, readClipboardText, } from '../utils/referenceSearch'; import OnboardingChat from '../components/OnboardingChat'; import AboutYouModal from '../components/AboutYouModal'; import ClearDataModal from '../components/ClearDataModal'; import SettingsModal from '../components/SettingsModal'; import IntakePanel from '../components/IntakePanel'; import useChatInputFollowupsVisible from '../hooks/useChatInputFollowupsVisible'; import useStatedGoal from '../hooks/useStatedGoal'; import { dropPrefetchedStream, getPrefetchedStream, MAX_PREFETCH_PROMPTS, putPrefetchedStream, readNdjsonLines, } from '../utils/starterPrefetchCache'; import { loadPathProgress, loadVisits, markStepComplete, recordVisit, savePathProgress, } from '../utils/advisorPathProgress'; const ACTIVE_ADVISORS_STORAGE_KEY = 'cybersecurityActiveAdvisorIds'; /** Persona-matched starter prompts used when a guest lands on a seeded demo chat. */ function guestStarterPrompts(config, guestPersona) { const intake = config?.chat_page?.intake || {}; const byPersona = intake.by_persona || {}; const key = (guestPersona || '').toLowerCase(); const chips = (key && Array.isArray(byPersona[key]) ? byPersona[key] : null) || (Array.isArray(intake.chips) ? intake.chips : []); return chips .filter((c) => c && c.prompt && !c.free_text) .map((c) => c.prompt) .slice(0, 4); } const ChatPage = ({ user, authToken, onNavigateToHome, onNavigateToCanvas, onNavigateToJourney, onNavigateToSignup, onSignOut, chatNavView = null, chatStarterNonce = 0, onChatBecameActive, onChatReturnedToStarter, }) => { const { config, advisors, getAdvisorColors } = useAppConfig(); const [messages, setMessages] = useState([]); const [isLoading, setIsLoading] = useState(false); const [thinkingAdvisors, setThinkingAdvisors] = useState([]); const [followupChips, setFollowupChips] = useState([]); const [activeAdvisorIds, setActiveAdvisorIds] = useState([]); const [replyingTo, setReplyingTo] = useState(null); const [uploadedDocuments, setUploadedDocuments] = useState([]); const messagesEndRef = useRef(null); const messagesScrollRef = useRef(null); const latestQuestionRef = useRef(null); const pinQuestionAfterRenderRef = useRef(false); const scrollSessionToEndRef = useRef(false); const pendingReferenceContextRef = useRef(''); const rankedAdvisorIdsRef = useRef([]); const { isDark } = useTheme(); const { followupsVisible, setFollowupsVisible, } = useChatInputFollowupsVisible(); const guestPersona = user?.is_guest ? (user?.guest_persona || null) : null; const [currentSessionId, setCurrentSessionId] = useState(null); const [currentSessionTitle, setCurrentSessionTitle] = useState(''); const [isLoadingSession, setIsLoadingSession] = useState(false); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const [sidebarRefreshTrigger, setSidebarRefreshTrigger] = useState(0); const [userAvatarId, setUserAvatarId] = useState( () => localStorage.getItem('userAvatarId') || (user?.avatarId ?? null) ); const handleAvatarChange = async (id) => { setUserAvatarId(id); localStorage.setItem('userAvatarId', id); try { await fetch(`${process.env.REACT_APP_API_URL}/auth/me`, { method: 'PATCH', headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ avatarId: id }), }); } catch (e) { console.error('Failed to save avatar:', e); } }; const [showOnboarding, setShowOnboarding] = useState(false); const [showAboutYou, setShowAboutYou] = useState(false); const [aboutYouInitialTab, setAboutYouInitialTab] = useState('about'); const [showClearData, setShowClearData] = useState(false); const [showSettings, setShowSettings] = useState(false); const [settingsInitialTab, setSettingsInitialTab] = useState('profile'); const [userProfile, setUserProfile] = useState(null); const [searchIncorporate, setSearchIncorporate] = useState(null); const [clipboardModal, setClipboardModal] = useState({ open: false, text: '', error: '' }); const [composeDraft, setComposeDraft] = useState(''); const statedGoal = useStatedGoal(authToken, user); const parkedConversationRef = useRef(null); const prefetchQueueRef = useRef([]); const prefetchBusyRef = useRef(false); const prefetchAbortRef = useRef(null); const prefetchUserKey = user?.id || user?._id || user?.email || (user?.is_guest ? 'guest' : 'user'); const pathUserKey = prefetchUserKey; const pathSignals = useMemo(() => { const visits = loadVisits(pathUserKey); const profileFields = ['cyber_role', 'organization_type', 'current_goals', 'knowledge_level', 'compliance_focus']; const hasProfileFacts = Boolean( userProfile && profileFields.some((field) => { const val = userProfile[field]; return Array.isArray(val) ? val.length > 0 : Boolean(String(val || '').trim()); }), ); return { hasChats: messages.some((m) => m.type === 'user' && !m.isReferenceSnippet), hasProfileFacts, hasStatedGoal: Boolean((statedGoal || '').trim()), visitedJourney: Boolean(visits.journey), visitedWorkspace: Boolean(visits.workspace), }; }, [messages, pathUserKey, statedGoal, userProfile]); const markPathStep = useCallback((stepId) => { const prev = loadPathProgress(pathUserKey); const completed = markStepComplete(prev.completed, stepId); if (completed.length === prev.completed.length) return; savePathProgress(pathUserKey, { ...prev, completed }); }, [pathUserKey]); const handleNavigateToJourney = useCallback(() => { recordVisit(pathUserKey, 'journey'); onNavigateToJourney?.(); }, [onNavigateToJourney, pathUserKey]); const handleNavigateToCanvas = useCallback(() => { recordVisit(pathUserKey, 'workspace'); onNavigateToCanvas?.(); }, [onNavigateToCanvas, pathUserKey]); const loadProfile = async () => { try { const resp = await fetch(`${process.env.REACT_APP_API_URL}/api/users/me/profile`, { headers: { Authorization: `Bearer ${authToken}` }, }); if (resp.ok) setUserProfile(await resp.json()); } catch (e) { /* ignore */ } }; useEffect(() => { if (authToken) loadProfile(); }, [authToken]); useEffect(() => { if (!chatStarterNonce) return; if (!messages.length) return; parkedConversationRef.current = { messages, currentSessionId, currentSessionTitle, followupChips, }; setMessages([]); setCurrentSessionId(null); setCurrentSessionTitle(''); setFollowupChips([]); setThinkingAdvisors([]); setReplyingTo(null); setIsLoading(false); // Intentionally keyed on nonce only: Back from an active chat parks the thread. // eslint-disable-next-line react-hooks/exhaustive-deps }, [chatStarterNonce]); useEffect(() => { if (chatNavView !== 'active') return; const parked = parkedConversationRef.current; if (!parked?.messages?.length) return; setMessages(parked.messages); setCurrentSessionId(parked.currentSessionId); setCurrentSessionTitle(parked.currentSessionTitle); setFollowupChips(parked.followupChips || []); parkedConversationRef.current = null; }, [chatNavView]); const runPrefetchQueue = useCallback(async () => { if (prefetchBusyRef.current || !authToken) return; prefetchBusyRef.current = true; const advisorsForRequest = activeAdvisorIds.length > 0 ? activeAdvisorIds : Object.keys(advisors || {}); while (prefetchQueueRef.current.length) { const prompt = prefetchQueueRef.current.shift(); if (!prompt || getPrefetchedStream(prefetchUserKey, prompt)) continue; const controller = new AbortController(); prefetchAbortRef.current = controller; try { const response = await fetch(`${process.env.REACT_APP_API_URL}/chat-stream`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authToken}`, }, body: JSON.stringify({ user_input: prompt, response_length: 'medium', prefetch: true, active_advisors: advisorsForRequest, }), signal: controller.signal, }); if (!response.ok) continue; const lines = await readNdjsonLines(response); if (lines.length) putPrefetchedStream(prefetchUserKey, prompt, lines); } catch (err) { if (err?.name === 'AbortError') break; } } prefetchBusyRef.current = false; prefetchAbortRef.current = null; }, [authToken, activeAdvisorIds, advisors, prefetchUserKey]); const handleSuggestionsReady = useCallback((prompts) => { const next = (prompts || []) .map((p) => String(p || '').trim()) .filter(Boolean) .slice(0, MAX_PREFETCH_PROMPTS) .filter((p) => !getPrefetchedStream(prefetchUserKey, p)); if (next.length === 0) return; prefetchQueueRef.current = next; runPrefetchQueue(); }, [prefetchUserKey, runPrefetchQueue]); useEffect(() => { const allIds = Object.keys(advisors || {}); if (allIds.length === 0) return; setActiveAdvisorIds((prev) => { let next = prev.filter((id) => allIds.includes(id)); if (next.length === 0) { try { const stored = JSON.parse(localStorage.getItem(ACTIVE_ADVISORS_STORAGE_KEY) || 'null'); if (Array.isArray(stored)) { const valid = stored.filter((id) => allIds.includes(id)); if (valid.length > 0) next = valid; } } catch { /* ignore */ } } if (next.length === 0) next = [...allIds]; if ( prev.length === next.length && prev.every((id, index) => id === next[index]) ) { return prev; } return next; }); }, [advisors]); const persistActiveAdvisorIds = (ids) => { localStorage.setItem(ACTIVE_ADVISORS_STORAGE_KEY, JSON.stringify(ids)); }; const handleSetActiveAdvisors = (ids) => { setActiveAdvisorIds(ids); persistActiveAdvisorIds(ids); }; const handleToggleAdvisor = (id) => { setActiveAdvisorIds((prev) => { const set = new Set(prev); if (set.has(id)) { if (set.size <= 1) return prev; set.delete(id); } else { set.add(id); } const next = Array.from(set); persistActiveAdvisorIds(next); return next; }); }; const handleMobileMenuToggle = () => { setIsMobileMenuOpen(!isMobileMenuOpen); }; const consumePendingReferences = (inputMessage) => { const pending = pendingReferenceContextRef.current; if (!pending) return inputMessage; pendingReferenceContextRef.current = ''; return `${pending}\n\n${inputMessage}`; }; const handleComposeDraftApplied = useCallback(() => setComposeDraft(''), []); const handleReferenceSearchOpened = useCallback(({ source } = {}) => { const label = source === 'perplexity' ? 'Perplexity' : 'web search'; setSearchIncorporate({ source: label }); }, []); const handlePasteFromClipboard = async () => { try { const { text } = await readClipboardText(); const trimmed = (text || '').trim(); setClipboardModal({ open: true, text: trimmed ? formatReferenceSnippet(trimmed) : '', error: trimmed ? '' : 'Clipboard was empty — paste the text you want to add.', }); } catch (err) { setClipboardModal({ open: true, text: '', error: err.code === 'denied' ? 'Clipboard access was blocked. Paste the text into the box below.' : 'Could not read the clipboard. Paste the text into the box below.', }); } }; const handleApproveClipboard = (draft) => { const snippet = (draft || '').trim(); setClipboardModal({ open: false, text: '', error: '' }); setSearchIncorporate(null); if (!snippet) return; const stored = snippet.startsWith('Here are references I found:') ? snippet : formatReferenceSnippet(snippet); pendingReferenceContextRef.current = wrapReferencesForAdvisorContext(stored); const card = { id: generateMessageId(), type: 'user', content: stored, timestamp: new Date(), isReferenceSnippet: true, }; setMessages((prev) => [...prev, card]); saveMessageToSession(card).catch((err) => console.error('Failed to persist reference snippet:', err) ); setComposeDraft('Please use the references I just added when you answer.'); }; useLayoutEffect(() => { if (pinQuestionAfterRenderRef.current) { pinQuestionAfterRenderRef.current = false; const pin = () => pinElementToScrollerTop( messagesScrollRef.current, latestQuestionRef.current, { behavior: 'auto' } ); pin(); requestAnimationFrame(pin); return; } if (scrollSessionToEndRef.current) { scrollSessionToEndRef.current = false; messagesEndRef.current?.scrollIntoView({ behavior: 'auto' }); } }, [messages]); const generateMessageId = () => { return Date.now().toString() + Math.random().toString(36).substr(2, 9); }; const createNewSession = async (firstMessage = null) => { try { const title = firstMessage ? `${firstMessage.substring(0, 30)}...` : `Chat ${new Date().toLocaleDateString()}`; const response = await fetch(`${process.env.REACT_APP_API_URL}/api/chat-sessions`, { method: 'POST', headers: { 'Authorization': `Bearer ${authToken}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ title }) }); if (response.ok) { const newSession = await response.json(); // Update state immediately setCurrentSessionId(newSession.id); setCurrentSessionTitle(newSession.title); console.log('MongoDB session created:', newSession.id); return newSession.id; } console.error('Failed to create new session', response.status); if (response.status === 401 && typeof onSignOut === 'function') { onSignOut(); } return null; } catch (error) { console.error('Error creating new session:', error); return null; } }; // Load an existing chat session const loadChatSession = async (sessionId) => { if (!sessionId || isLoadingSession) return; setIsLoadingSession(true); try { // Use the new switch-chat endpoint that syncs context const response = await fetch(`${process.env.REACT_APP_API_URL}/switch-chat`, { method: 'POST', headers: { 'Authorization': `Bearer ${authToken}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ chat_session_id: sessionId }) }); if (response.ok) { const result = await response.json(); if (result.status === 'success') { setCurrentSessionId(sessionId); setCurrentSessionTitle(''); // Will be set from MongoDB data setFollowupChips([]); // Load the messages from the synced context const formattedMessages = result.context.messages.map(msg => ({ ...msg, timestamp: new Date(msg.timestamp), persona_id: msg.persona_id || msg.advisor || msg.advisorId })); setMessages(formattedMessages); setReplyingTo(null); setThinkingAdvisors([]); pendingReferenceContextRef.current = ''; scrollSessionToEndRef.current = true; // Guest demo: seed persona-aligned starter prompts so suggestions match intake type if (user?.is_guest && formattedMessages.length > 0) { const starters = guestStarterPrompts(config, user.guest_persona); if (starters.length > 0) setFollowupChips(starters); } // Also get the session title from MongoDB const sessionResponse = await fetch(`${process.env.REACT_APP_API_URL}/api/chat-sessions/${sessionId}`, { headers: { 'Authorization': `Bearer ${authToken}`, 'Content-Type': 'application/json' } }); if (sessionResponse.ok) { const sessionData = await sessionResponse.json(); setCurrentSessionTitle(sessionData.title); } } } } catch (error) { console.error('Error loading session:', error); } finally { setIsLoadingSession(false); } }; // Save a message to the current session (optional sessionId when state is not updated yet) const saveMessageToSession = async (message, sessionIdOverride) => { const sid = sessionIdOverride || currentSessionId; if (!sid || !authToken) return; try { await fetch(`${process.env.REACT_APP_API_URL}/api/chat-sessions/${sid}/messages`, { method: 'POST', headers: { 'Authorization': `Bearer ${authToken}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: sid, message: { ...message, timestamp: message.timestamp.toISOString() } }) }); } catch (error) { console.error('Error saving message to session:', error); } }; // Update session title based on first message const updateSessionTitle = async (sessionId, newTitle) => { if (!sessionId || !authToken) return; try { await fetch(`${process.env.REACT_APP_API_URL}/api/chat-sessions/${sessionId}`, { method: 'PUT', headers: { 'Authorization': `Bearer ${authToken}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ title: newTitle }) }); setCurrentSessionTitle(newTitle); } catch (error) { console.error('Error updating session title:', error); } }; // Handle selecting a session from sidebar const handleSelectSession = async (sessionId) => { if (sessionId === currentSessionId) return; parkedConversationRef.current = null; await loadChatSession(sessionId); onChatBecameActive?.(sessionId); }; // Sidebar deleted the currently-active chat. Clear local state without // creating a replacement session. const handleCurrentSessionDeleted = () => { setCurrentSessionId(null); setCurrentSessionTitle(''); setMessages([]); setReplyingTo(null); setThinkingAdvisors([]); setFollowupChips([]); setUploadedDocuments([]); }; // Handle creating new chat from sidebar const handleNewChat = async (sessionId = null) => { if (sessionId) { // Loading existing session await loadChatSession(sessionId); return; // Return early for existing session loading } else { // Creating completely new chat with fresh context try { // Step 1: Reset memory session const response = await fetch(`${process.env.REACT_APP_API_URL}/new-chat`, { method: 'POST', headers: { 'Authorization': `Bearer ${authToken}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ title: `Chat ${new Date().toLocaleDateString()}` }) }); if (response.ok) { const result = await response.json(); if (result.status === 'success') { // Step 2: Immediately create MongoDB session const newSessionId = await createNewSession(`Chat ${new Date().toLocaleDateString()}`); if (newSessionId) { // Reset all state to fresh with the new session setMessages([]); setCurrentSessionId(newSessionId); // Set the new session ID immediately setCurrentSessionTitle(`Chat ${new Date().toLocaleDateString()}`); setReplyingTo(null); setFollowupChips([]); setThinkingAdvisors([]); setUploadedDocuments([]); pendingReferenceContextRef.current = ''; setSearchIncorporate(null); onChatReturnedToStarter?.(); console.log('New chat created with MongoDB session:', newSessionId); // Wait a bit to ensure state has updated await new Promise(resolve => setTimeout(resolve, 100)); return newSessionId; // Return the session ID for the sidebar } else { throw new Error('Failed to create MongoDB session'); } } else { throw new Error('Failed to create memory session'); } } else { throw new Error(`HTTP error: ${response.status}`); } } catch (error) { console.error('Error creating new chat:', error); // Fallback to local reset setMessages([]); setCurrentSessionId(null); setCurrentSessionTitle(''); setReplyingTo(null); setThinkingAdvisors([]); setUploadedDocuments([]); onChatReturnedToStarter?.(); // Re-throw the error so the sidebar knows something went wrong throw error; } } }; const handleFileUploaded = async (file, uploadResult) => { // FIXED: Use the upload result data for better messaging const documentMessage = { id: generateMessageId(), type: 'document_upload', content: `Document uploaded: ${uploadResult.filename || file.name} (${uploadResult.chunks_created || 0} sections processed)`, timestamp: new Date() }; setMessages(prev => [...prev, documentMessage]); setUploadedDocuments(prev => [...prev, file]); // FIXED: Log document access info console.log('File uploaded to session:', { filename: uploadResult.filename, session_id: uploadResult.session_id, chat_session_id: uploadResult.chat_session_id, current_session_id: currentSessionId }); // Save document upload message to database if we have a current session if (currentSessionId) { await saveMessageToSession(documentMessage); } }; const applyStreamPayload = (payload, sessionId) => { const d = payload.data || {}; switch (payload.type) { case 'advisor_start': case 'advisor_delta': case 'advisor_done': case 'advisor': { setMessages(prev => applyAdvisorStreamEvent(prev, payload, { generateId: generateMessageId, })); if (payload.type === 'advisor_start' || payload.type === 'advisor') { setThinkingAdvisors(prev => prev.filter(a => a !== d.persona_id)); } if (payload.type === 'advisor_done' || payload.type === 'advisor') { saveMessageToSession({ id: generateMessageId(), type: 'advisor', persona_id: d.persona_id, content: d.content, timestamp: new Date(), advisorName: d.persona_name || d.persona_id, used_documents: d.used_documents || false, document_chunks_used: d.document_chunks_used || 0, }, sessionId).catch(err => console.error('Failed to persist advisor message:', err) ); } break; } case 'clarification': setMessages(prev => [...prev, { id: generateMessageId(), type: 'clarification', content: d.message, suggestions: d.suggestions || [], timestamp: new Date(), }]); break; case 'followups': if (Array.isArray(d.suggestions) && d.suggestions.length > 0) { setFollowupChips(d.suggestions); } break; case 'journey_suggestions': break; case 'progress': if (d.phase === 'selected' && Array.isArray(d.selected_advisors)) { rankedAdvisorIdsRef.current = d.selected_advisors; setThinkingAdvisors(prev => { const next = new Set(prev); next.add('system'); d.selected_advisors.forEach(id => next.add(id)); return Array.from(next); }); break; } if (d.phase === 'complete') { setIsLoading(false); setThinkingAdvisors([]); break; } if (d.persona_id != null) { setThinkingAdvisors(prev => prev.filter(a => a !== d.persona_id)); } break; case 'error': setMessages(prev => [...prev, { id: generateMessageId(), type: 'error', content: d.detail || 'An error occurred', timestamp: new Date(), }]); break; default: break; } }; const replayCachedLines = async (lines, sessionId) => { for (const line of lines) { if (!line || !String(line).trim()) continue; try { applyStreamPayload(JSON.parse(line), sessionId); } catch { /* skip */ } } try { await fetch(`${process.env.REACT_APP_API_URL}/switch-chat`, { method: 'POST', headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ chat_session_id: sessionId }), }); } catch (err) { console.error('Failed to hydrate session from prefetch cache:', err); } }; const handleSendMessage = async (inputMessage) => { if (!inputMessage.trim()) return; if (messages.length === 0) { markPathStep('first-question'); } // Create user message const userMessage = { id: generateMessageId(), type: 'user', content: inputMessage, timestamp: new Date() }; // Add to local state immediately setMessages(prev => [...prev, userMessage]); pinQuestionAfterRenderRef.current = true; // Create new session if we don't have one let sessionId = currentSessionId; if (!sessionId) { sessionId = await createNewSession(inputMessage); if (!sessionId) { console.error('Failed to create session'); setMessages(prev => [...prev, { id: generateMessageId(), type: 'error', content: 'Could not start a chat session. Your sign-in may have expired — please sign in again (or Explore as guest).', timestamp: new Date(), }]); return; } } saveMessageToSession(userMessage, sessionId).catch(err => console.error('Failed to persist user message:', err) ); if (messages.length === 0) { onChatBecameActive?.(sessionId); } // Update session title if this is the first message and title is generic if (messages.length === 0 && currentSessionTitle.includes('Chat ')) { const newTitle = inputMessage.length > 30 ? `${inputMessage.substring(0, 30)}...` : inputMessage; setCurrentSessionTitle(newTitle); updateSessionTitle(sessionId, newTitle).catch(err => console.error('Failed to update session title:', err) ); } // Set loading state setIsLoading(true); const advisorsForRequest = activeAdvisorIds.length > 0 ? activeAdvisorIds : Object.keys(advisors || {}); // Start with just the orchestrator's thinking bubble. The backend will // emit a `progress { phase: 'selected', selected_advisors: [...] }` event // naming the advisors it picked (ranked), and that handler will add their // ThinkingIndicators. This prevents the brief flash of thinking indicators // for every advisor in the active pool before ranking has run. setThinkingAdvisors(['system']); setFollowupChips([]); rankedAdvisorIdsRef.current = []; prefetchAbortRef.current?.abort(); prefetchQueueRef.current = []; const cached = !pendingReferenceContextRef.current && getPrefetchedStream(prefetchUserKey, inputMessage); if (cached?.lines?.length) { try { await replayCachedLines(cached.lines, sessionId); dropPrefetchedStream(prefetchUserKey, inputMessage); setIsLoading(false); setThinkingAdvisors([]); setSidebarRefreshTrigger(prev => prev + 1); return; } catch (error) { console.error('Prefetch replay failed, using live stream:', error); } } try { const response = await fetch(`${process.env.REACT_APP_API_URL}/chat-stream`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${authToken}`, }, body: JSON.stringify({ user_input: consumePendingReferences(inputMessage), response_length: 'medium', chat_session_id: sessionId, active_advisors: advisorsForRequest, }), }); if (!response.ok) { if (response.status === 401 && typeof onSignOut === 'function') { onSignOut(); throw new Error('Session expired. Please sign in again.'); } let detail = `HTTP error! status: ${response.status}`; try { const errBody = await response.json(); if (errBody?.detail) { detail = typeof errBody.detail === 'string' ? errBody.detail : JSON.stringify(errBody.detail); } } catch { /* keep status text */ } throw new Error(detail); } const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop() ?? ''; for (const line of lines) { if (!line.trim()) continue; let payload; try { payload = JSON.parse(line); } catch (parseErr) { console.error('Failed to parse chat-stream line:', line, parseErr); continue; } applyStreamPayload(payload, sessionId); } } } catch (error) { console.error('Error sending message:', error); setMessages(prev => [...prev, { id: generateMessageId(), type: 'error', content: `Failed to send message: ${error.message}`, timestamp: new Date() }]); } finally { setIsLoading(false); setThinkingAdvisors([]); setSidebarRefreshTrigger(prev => prev + 1); } }; const handleReplyToAdvisor = async (inputMessage, replyContext) => { // Ensure we have a session before proceeding let sessionId = currentSessionId; if (!sessionId) { sessionId = await createNewSession(inputMessage); if (!sessionId) { console.error('Failed to create session for reply'); return; } } const replyMessage = { id: generateMessageId(), type: 'user', content: inputMessage, replyTo: { advisorId: replyContext.persona_id, advisorName: replyContext.advisorName, messageId: replyContext.messageId }, timestamp: new Date() }; setMessages(prev => [...prev, replyMessage]); pinQuestionAfterRenderRef.current = true; // Save reply message to database with explicit session ID await saveMessageToSession(replyMessage, sessionId); setIsLoading(true); setThinkingAdvisors([replyContext.persona_id]); try { const response = await fetch(`${process.env.REACT_APP_API_URL}/reply-to-advisor`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${authToken}`, }, body: JSON.stringify({ user_input: consumePendingReferences(inputMessage), advisor_id: replyContext.advisorId, original_message_id: replyContext.messageId, chat_session_id: sessionId // Use confirmed session ID }), }); if (!response.ok) { throw new Error(`HTTP error! Status: ${response.status}`); } const data = await response.json(); if (data.type === 'advisor_reply') { const replyResponseMessage = { id: generateMessageId(), type: 'advisor', persona_id: data.persona_id, advisorName: data.persona, content: data.response, isReply: true, timestamp: new Date() }; setMessages(prev => [...prev, replyResponseMessage]); // Save advisor reply to database await saveMessageToSession(replyResponseMessage, sessionId); } } catch (error) { console.error('Error replying to advisor:', error); const errorMessage = { id: generateMessageId(), type: 'error', content: 'Sorry, I encountered an error with your reply. Please try again.', timestamp: new Date() }; setMessages(prev => [...prev, errorMessage]); // Save error message to database await saveMessageToSession(errorMessage, sessionId); } setIsLoading(false); setThinkingAdvisors([]); }; const handleExpandMessage = async (messageId, advisorId) => { const advisor = advisors[advisorId]; if (!advisor) return; const originalMessage = messages.find(msg => msg.id === messageId); if (!originalMessage) return; const expandPrompt = `Please expand on your previous response: "${originalMessage.content.substring(0, 100)}..." Provide more detail and depth.`; const expandMessage = { id: generateMessageId(), type: 'user', content: expandPrompt, timestamp: new Date(), isExpandRequest: true, expandsMessageId: messageId }; setMessages(prev => [...prev, expandMessage]); pinQuestionAfterRenderRef.current = true; // Save expand request to database await saveMessageToSession(expandMessage); setIsLoading(true); setThinkingAdvisors([advisorId]); try { const response = await fetch(`${process.env.REACT_APP_API_URL}/chat/${advisorId}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${authToken}`, }, body: JSON.stringify({ user_input: expandPrompt, response_length: 'long' }), }); if (!response.ok) { throw new Error(`HTTP error! Status: ${response.status}`); } const data = await response.json(); if (data.persona && data.response) { const expandedMessage = { id: generateMessageId(), type: 'advisor', persona_id: advisorId, advisorName: advisor.name, content: data.response, isExpansion: true, expandsMessageId: messageId, timestamp: new Date() }; setMessages(prev => [...prev, expandedMessage]); // Save expanded response to database await saveMessageToSession(expandedMessage); } else { const errorMessage = { id: generateMessageId(), type: 'error', content: 'Sorry, I received an unexpected response format. Please try again.', timestamp: new Date() }; setMessages(prev => [...prev, errorMessage]); // Save error message to database await saveMessageToSession(errorMessage); } } catch (error) { console.error('Error expanding message:', error); const errorMessage = { id: generateMessageId(), type: 'error', content: 'Sorry, I encountered an error while expanding the message. Please try again.', timestamp: new Date() }; setMessages(prev => [...prev, errorMessage]); // Save error message to database await saveMessageToSession(errorMessage); } setIsLoading(false); setThinkingAdvisors([]); }; const handleReplyToMessage = (message) => { const advisor = advisors[message.persona_id]; setReplyingTo({ advisorId: message.persona_id, messageId: message.id, advisorName: advisor?.name || message.advisorName || 'Advisor', persona_id: message.persona_id }); }; const handleMessageClick = (message) => { if (message.type === 'advisor') { const advisor = advisors[message.persona_id]; setReplyingTo({ advisorId: message.persona_id, messageId: message.id, advisorName: advisor?.name || message.advisorName || 'Advisor', persona_id: message.persona_id }); } }; /** Group consecutive advisor messages so we can render them in a horizontal carousel */ const messageGroups = useMemo(() => { const groups = []; let i = 0; let lastUserContent = ''; while (i < messages.length) { if (messages[i].type === 'advisor') { const advisorGroup = []; while (i < messages.length && messages[i].type === 'advisor') { advisorGroup.push(messages[i]); i++; } groups.push({ type: 'advisor_group', messages: advisorGroup, userQuestion: lastUserContent, }); } else { if (messages[i].type === 'user' && !messages[i].isReferenceSnippet) { lastUserContent = messages[i].content; } groups.push({ type: 'single', message: messages[i] }); i++; } } return groups; }, [messages]); const latestUserMessageId = useMemo( () => findLatestUserMessage(messages)?.id || null, [messages] ); const handleInputSubmit = async (inputMessage) => { if (replyingTo) { // This is a reply to a specific message await handleReplyToAdvisor(inputMessage, replyingTo); } else { // This is a regular message await handleSendMessage(inputMessage); } }; const cancelReply = () => { setReplyingTo(null); }; const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); const handleSidebarToggle = (isCollapsed) => { setIsSidebarCollapsed(isCollapsed); }; const hasMessages = messages.length > 0; const hasConversationMessages = messages.filter(m => m.type !== 'system' && m.type !== 'document_upload').length > 0; const chatPlaceholder = config?.chat_page?.placeholder || "Ask your advisors anything..."; return (
{/* Sidebar Component */} { setAboutYouInitialTab('about'); setShowAboutYou(true); }} onOpenAccount={() => { setSettingsInitialTab('profile'); setShowSettings(true); }} onOpenClearData={() => setShowClearData(true)} onOpenModelStatus={() => { setSettingsInitialTab('model-status'); setShowSettings(true); }} onNavigateToJourney={handleNavigateToJourney} statedGoal={statedGoal} onRemoveSampleData={user?.is_guest ? async () => { if (!window.confirm('Remove all sample demo data? You stay in guest mode with a clean slate.')) return; try { const { removeGuestSampleData } = await import('../utils/guestSample'); await removeGuestSampleData(authToken); setMessages([]); setCurrentSessionId(null); setCurrentSessionTitle(''); setSidebarRefreshTrigger((n) => (n || 0) + 1); window.alert('Sample data removed.'); } catch (e) { window.alert(e.message || 'Failed to remove sample data'); } } : undefined} />
{}} onNavigateToCanvas={handleNavigateToCanvas} onNavigateToJourney={handleNavigateToJourney} onMobileMenu={handleMobileMenuToggle} > {/* User guide button (from main) — slotted into AppHeader's children */} {/* Main Content */}
{!hasMessages ? (
{ setAboutYouInitialTab('about'); setShowAboutYou(true); }} onNavigateToJourney={handleNavigateToJourney} onNavigateToCanvas={handleNavigateToCanvas} onOpenModelStatus={() => { setSettingsInitialTab('model-status'); setShowSettings(true); }} onNavigateToSignup={onNavigateToSignup} onPathStepComplete={markPathStep} />
) : (
{/* Add loading session indicator */} {isLoadingSession && (
Loading chat session...
)}
{messageGroups.map((group) => ( group.type === 'advisor_group' ? ( ) : (
{group.message.type === 'user' && group.message.isReferenceSnippet && (

Added to context

{group.message.content}

)} {group.message.type === 'user' && !group.message.isReferenceSnippet && (
{group.message.replyTo && (
Reply to {group.message.replyTo.advisorName}
)}

{group.message.content}

)} {group.message.type === 'error' && (

{group.message.content}

)} {group.message.type === 'system' && (

{group.message.content}

)} {group.message.type === 'document_upload' && (

{group.message.content}

)} {group.message.type === 'clarification' && (
I need a bit more information

{group.message.content}

{group.message.suggestions && group.message.suggestions.length > 0 && (

Here are some ways you could be more specific:

{group.message.suggestions.map((suggestion, index) => ( ))}
)}
)}
) ))} {thinkingAdvisors.includes('system') && (
Orchestrator is thinking...
)} {thinkingAdvisors.filter(id => id !== 'system').map(advisorId => ( ))}
)}
{replyingTo && (
Replying to {replyingTo.advisorName}
)} {searchIncorporate && (
Copy what you want from {searchIncorporate.source}, then come back and paste it here.
)} setShowOnboarding(true)} onOpenProfileForm={() => { setAboutYouInitialTab('profile'); setShowAboutYou(true); }} showExport exportHasMessages={hasConversationMessages} exportSessionId={currentSessionId} composeDraft={composeDraft} onComposeDraftApplied={handleComposeDraftApplied} /> {followupsVisible && followupChips.length > 0 && !isLoading && (
{followupChips.map((chip, idx) => ( ))}
)} {!followupsVisible && hasMessages && !isLoading && (
)}
{showAboutYou && ( { setShowAboutYou(false); loadProfile(); }} /> )} {showOnboarding && ( { setShowOnboarding(false); loadProfile(); }} /> )} {showSettings && ( setShowSettings(false)} onSignOut={onSignOut} onUserUpdate={(u) => { localStorage.setItem('user', JSON.stringify(u)); }} /> )} {showClearData && ( setShowClearData(false)} onDataCleared={() => { loadProfile(); setShowClearData(false); }} /> )} setClipboardModal({ open: false, text: '', error: '' })} onApprove={handleApproveClipboard} />
); }; export default ChatPage;