// Atlas · Step 4 — Live agent research console (real SSE from /api/research) const { useState: useStateRes, useEffect: useEffectRes, useRef: useRefRes } = React; function getActivityStatus(lines, agentLabel, agentState) { // Find where this agent's section starts in the log const headerIdx = lines.findIndex(l => l.startsWith('◆') && l.includes(agentLabel)); const section = headerIdx >= 0 ? lines.slice(headerIdx + 1) : []; if (agentLabel === 'Specialist') return 'Designing strategy…'; if (agentLabel.includes('Synthesizer')) return 'Synthesizing…'; if (agentLabel.includes('Review')) { if (agentState === 'rejected') return 'Gaps identified — running targeted research…'; return 'Reviewing completeness…'; } if (agentLabel.includes('Enrichment')) return 'Enriching data…'; const hasCheck = section.some(l => l.trim().startsWith('✓')); if (hasCheck) return 'Analyzing results…'; if (section.some(l => l.includes('▶ PubMed'))) return 'Querying PubMed…'; if (section.some(l => l.includes('▶ OpenAlex'))) return 'Querying OpenAlex…'; if (section.some(l => l.includes('▶ arXiv') || l.includes('▶ Preprint'))) return 'Querying preprints…'; if (section.some(l => l.includes('▶ ClinicalTrials') || l.includes('▶ Clinical'))) return 'Querying ClinicalTrials…'; if (section.some(l => l.includes('▶ Patent') || l.includes('▶ PPUBS'))) return 'Querying patents…'; if (section.some(l => l.includes('▶ Web'))) return 'Searching web…'; return 'Working…'; } function deriveAgentStates(lines, isDone, manifestAgents, gapManifestAgents, reviewStatus) { const has = (pattern) => lines.some(l => l.includes(pattern)); const hasSpecialist = has('SPECIALIST') && has('Initializing'); const hasSynthesizer = has('◆ SYNTHESIZER'); const hasReview = has('◆ REVIEW AGENT'); const hasWriting = has('◆ WRITING AGENT'); const hasEnrichment = has('◆ ENRICHMENT SWEEP') && !has('◆ ENRICHMENT SWEEP COMPLETE'); const hasEnrichmentDone = has('◆ ENRICHMENT SWEEP COMPLETE'); const st = (active, done) => isDone && done !== false ? 'done' : done ? 'done' : active ? 'active' : 'pending'; // Build typed agent rows from the manifest if available; otherwise empty until it arrives const typedRows = []; if (manifestAgents && manifestAgents.length > 0) { const seenLabels = new Set(); for (const { label } of manifestAgents) { if (seenLabels.has(label)) continue; seenLabels.add(label); const headerLine = lines.find(l => l.startsWith('◆') && l.includes(label)); const started = !!headerLine; // Agent is done if a later agent has started, or synthesizer/enrichment is running const labelIdx = manifestAgents.findIndex(a => a.label === label); const nextStarted = manifestAgents.slice(labelIdx + 1).some(a => lines.some(l => l.startsWith('◆') && l.includes(a.label)) ); const isDoneAgent = started && (nextStarted || hasEnrichment || hasEnrichmentDone || hasSynthesizer || isDone); const isActive = started && !isDoneAgent; typedRows.push({ label, state: st(isActive, isDoneAgent) }); } } const firstTypedStarted = typedRows.length > 0 && typedRows.some(r => r.state !== 'pending'); const reviewIdx = lines.reduce((acc, l, i) => l.includes('◆ REVIEW AGENT') ? i : acc, -1); const gapIdx = lines.reduce((acc, l, i) => (l.includes('GAP RESEARCH SUBAGENT') || l.includes('gap-filling')) ? i : acc, -1); const hasGap = (gapManifestAgents && gapManifestAgents.length > 0) || lines.some(l => l.includes('GAP RESEARCH SUBAGENT') || l.includes('gap-filling')); const hasGapSpecialist = hasGap && has('gap-filling') && has('Designing'); const hasResynth = hasGap && has('Re-synthesizing'); const hasReviewAfterGap = hasGap && lines.slice(gapIdx).some(l => l.includes('◆ REVIEW AGENT')); const agents = [ { label: "Review Agent", state: 'done' }, { label: "Outline Agent", state: 'done' }, { label: "Specialist", state: st(hasSpecialist, firstTypedStarted || isDone) }, ...typedRows, { label: "Enrichment Sweep", state: st(hasEnrichment, hasEnrichmentDone || isDone) }, { label: "Synthesizer", state: st(hasSynthesizer && !hasGap, hasReview || isDone) }, { label: "Review Agent", state: !hasGap ? st(hasReview, hasWriting || isDone) : 'rejected' }, { label: "Writing Agent", state: st(hasWriting, isDone) }, ]; if (hasGap) { const gapRows = []; gapRows.push({ label: "Specialist", state: st(hasGapSpecialist, !!(gapManifestAgents) || isDone) }); if (gapManifestAgents && gapManifestAgents.length > 0) { const gapSeenLabels = new Set(); gapManifestAgents.forEach(({ label }, idx) => { if (gapSeenLabels.has(label)) return; gapSeenLabels.add(label); const headerLine = lines.find(l => l.startsWith('◆') && l.includes(label)); const started = !!headerLine; const nextStarted = gapManifestAgents.slice(idx + 1).some(a => lines.some(l => l.startsWith('◆') && l.includes(a.label)) ); const isDoneAgent = started && (nextStarted || hasResynth || isDone); const isActive = started && !isDoneAgent; gapRows.push({ label, state: st(isActive, isDoneAgent) }); }); } gapRows.push({ label: "Synthesizer", state: st(hasResynth, hasReviewAfterGap || isDone) }); gapRows.push({ label: "Review Agent", state: st(hasReviewAfterGap && !hasWriting, hasWriting || isDone) }); const writingIdx = agents.findIndex(a => a.label === "Writing Agent"); agents.splice(writingIdx, 0, ...gapRows); } return agents; } function StepResearch({ reportId, goBack, goNext, goHome, loading, error, refreshedFrom }) { const [lines, setLines] = useStateRes([]); const [done, setDone] = useStateRes(false); const [started, setStarted] = useStateRes(false); const [streamError, setStreamError] = useStateRes(null); const [reconnecting, setReconnecting] = useStateRes(false); const [stuck, setStuck] = useStateRes(false); const [sourcesUsed, setSourcesUsed] = useStateRes(null); const [reviewStatus, setReviewStatus] = useStateRes(null); const [manifestAgents, setManifestAgents] = useStateRes(null); const [gapManifestAgents, setGapManifestAgents] = useStateRes(null); const consoleRef = useRefRes(null); const esRef = useRefRes(null); const reconnectTimeoutRef = useRefRes(null); const messageIndexRef = useRefRes(0); // Auto-scroll console useEffectRes(() => { if (consoleRef.current) { consoleRef.current.scrollTop = consoleRef.current.scrollHeight; } }, [lines]); // Start: fire background task then open SSE replay const startResearch = (isReconnect = false, forceRestart = false) => { if (!reportId) return; if (!isReconnect && !forceRestart && started) return; setStarted(true); setStreamError(null); setReconnecting(false); messageIndexRef.current = 0; // Fire background task (idempotent — safe to call if already running) if (!isReconnect) { fetch(`/api/research-background/${reportId}`, { method: "POST" }).catch(() => {}); } const es = new EventSource(`/api/research/${reportId}`); esRef.current = es; es.onmessage = (e) => { try { const data = JSON.parse(e.data); if (data.stuck) { setStuck(true); } else if (data.done) { setDone(true); setStuck(false); es.close(); } else if (data.sources_used) { setSourcesUsed(data.sources_used); } else if (data.review_status) { setReviewStatus(data.review_status); } else if (data.manifest) { setManifestAgents(data.manifest); } else if (data.gap_manifest) { setGapManifestAgents(data.gap_manifest); } else if (data.text !== undefined) { const idx = messageIndexRef.current; messageIndexRef.current++; setLines((prev) => { // Replay synchronization: if we already have this line at this index, ignore it. if (idx < prev.length) return prev; return [...prev, data.text]; }); } } catch (err) {} }; es.onerror = () => { es.close(); if (!done) { setReconnecting(true); setStreamError("Connection lost. Reconnecting to agent logs…"); // Auto-reconnect after 3 seconds if (reconnectTimeoutRef.current) clearTimeout(reconnectTimeoutRef.current); reconnectTimeoutRef.current = setTimeout(() => { console.log("[research] Attempting auto-reconnect..."); startResearch(true); }, 3000); } }; }; // Auto-start when component mounts with a valid reportId useEffectRes(() => { if (reportId && !started) startResearch(); return () => { if (esRef.current) esRef.current.close(); if (reconnectTimeoutRef.current) clearTimeout(reconnectTimeoutRef.current); }; }, [reportId]); const retryResearch = async () => { if (esRef.current) esRef.current.close(); if (reconnectTimeoutRef.current) clearTimeout(reconnectTimeoutRef.current); await fetch(`/api/research/${reportId}/reset`, { method: "POST" }).catch(() => {}); setStuck(false); setLines([]); setDone(false); setManifestAgents(null); setGapManifestAgents(null); setReviewStatus(null); setSourcesUsed(null); setStreamError(null); messageIndexRef.current = 0; startResearch(false, true); }; const renderLine = (line, i) => { if (!line && line !== "") return