/** Reconcile server-delivered background replies without resetting chat DOM. */ import { createWhirlpool } from './spinner.js'; const researchSpinners = new WeakMap(); function stopResearchSpinner(node) { researchSpinners.get(node)?.destroy(); researchSpinners.delete(node); node.querySelector('[data-research-spinner]')?.replaceChildren(); } export function deliveryMessages(jobs, existingIds) { const seen = new Set(existingIds); return jobs.flatMap(job => { const message = job.status === 'delivered' && job.message; const id = message?.metadata?._db_id; if (!id || seen.has(id)) return []; seen.add(id); return [message]; }); } export function researchCardState(job) { if (job.outcome === 'error') return { tone: 'error', label: 'Research failed', detail: 'Open research for details.' }; if (job.outcome === 'no_sources') return { tone: 'error', label: 'No sources found', detail: 'This run did not return source-backed findings.' }; if (job.status === 'delivered') return { tone: 'done', label: 'Research finished', detail: `${job.source_count ?? '—'} sources · See the update in chat.` }; if (job.status === 'ready') return { tone: 'running', label: 'Preparing chat update', detail: 'The report is ready. You can keep chatting.' }; const p = job.progress || {}; const phase = { probing: 'Checking model', planning: 'Planning', searching: 'Searching', reading: 'Reading sources', analyzing: 'Analyzing findings', writing: 'Writing report' }[p.phase] || 'Starting research'; const round = p.round ? `Round ${p.round}${job.rounds ? `/${job.rounds}` : ''} · ` : ''; return { tone: 'running', label: phase, detail: `${round}${p.total_sources ?? 0} sources · You can keep chatting.` }; } export function renderResearchCards(box, jobs) { const visible = jobs.filter(j => j.tool === 'research' && /^[A-Za-z0-9_-]+$/.test(j.id) && j.status !== 'discarded'); let region = box.querySelector('[data-background-tools-status]'); if (!visible.length) { if (region) for (const card of region.children) stopResearchSpinner(card); region?.remove(); return; } if (!region) { region = document.createElement('section'); region.dataset.backgroundToolsStatus = ''; region.className = 'background-tools-status agent-thread has-top'; region.setAttribute('aria-label', 'Chat research'); box.append(region); } const keep = new Set(visible.map(job => job.id)); for (const card of Array.from(region.children)) if (!keep.has(card.dataset.jobId)) { stopResearchSpinner(card); card.remove(); } for (const job of visible) { let card = Array.from(region.children).find(node => node.dataset.jobId === job.id); if (!card) { card = document.createElement('article'); card.dataset.jobId = job.id; // Only constant markup; model-authored topics are assigned as text below. card.innerHTML = '
'; const header = card.querySelector('.agent-thread-header'); const content = card.querySelector('.agent-thread-content'); content.id = `chat-research-details-${job.id}`; header.setAttribute('aria-controls', content.id); header.addEventListener('click', event => { // Own this disclosure; do not also trigger the chat's delegated toggle. event.stopPropagation(); header.setAttribute('aria-expanded', String(card.classList.toggle('open'))); }); region.append(card); } const state = researchCardState(job); card.className = `agent-thread-node chat-research-card ${state.tone}${card.classList.contains('open') ? ' open' : ''}`; const setText = (selector, text) => { const node = card.querySelector(selector); if (node.textContent !== text) node.textContent = text; }; setText('.research-job-query', job.query || 'Research'); setText('[data-stage]', state.label); setText('.agent-thread-icon', state.tone === 'error' ? '✗' : state.tone === 'done' ? '✓' : '·'); setText('.chat-research-detail', state.detail); if (state.tone === 'running' && !researchSpinners.has(card)) { const spinner = createWhirlpool(16); card.querySelector('[data-research-spinner]').append(spinner.element); researchSpinners.set(card, spinner); } else if (state.tone !== 'running') stopResearchSpinner(card); card.querySelector('a').href = `#research-${job.id}`; } } export function startBackgroundToolJobs({ getSessionId, addMessage, base = '' }) { let inFlight = false; const busy = () => Boolean(document.querySelector('#chat-history .msg-ai.streaming')); const tick = async () => { const sid = getSessionId(); if (inFlight || !sid || document.hidden || window.__odysseusSessionReadyId !== sid) return; inFlight = true; try { const response = await fetch(`${base}/api/research/chat-jobs/${encodeURIComponent(sid)}`, { credentials: 'same-origin' }); if (!response.ok) return; const { jobs = [] } = await response.json(); if (getSessionId() !== sid || window.__odysseusSessionReadyId !== sid) return; const box = document.querySelector('#chat-history'); if (!box) return; renderResearchCards(box, jobs); if (busy()) return; const ids = Array.from(box.querySelectorAll('[data-db-id]'), node => node.dataset.dbId); for (const message of deliveryMessages(jobs, ids)) { addMessage(message.role, message.content, message.metadata?.model, message.metadata); } } catch { /* A transient poll error must not disrupt foreground chat. */ } finally { inFlight = false; } }; const timer = setInterval(tick, 3000); const onVisible = () => { if (!document.hidden) void tick(); }; document.addEventListener('visibilitychange', onVisible); void tick(); return () => { clearInterval(timer); document.removeEventListener('visibilitychange', onVisible); }; }