// Primus IQ — Session Report Panel (redesigned). // Right-side panel scoped to a chat session. Claude-inspired: a clean, // document-like reading/editing surface with quiet chrome. // // • lists the session's reports (header switcher) — listSessionReports / getReport // • awaiting_review = edit the WHOLE report inline (all sections — listReportSections // stacked; Contents rail jumps between them), done = read-only // • per-section save — updateReportSection // • add section — addReportSection // • AI-assisted revision with an inline accept/reject diff — suggestReportEdit // • whole-report preview (the real PDF renderer) — previewReport // • approve & render / open + download final PDF — approveReportRender / getDownloadUrl // ── markdown helpers (uses the app's already-loaded marked + DOMPurify) ────── const _renderMd = (md) => { try { return DOMPurify.sanitize(marked.parse(md || '')); } catch (_) { return (md || '').replace(/ { let s = ''; node.childNodes.forEach((n) => { if (n.nodeType === 3) { s += n.textContent; return; } if (n.nodeType !== 1) return; const t = n.tagName.toLowerCase(); const inner = _inlineMd(n); if (t === 'strong' || t === 'b') s += '**' + inner + '**'; else if (t === 'em' || t === 'i') s += '*' + inner + '*'; else if (t === 'code') s += '`' + inner + '`'; else if (t === 'a') s += '[' + inner + '](' + (n.getAttribute('href') || '') + ')'; else if (t === 'br') s += ' \n'; else s += inner; }); return s; }; const _htmlToMd = (html) => { const root = document.createElement('div'); root.innerHTML = html; const out = []; root.childNodes.forEach((n) => { if (n.nodeType === 3) { const t = n.textContent.trim(); if (t) out.push(t); return; } if (n.nodeType !== 1) return; const tag = n.tagName.toLowerCase(); if (/^h[1-6]$/.test(tag)) out.push('#'.repeat(+tag[1]) + ' ' + _inlineMd(n).trim()); else if (tag === 'ul') n.querySelectorAll(':scope > li').forEach((li) => out.push('- ' + _inlineMd(li).trim())); else if (tag === 'ol') { let i = 1; n.querySelectorAll(':scope > li').forEach((li) => out.push((i++) + '. ' + _inlineMd(li).trim())); } else if (tag === 'blockquote') out.push('> ' + _inlineMd(n).trim()); else if (tag === 'hr') out.push('---'); else if (tag === 'pre') { // Preserve the fence INFO-STRING (```stat, ```barchart, ```callout, …) — marked // renders it as `class="language-"` on the inner . Dropping it would // turn an infographic/callout block into a plain code fence and it would stop // rendering in the PDF. Also strip marked's single trailing newline. const code = n.querySelector('code'); const m = ((code && code.className) || '').match(/language-([\w-]+)/); const lang = m ? m[1] : ''; const body = (code ? code.textContent : n.textContent).replace(/\n$/, ''); out.push('```' + lang + '\n' + body + '\n```'); } else if (tag === 'table') { // Reconstruct a GitHub-flavoured markdown table (header + --- separator + rows) // so tables survive the WYSIWYG round-trip instead of collapsing to plain text. const rows = []; const head = Array.from(n.querySelectorAll('thead th, thead td')).map((c) => _inlineMd(c).trim()); if (head.length) { rows.push('| ' + head.join(' | ') + ' |'); rows.push('| ' + head.map(() => '---').join(' | ') + ' |'); } Array.from(n.querySelectorAll('tbody tr')).forEach((tr) => { const cells = Array.from(tr.querySelectorAll('td, th')).map((c) => _inlineMd(c).trim()); if (cells.length) rows.push('| ' + cells.join(' | ') + ' |'); }); if (rows.length) out.push(rows.join('\n')); } else { const t = _inlineMd(n).trim(); if (t) out.push(t); } }); return out.join('\n\n'); }; const _statusChip = (st) => ({ awaiting_review: { label: 'Review', bg: 'var(--maroon-10)', fg: 'var(--maroon)' }, render_queued: { label: 'Rendering', bg: 'var(--bg-soft)', fg: 'var(--mute)' }, rendering: { label: 'Rendering', bg: 'var(--bg-soft)', fg: 'var(--mute)' }, done: { label: 'Final', bg: 'rgba(46,160,64,0.14)', fg: '#1f6f2f' }, running: { label: 'Generating', bg: 'var(--bg-soft)', fg: 'var(--mute)' }, queued: { label: 'Generating', bg: 'var(--bg-soft)', fg: 'var(--mute)' }, failed: { label: 'Failed', bg: 'rgba(200,60,60,0.14)', fg: '#9a3b3b' }, }[st] || { label: st, bg: 'var(--bg-soft)', fg: 'var(--mute)' }); const ReviewPanel = ({ sessionId, focusRunId, onClose, onOpenArtifact }) => { const [reports, setReports] = React.useState([]); const [activeRun, setActiveRun] = React.useState(null); const [sections, setSections] = React.useState([]); const [dirtyFiles, setDirtyFiles] = React.useState([]); // filenames with unsaved edits const [focusedFile, setFocusedFile] = React.useState(null); // Contents-rail highlight const [saving, setSaving] = React.useState(false); const [savedAt, setSavedAt] = React.useState(null); const [error, setError] = React.useState(null); // layout — default to ~half the viewport (a document-like workspace), clamped so // the chat always keeps ≥360px. The user can still drag it narrower/wider. const [panelW, setPanelW] = React.useState(() => { const vw = (typeof window !== 'undefined' && window.innerWidth) || 1440; return Math.round(Math.max(420, Math.min(980, vw - 360, vw * 0.5))); }); const [expanded, setExpanded] = React.useState(false); const [dragging, setDragging] = React.useState(false); const [tocOpen, setTocOpen] = React.useState(true); const [dropdownOpen, setDropdownOpen] = React.useState(false); const [docView, setDocView] = React.useState('edit'); // editable view: 'edit' (WYSIWYG) | 'preview' (server render) // inline AI const [pill, setPill] = React.useState(null); // {left,top,bLeft,bTop,text,filename} const [aiOpen, setAiOpen] = React.useState(false); const [aiInstruction, setAiInstruction] = React.useState(''); const [aiBusy, setAiBusy] = React.useState(false); const [suggestion, setSuggestion] = React.useState(null); // {revised, parts, filename} const [previewKey, setPreviewKey] = React.useState(0); const scrollRef = React.useRef(null); const docRefs = React.useRef({}); // filename -> that section's contentEditable element const runId = activeRun && activeRun.run_id; const editable = activeRun && activeRun.status === 'awaiting_review'; const isDone = activeRun && activeRun.status === 'done'; const hasSections = activeRun && ['awaiting_review', 'render_queued', 'rendering', 'done'].includes(activeRun.status); const dirty = dirtyFiles.length > 0; const markDirty = (fn) => setDirtyFiles((prev) => (prev.includes(fn) ? prev : [...prev, fn])); // ── load the session's reports ────────────────────────────────────────── React.useEffect(() => { if (!sessionId && !focusRunId) return; setError(null); setReports([]); setActiveRun(null); const pick = (list) => { if (focusRunId) { const f = list.find((r) => r.run_id === focusRunId); if (f) return f; } return list.find((r) => r.status === 'awaiting_review') || list[0] || null; }; const listP = sessionId ? PrimusAPI.listSessionReports(sessionId).then((res) => (res && res.reports) || []) : Promise.resolve([]); listP .then(async (list) => { if (focusRunId && !list.some((r) => r.run_id === focusRunId)) { try { const meta = await PrimusAPI.getReport(focusRunId); if (meta && meta.run_id) list = [meta, ...list]; } catch (_) { /* focused run not resolvable — leave it out */ } } setReports(list); setActiveRun(pick(list)); }) .catch((e) => setError(e.message || "Could not load this session's reports.")); }, [sessionId, focusRunId]); // ── load ALL sections when the active report changes ───────────────────── React.useEffect(() => { setSections([]); setDirtyFiles([]); setSavedAt(null); setFocusedFile(null); setSuggestion(null); setAiInstruction(''); setPill(null); setAiOpen(false); docRefs.current = {}; if (!activeRun || !hasSections) return; PrimusAPI.listReportSections(activeRun.run_id) .then((secs) => { setSections(secs || []); if (secs && secs.length) setFocusedFile(secs[0].filename); }) .catch((e) => setError(e.message || 'Could not load the report.')); }, [activeRun && activeRun.run_id]); // ── inject each section's rendered HTML into its block, once per run ────── // Blocks stay mounted (Preview just hides them), so this never clobbers live // edits: a block already tagged for this run is skipped. New/added blocks and // a fresh run get (re)injected. React.useEffect(() => { if (!editable || !sections.length) return; sections.forEach((s) => { const el = docRefs.current[s.filename]; if (el && el.getAttribute('data-loaded') !== String(runId)) { el.innerHTML = _renderMd(s.content); el.setAttribute('data-loaded', String(runId)); } }); }, [editable, sections, runId, docView]); // ── resize + expand geometry are pure CSS on THIS element only ────────── const startResize = (e) => { e.preventDefault(); const sx = e.clientX, sw = panelW; setDragging(true); setPill(null); const maxW = Math.max(760, (window.innerWidth || 1440) - 360); // always leave ≥360 for the chat const move = (ev) => setPanelW(Math.max(380, Math.min(maxW, sw + (sx - ev.clientX)))); const up = () => { document.removeEventListener('mousemove', move); document.removeEventListener('mouseup', up); setDragging(false); }; document.addEventListener('mousemove', move); document.addEventListener('mouseup', up); }; // Contents rail → scroll that section into view (in edit mode). const scrollToSection = (fn) => { setFocusedFile(fn); setDocView('edit'); requestAnimationFrame(() => { const el = docRefs.current[fn]; const wrap = el && el.closest('[data-file]'); if (wrap && typeof wrap.scrollIntoView === 'function') wrap.scrollIntoView({ behavior: 'smooth', block: 'start' }); }); }; const switchReport = (r) => { if (r.run_id === runId) { setDropdownOpen(false); return; } if (dirty && !window.confirm('Discard unsaved edits?')) return; setActiveRun(r); setDropdownOpen(false); }; // ── inline selection → Ask AI (scoped to the section block it lands in) ── const onDocMouseDown = () => { if (pill || aiOpen) { setPill(null); setAiOpen(false); setSuggestion(null); setAiInstruction(''); } }; const onDocSelect = () => { if (!editable) return; const sel = window.getSelection(); if (!sel || sel.isCollapsed || !sel.rangeCount) return; const text = sel.toString().trim(); if (text.length < 2) return; const range = sel.getRangeAt(0); const anchor = range.commonAncestorContainer; const anchorEl = anchor.nodeType === 1 ? anchor : anchor.parentElement; const wrap = anchorEl && anchorEl.closest('[data-file]'); if (!wrap || !scrollRef.current || !scrollRef.current.contains(wrap)) return; const fn = wrap.getAttribute('data-file'); const rect = range.getBoundingClientRect(); const pr = scrollRef.current.getBoundingClientRect(); const cw = scrollRef.current.clientWidth, bw = Math.min(380, cw - 32); const rawLeft = rect.left - pr.left + rect.width / 2; const bLeft = Math.max(bw / 2 + 12, Math.min(cw - bw / 2 - 12, rawLeft)); const bTop = Math.max(8, Math.min(rect.top - pr.top + 6, scrollRef.current.clientHeight - 230)); setPill({ left: Math.max(20, Math.min(cw - 20, rawLeft + scrollRef.current.scrollLeft)), top: rect.top - pr.top + scrollRef.current.scrollTop - 8, bLeft, bTop, text, filename: fn, }); setAiOpen(false); setSuggestion(null); setAiInstruction(''); }; const onScrollDoc = () => { if (pill && !aiOpen) setPill(null); }; const askAI = async (instrArg) => { const instruction = (typeof instrArg === 'string' ? instrArg : aiInstruction).trim(); if (!pill || !pill.text) return; const fn = pill.filename; setAiBusy(true); setError(null); try { const res = await PrimusAPI.suggestReportEdit(runId, fn, instruction || 'Improve this passage', pill.text); const revised = (res && res.revised_section) || ''; const el = docRefs.current[fn]; const curMd = el ? _htmlToMd(el.innerHTML) : ''; const parts = window.Diff ? window.Diff.diffWords(curMd, revised) : [{ value: revised }]; setSuggestion({ revised, parts, filename: fn }); } catch (e) { setError(e.message || 'Suggestion failed.'); } finally { setAiBusy(false); } }; const acceptSuggestion = () => { if (!suggestion) return; const el = docRefs.current[suggestion.filename]; if (el) { el.innerHTML = _renderMd(suggestion.revised); markDirty(suggestion.filename); } // whole-section replace (matches backend) setPill(null); setAiOpen(false); setSuggestion(null); setAiInstruction(''); const sel = window.getSelection(); if (sel) sel.removeAllRanges(); }; const rejectSuggestion = () => setSuggestion(null); const closeAi = () => { setAiOpen(false); setPill(null); setSuggestion(null); setAiInstruction(''); setAiBusy(false); }; // ── save (all edited sections) / add section ───────────────────────────── const save = async () => { if (!dirty || saving) return; setSaving(true); setError(null); try { const updated = {}; for (const fn of dirtyFiles) { const el = docRefs.current[fn]; if (!el) continue; const md = _htmlToMd(el.innerHTML); await PrimusAPI.updateReportSection(runId, fn, md); updated[fn] = md; } setSections((prev) => prev.map((s) => (updated[s.filename] != null ? { ...s, content: updated[s.filename] } : s))); setDirtyFiles([]); setSavedAt(Date.now()); setPreviewKey((k) => k + 1); } catch (e) { setError(e.message || 'Save failed.'); } finally { setSaving(false); } }; const addSection = async () => { const t = window.prompt('New section title'); if (!t || !t.trim()) return; try { const s = await PrimusAPI.addReportSection(runId, t.trim()); setSections((prev) => [...prev, s]); setPreviewKey((k) => k + 1); setFocusedFile(s.filename); requestAnimationFrame(() => { const el = docRefs.current[s.filename]; const wrap = el && el.closest('[data-file]'); if (wrap && typeof wrap.scrollIntoView === 'function') wrap.scrollIntoView({ behavior: 'smooth', block: 'start' }); }); } catch (e) { setError((e && e.message) || 'Could not add section.'); } }; const closePanel = () => { setExpanded(false); onClose && onClose(); }; // Download the finished report's PDF (signed URL, same path the notification uses). const downloadPdf = async () => { if (!activeRun || !activeRun.artifact_id) return; try { const res = await PrimusAPI.getDownloadUrl(activeRun.artifact_id); const url = res && (res.download_url || res.url); if (url) window.open(url, '_blank', 'noopener'); } catch (e) { setError((e && e.message) || 'Could not get the download link.'); } }; // ── styles ────────────────────────────────────────────────────────────── const asideStyle = expanded ? { position: 'fixed', top: 20, right: 20, bottom: 20, left: 20, width: 'auto', zIndex: 1000, display: 'flex', flexDirection: 'column', background: 'var(--bg-card)', border: '1px solid var(--line-2)', borderRadius: 16, boxShadow: 'var(--shadow-lg)', overflow: 'hidden' } : { position: 'relative', width: panelW, minWidth: panelW, height: '100%', display: 'flex', flexDirection: 'column', background: 'var(--bg-card)', borderLeft: '1px solid var(--line)', overflow: 'hidden', transition: dragging ? 'none' : 'width 0.18s ease' }; const iconBtn = { width: 32, height: 32, borderRadius: 8, border: 'none', background: 'transparent', cursor: 'pointer', display: 'grid', placeItems: 'center', color: 'var(--mute)' }; const wideEnough = expanded || panelW >= 460; // Contents rail only in EDIT mode — it scrolls between the stacked section blocks. // A done report is a single rendered document (iframe), so a jump-list there does // nothing; omit it rather than show dead nav. const showToc = tocOpen && hasSections && wideEnough && editable; const docMaxWidth = expanded ? 760 : '100%'; const showPreview = isDone || (editable && docView === 'preview'); return ( {expanded && (
setExpanded(false)} style={{ position: 'fixed', inset: 0, background: 'var(--bg-overlay)', backdropFilter: 'blur(2px)', zIndex: 999, animation: 'fadein 0.2s ease' }} /> )} ); }; // Whole-report WYSIWYG preview — the SAME renderer the PDF uses. Used for // finished (read-only) reports and the editable Preview tab. `refreshKey` bumps on save/add. const ReviewPreview = ({ runId, refreshKey }) => { const [html, setHtml] = React.useState(''); const [loading, setLoading] = React.useState(true); React.useEffect(() => { if (!runId) { setLoading(false); return; } let cancelled = false; setLoading(true); PrimusAPI.previewReport(runId) .then((res) => { if (!cancelled) setHtml((res && res.html) || ''); }) .catch(() => { if (!cancelled) setHtml('

Preview failed.

'); }) .finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; }, [runId, refreshKey]); return (
{loading &&
rendering…
}