// 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 and analysis runs — listSessionReports / listSessionAnalysisRuns // • 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 { // Section images use asset:// URLs the browser can't load — and DOMPurify // strips the scheme, so after one WYSIWYG save the image reference was // silently LOST (figure → caption-only text). Swap each
for a // protected chip carrying the original HTML (base64); _htmlToMd restores // it verbatim on save. const prepared = (md || '').replace(/
[\s\S]*?<\/figure>/gi, (fig) => { const cap = (fig.match(/
([\s\S]*?)<\/figcaption>/i) || [])[1] || ''; const alt = (fig.match(/alt="([^"]*)"/i) || [])[1] || ''; const b64 = btoa(unescape(encodeURIComponent(fig))); const label = (alt || cap || 'section image').replace(/📷 ${label}`; }); return DOMPurify.sanitize(marked.parse(prepared)); } 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 if (tag === 'div' && n.classList && n.classList.contains('pi-fig-slot')) { // Restore the protected
(see _renderMd) byte-identical. try { const fig = decodeURIComponent(escape(atob(n.getAttribute('data-fig') || ''))); if (fig) out.push(fig); } catch (_) { /* mangled placeholder — drop rather than emit garbage */ } } else { const t = _inlineMd(n).trim(); if (t) out.push(t); } }); return out.join('\n\n'); }; const _statusChip = (st) => ({ awaiting_plan_review: { label: 'Plan Review', bg: 'var(--maroon-10)', fg: 'var(--maroon)' }, awaiting_review: { label: 'Review', bg: 'var(--maroon-10)', fg: 'var(--maroon)' }, ready_for_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' }, published: { label: 'Published', 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 [reviewData, setReviewData] = React.useState(null); 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); // in-flight flags for the analysis review actions (publish / approve / reject). // Each shows a spinner INSIDE its button while the 202 request is pending, and // guards against a double-click firing the request twice. const [publishBusy, setPublishBusy] = React.useState(null); // null | 'publish' | 'download' const [approveBusy, setApproveBusy] = React.useState(false); const [rejectBusy, setRejectBusy] = React.useState(false); // 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 isAnalysis = activeRun && activeRun.kind === 'analysis'; const editable = activeRun && activeRun.status === 'awaiting_review'; const isDone = activeRun && (activeRun.status === 'done' || activeRun.status === 'published'); 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); // NOTE: do NOT reset reports/activeRun to empty here. Blanking before the // refetch resolves made the whole pane flash empty on every run/notification // switch. The .then below always replaces both, so the previous run stays // visible until the new list arrives (fetch-then-swap, no flicker). const pick = (list) => { if (focusRunId) { const f = list.find((r) => r.run_id === focusRunId); if (f) return f; } return list.find((r) => ['awaiting_plan_review', 'awaiting_review', 'ready_for_review'].includes(r.status)) || list[0] || null; }; const listP = sessionId ? Promise.all([ PrimusAPI.listSessionReports(sessionId).then((res) => (res && res.reports) || []), PrimusAPI.listSessionAnalysisRuns(sessionId).then((res) => (res && res.runs) || []), ]).then(([reportRuns, analysisRuns]) => [...reportRuns, ...analysisRuns]) : Promise.resolve([]); listP .then(async (list) => { if (focusRunId && !list.some((r) => r.run_id === focusRunId)) { try { let meta = null; try { meta = await PrimusAPI.getReport(focusRunId); } catch (_) { meta = await PrimusAPI.getAnalysisRun(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(() => { // Reset only the report-scratch state (sections, edit buffers, inline-AI). // Do NOT null reviewData up front — that made the AnalysisReviewCard blank // then repopulate on every active-run change. For analysis runs we // fetch-then-swap below so the card updates in place; only clear it when // the active run is not an analysis run (so no stale card lingers). setSections([]); setDirtyFiles([]); setSavedAt(null); setFocusedFile(null); setSuggestion(null); setAiInstruction(''); setPill(null); setAiOpen(false); docRefs.current = {}; if (!activeRun) { setReviewData(null); return; } if (activeRun.kind === 'analysis') { PrimusAPI.getAnalysisRunReview(activeRun.run_id) .then((data) => setReviewData(data || null)) .catch((e) => { setReviewData(null); setError(e.message || 'Could not load the analysis run.'); }); return; } setReviewData(null); if (!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(); }; const downloadArtifact = 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.'); } }; const publishAnalysis = async ({ download = false } = {}) => { if (!activeRun || activeRun.kind !== 'analysis' || publishBusy) return; setPublishBusy(download ? 'download' : 'publish'); setError(null); try { const next = await PrimusAPI.publishAnalysisRun(runId); setActiveRun(next); const review = await PrimusAPI.getAnalysisRunReview(runId); setReviewData(review || null); // "Publish and Download" — after the publish succeeds, immediately // fetch a signed URL and open it. Reuses the same endpoint used by // the notification's Download button. if (download && next && next.artifact_id) { try { const res = await PrimusAPI.getDownloadUrl(next.artifact_id); const url = res && (res.download_url || res.url); if (url) window.open(url, '_blank', 'noopener'); } catch (_) { /* silent — publish already succeeded */ } } } catch (e) { setError((e && e.message) || 'Could not publish the cleaned dataset.'); } finally { setPublishBusy(null); } }; const [planFeedbackOpen, setPlanFeedbackOpen] = React.useState(false); const [planFeedback, setPlanFeedback] = React.useState(''); const approvePlan = async () => { if (!activeRun || activeRun.kind !== 'analysis' || approveBusy) return; setApproveBusy(true); setError(null); try { const next = await PrimusAPI.approveAnalysisPlan(runId); setActiveRun(next); const review = await PrimusAPI.getAnalysisRunReview(runId); setReviewData(review || null); } catch (e) { setError((e && e.message) || 'Could not approve the cleaning plan.'); } finally { setApproveBusy(false); } }; const rejectPlan = async () => { if (!activeRun || activeRun.kind !== 'analysis' || !planFeedback.trim() || rejectBusy) return; setRejectBusy(true); setError(null); try { const next = await PrimusAPI.rejectAnalysisPlan(runId, planFeedback.trim()); setActiveRun(next); const review = await PrimusAPI.getAnalysisRunReview(runId); setReviewData(review || null); setPlanFeedbackOpen(false); setPlanFeedback(''); } catch (e) { setError((e && e.message) || 'Could not reject the cleaning plan.'); } finally { setRejectBusy(false); } }; // ── 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%'; // Report-only: ReviewPreview iframes /reports/{run_id}/preview which does // not exist for analysis runs. Analysis runs render their preview inline // via AnalysisReviewCard (table of preview_rows). Without this guard, a // done analysis run showed a red "Preview failed." under its own preview. const showPreview = !isAnalysis && (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' }} /> )} {/* While dragging, a transparent shield captures ALL mouse events — without it, the report iframe swallows mousemove as soon as the cursor crosses it, which froze shrink-drags (grow-drags move over regular DOM and worked). */} {dragging && (
)} ); }; // One-line human description for a single cleaning tool's summary dict. Tool // summaries have no shared shape (dedupe_rows reports rows_before/after, // cluster_near_duplicates reports clusters_merged, dataprep tools report // new_columns, ...) — this renders whichever fields a given op actually has, // rather than assuming one fixed shape (which is what silently produced // "Rows before: n/a · Rows after: n/a" whenever the LAST-run tool in a chain // didn't happen to report those two specific keys). const _describeCleaningOp = (op) => { const name = String(op.op || 'cleaning step').replace(/_/g, ' '); const bits = []; if (op.column) bits.push(`column '${op.column}'`); if (Array.isArray(op.columns_changed) && op.columns_changed.length) bits.push(`columns: ${op.columns_changed.join(', ')}`); if (Array.isArray(op.dropped_columns) && op.dropped_columns.length) bits.push(`dropped: ${op.dropped_columns.join(', ')}`); if (op.clusters_merged != null) bits.push(`${op.clusters_merged} value(s) merged`); if (op.removed != null) bits.push(`${op.removed} row(s) removed`); if (op.rows_before != null && op.rows_after != null) bits.push(`${op.rows_before} → ${op.rows_after} rows`); if (Array.isArray(op.new_columns) && op.new_columns.length) bits.push(`added: ${op.new_columns.join(', ')}`); if (Array.isArray(op.operations_applied) && op.operations_applied.length) bits.push(op.operations_applied.join('; ')); if (op.notes) bits.push(op.notes); return bits.length ? `${name} — ${bits.join(', ')}` : name; }; const AnalysisReviewCard = ({ review, onDownload }) => { const profile = review && review.cleaning_profile ? review.cleaning_profile : {}; const summary = review && review.cleaning_summary ? review.cleaning_summary : {}; const operations = (review && review.cleaning_operations) || []; const strategies = (review && review.cleaning_plan) || []; const previewRows = (review && review.preview_rows) || []; const previewColumns = (review && review.preview_columns) || []; // profile.rows (the ORIGINAL row count, captured once during profiling) and // review.row_count (the CURRENT/latest cleaned file's row count, computed // server-side for the preview) are always both available regardless of // which cleaning tool(s) ran — unlike a single tool's self-reported // rows_before/rows_after, which only some tools include. const rowsBefore = profile.rows; const rowsAfter = review.row_count; // The published/downloaded file is named after the SOURCE (`_cleaned.`, // mirroring the backend's _cleaned_output_basename), so show that SAME name here // instead of the internal per-step working file (`cleaned.csv` / `cleaned__Sheet.csv`) // — the review card and the library entry then always agree. const _outputFileName = (() => { const src = (review.source_file_name || '').toString(); let stem = src.replace(/\.[^.\\/]+$/, '').replace(/\s+/g, '_') .replace(/[^0-9A-Za-z._-]+/g, '').replace(/^[._-]+|[._-]+$/g, ''); if (!stem) { // no source name known — fall back to whatever the run actually produced return (review.latest_cleaned_file || '').split('/').pop() || summary.cleaned_filename || 'Not published yet'; } const ext = review.input_format === 'xlsx' ? 'xlsx' : 'csv'; return `${stem}_cleaned.${ext}`; })(); return (
Cleaning Review
{review.artifact_id && ( Published to library )}
{review.source_file_name || review.title || 'Dataset cleaning run'}
{review.question &&
{review.question}
} {review.artifact_id ? (
{_outputFileName}
) : review.status === 'awaiting_plan_review' ? (
Awaiting your approval before any cleaning happens. Review the suggested algorithms below, then Approve plan to let cleaning proceed, or Reject with feedback to have it revised.
) : (review.status === 'awaiting_review' || review.status === 'ready_for_review') ? (
Awaiting your approval. Preview the cleaned rows and cleaning summary below, then use the Publish or Publish & Download buttons at the bottom of this panel to save the file to your library.
) : (
Not yet published — the analyst will save the cleaned file to your library when it finishes.
)}
{/* Source-based name (`_cleaned.`) — matches the published library artifact and the Download button, not the internal per-step working file (`cleaned.csv` / `cleaned__Sheet.csv`). */}
Suggested cleaning algorithms
{strategies.length ? strategies.map((item) => (
{item.label} {item.risk || 'info'}
{item.why}
Algorithm: {item.algorithm}
)) :
No strategy summary was saved for this run.
}
Applied cleaning summary
Rows before: {rowsBefore == null ? 'n/a' : rowsBefore} · Rows after: {rowsAfter == null ? 'n/a' : rowsAfter}
{operations.length ? operations.map((op, idx) => (
• {_describeCleaningOp(op)}
)) : (
No cleaning operations recorded yet.
)}
Preview of cleaned data
{previewColumns.map((col) => ( ))} {previewRows.map((row, idx) => ( {previewColumns.map((col) => ( ))} ))}
{col}
{String(row[col] == null ? '' : row[col])}
); }; // Inline button spinner (uses the app-wide `pi-spin` keyframe). `light` for use // on a filled/maroon button (white text), default for a light/secondary button. const Spinner = ({ light }) => ( ); const MetricCard = ({ label, value }) => (
{label}
{value}
); // 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); const frameRef = React.useRef(null); 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]); // srcDoc documents inherit the PARENT's base URL, so the report's internal // TOC anchors (href="#section-x") resolve to the app URL and navigate the // iframe INTO the app (the "screen blows up"). Intercept: same-document // anchors scroll inside the iframe; external links open a new tab. const wireLinks = () => { const doc = frameRef.current && frameRef.current.contentDocument; if (!doc) return; doc.addEventListener('click', (e) => { const a = e.target && e.target.closest && e.target.closest('a[href]'); if (!a) return; const href = a.getAttribute('href') || ''; if (href.startsWith('#')) { e.preventDefault(); const id = href.slice(1); const target = doc.getElementById(id) || doc.querySelector(`[name="${CSS.escape ? CSS.escape(id) : id}"]`); if (target && typeof target.scrollIntoView === 'function') { target.scrollIntoView({ behavior: 'smooth', block: 'start' }); } } else { e.preventDefault(); window.open(a.href, '_blank', 'noopener'); } }); }; return (
{loading &&
rendering…
}