// Atlas · Step 5 — Draft review with inline Google-Docs-style comments const { useState: useStateDr, useEffect: useEffectDr, useMemo: useMemoDr, useRef: useRefDr, useCallback: useCallbackDr, } = React; const NOWRAP_THRESHOLD = 25; function getCellsForColumn(table, colIdx) { const cells = []; Array.from(table.rows).forEach(tr => { let cursor = 0; for (const cell of tr.cells) { const span = parseInt(cell.getAttribute("colspan") || "1", 10); if (cursor === colIdx) { cells.push(cell); break; } if (cursor > colIdx) break; cursor += span; } }); return cells; } function applyTableColumnWidths(html) { if (!html || !html.includes("${html}`, "text/html"); doc.querySelectorAll("table").forEach(table => { const numCols = Math.max( ...Array.from(table.rows).map(tr => Array.from(tr.cells).reduce((sum, cell) => sum + parseInt(cell.getAttribute("colspan") || "1", 10), 0) ), 0 ); for (let colIdx = 0; colIdx < numCols; colIdx++) { const cells = getCellsForColumn(table, colIdx); const maxLen = Math.max(...cells.map(c => c.textContent.trim().length), 0); if (maxLen > 0 && maxLen <= NOWRAP_THRESHOLD) { cells.forEach(c => { const existing = c.getAttribute("style") || ""; c.setAttribute("style", existing ? `${existing}; white-space: nowrap` : "white-space: nowrap"); }); } } const wrapper = doc.createElement("div"); wrapper.setAttribute("style", "overflow-x: auto; margin: 0 0 20px;"); table.parentNode.insertBefore(wrapper, table); wrapper.appendChild(table); }); return doc.body.innerHTML; } function renderWithIds(markdown) { if (!markdown) return ""; if (typeof marked === "undefined") return `
${markdown}
`; try { let html = marked.parse(markdown); html = applyTableColumnWidths(html); const slugs = {}; html = html.replace(/]*)>([\s\S]*?)<\/h[1-3]>/gi, (match, level, attrs, inner) => { const plain = inner.replace(/<[^>]+>/g, ""); const slug = plain.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); slugs[slug] = (slugs[slug] || 0) + 1; const id = slugs[slug] > 1 ? `${slug}-${slugs[slug]}` : slug; return `${inner}`; }); return html; } catch (e) { return `
${markdown}
`; } } // Highlight all text nodes that fall within a Range using elements. // Works for multi-paragraph / multi-element selections. function applyRangeHighlight(range, commentId) { const marks = []; const container = range.commonAncestorContainer; const root = container.nodeType === Node.TEXT_NODE ? container.parentNode : container; const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null, false); const textNodes = []; let node; while ((node = walker.nextNode())) { const cmp1 = range.comparePoint(node, 0); const cmp2 = range.comparePoint(node, node.nodeValue.length); // Node overlaps range if start is before end of range and end is after start of range if (cmp1 <= 0 && cmp2 >= 0) textNodes.push(node); else if (cmp1 < 0 && cmp2 > 0) textNodes.push(node); else if (cmp1 === 0 || cmp2 === 0) textNodes.push(node); // simpler: any node inside or touching } // Re-filter: keep only nodes within the range const inRange = textNodes.filter(n => { try { const nr = document.createRange(); nr.selectNode(n); return range.compareBoundaryPoints(Range.END_TO_START, nr) < 0 && range.compareBoundaryPoints(Range.START_TO_END, nr) > 0; } catch { return false; } }); // Clip to single table cell: if the first node is inside a td/th, discard any // nodes that belong to a different cell to avoid breaking table structure. const getCell = n => n.parentNode?.closest("td, th") || null; const firstCell = inRange.length > 0 ? getCell(inRange[0]) : null; const safeNodes = firstCell ? inRange.filter(n => getCell(n) === firstCell) : inRange; if (safeNodes.length === 0) return false; safeNodes.forEach(n => { let start = 0; let end = n.nodeValue.length; if (n === range.startContainer) start = range.startOffset; if (n === range.endContainer) end = range.endOffset; if (start >= end) return; const before = n.nodeValue.slice(0, start); const mid = n.nodeValue.slice(start, end); const after = n.nodeValue.slice(end); const mark = document.createElement("mark"); mark.className = "inline-comment-mark"; mark.dataset.commentId = commentId; mark.textContent = mid; marks.push(mark); const parent = n.parentNode; if (before) parent.insertBefore(document.createTextNode(before), n); parent.insertBefore(mark, n); if (after) parent.insertBefore(document.createTextNode(after), n); parent.removeChild(n); }); return marks.length > 0; } function removeHighlight(commentId) { document.querySelectorAll(`.inline-comment-mark[data-comment-id="${commentId}"]`).forEach(mark => { mark.replaceWith(document.createTextNode(mark.textContent)); mark.parentNode && mark.parentNode.normalize(); }); } function diffParagraphs(oldMd, newMd) { const oldPs = oldMd.split(/\n{2,}/); const newPs = newMd.split(/\n{2,}/); const m = oldPs.length, n = newPs.length; const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0)); for (let i = 1; i <= m; i++) for (let j = 1; j <= n; j++) dp[i][j] = oldPs[i-1] === newPs[j-1] ? dp[i-1][j-1] + 1 : Math.max(dp[i-1][j], dp[i][j-1]); const result = []; let i = m, j = n; while (i > 0 || j > 0) { if (i > 0 && j > 0 && oldPs[i-1] === newPs[j-1]) { result.unshift({ type: "equal", text: newPs[j-1] }); i--; j--; } else if (j > 0 && (i === 0 || dp[i][j-1] >= dp[i-1][j])) { result.unshift({ type: "add", text: newPs[j-1] }); j--; } else { result.unshift({ type: "remove", text: oldPs[i-1] }); i--; } } return result; } function StepDraft({ reportId, draft, setDraft, goBack, goNext, onRevisionComplete, loading }) { const [comments, setComments] = useStateDr([]); const [revisionHistory, setRevisionHistory] = useStateDr([]); // pendingQuote: {quote, docY, id} — set at mouseup, cleared on add/cancel const [pendingQuote, setPendingQuote] = useStateDr(null); const [commentInput, setCommentInput] = useStateDr(""); const [triggerBtnY, setTriggerBtnY] = useStateDr(null); // viewport Y for pill const [applyLoading, setApplyLoading] = useStateDr(false); const [applyDone, setApplyDone] = useStateDr(false); const [revising, setRevising] = useStateDr(false); const [revisionLog, setRevisionLog] = useStateDr([]); const [revisionPhase, setRevisionPhase] = useStateDr(null); // "classifying"|"researching"|"writing"|"done" const [revisionIteration, setRevisionIteration] = useStateDr(0); const [appliedIds, setAppliedIds] = useStateDr(new Set()); const [unmatchedIds, setUnmatchedIds] = useStateDr(new Set()); const [rejectedIds, setRejectedIds] = useStateDr(new Set()); const [writingRejectionReasons, setWritingRejectionReasons] = useStateDr({}); const [researchBlockedInfo, setResearchBlockedInfo] = useStateDr(null); const [revisionStuck, setRevisionStuck] = useStateDr(false); const [previousDraft, setPreviousDraft] = useStateDr(null); const [showDiff, setShowDiff] = useStateDr(false); const [reportPreview, setReportPreview] = useStateDr(false); const draftRef = useRefDr(null); const rightPanelRef = useRefDr(null); const logRef = useRefDr(null); const abortControllerRef = useRefDr(null); const lastRevisionDataRef = useRefDr(null); const panelTopY = useRefDr(0); // page-absolute top of right panel const inputRef = useRefDr(null); const renderedHtml = useMemoDr(() => renderWithIds(draft), [draft]); const diffSegments = useMemoDr(() => { if (!showDiff || !previousDraft) return null; return diffParagraphs(previousDraft, draft); }, [showDiff, previousDraft, draft]); // After HTML re-renders (draft changed after Apply), re-apply all highlights useEffectDr(() => { const container = draftRef.current; if (!container || !comments.length) return; // Remove any stale marks then re-apply (fallback text search for post-Apply) container.querySelectorAll(".inline-comment-mark").forEach(m => { m.replaceWith(document.createTextNode(m.textContent)); }); container.normalize(); comments.forEach(c => { if (!c.quote) return; // Walk text nodes looking for the first occurrence of the quote const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, null, false); let node; while ((node = walker.nextNode())) { const idx = node.nodeValue.indexOf(c.quote.slice(0, 40)); if (idx !== -1) { // Best-effort single-node highlight for post-Apply re-renders const r = document.createRange(); r.setStart(node, idx); r.setEnd(node, Math.min(idx + c.quote.length, node.nodeValue.length)); applyRangeHighlight(r, c.id); break; } } }); }, [renderedHtml]); // only runs when draft HTML changes (Apply) // Record panel top for positioning useEffectDr(() => { if (rightPanelRef.current) { panelTopY.current = rightPanelRef.current.getBoundingClientRect().top + window.scrollY; } }, [renderedHtml]); // Auto-scroll revision log to bottom as new lines arrive useEffectDr(() => { if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight; }, [revisionLog]); const handleMouseUp = useCallbackDr(() => { const sel = window.getSelection(); if (!sel || sel.isCollapsed || !sel.toString().trim()) return; const container = draftRef.current; if (!container) return; // Only react to selections inside the draft content if (!container.contains(sel.anchorNode) && !container.contains(sel.focusNode)) return; const range = sel.getRangeAt(0).cloneRange(); const rect = range.getBoundingClientRect(); const viewY = rect.top + rect.height / 2; const clampedY = Math.max(60, Math.min(viewY, window.innerHeight - 60)); const docY = rect.top + window.scrollY; // page-absolute // Assign ID now so we can immediately highlight const id = `c${Date.now()}`; // Capture quote text BEFORE DOM mutation (applyRangeHighlight replaces text nodes) const quoteText = range.toString().trim(); // Apply highlight immediately — works for multi-node selections const ok = applyRangeHighlight(range, id); sel.removeAllRanges(); setTriggerBtnY(clampedY); setPendingQuote({ quote: quoteText, docY, id }); setApplyDone(false); }, []); // Dismiss pill on click-away (not on panel or pill) useEffectDr(() => { const onDown = (e) => { if (e.target.closest(".comment-trigger-btn") || e.target.closest(".comment-right-panel")) return; if (!commentInput && pendingQuote) { // Cancel — remove the pending highlight removeHighlight(pendingQuote.id); setTriggerBtnY(null); setPendingQuote(null); } }; document.addEventListener("mousedown", onDown); return () => document.removeEventListener("mousedown", onDown); }, [commentInput, pendingQuote]); const openInput = () => { setCommentInput(""); setTriggerBtnY(null); // hide pill, show input in panel setTimeout(() => inputRef.current?.focus(), 50); }; const submitComment = () => { if (!commentInput.trim() || !pendingQuote) return; const newComment = { id: pendingQuote.id, quote: pendingQuote.quote, docY: pendingQuote.docY, body: commentInput.trim(), }; setComments(prev => [...prev, newComment].sort((a, b) => a.docY - b.docY)); setCommentInput(""); setPendingQuote(null); setApplyDone(false); }; const cancelInput = () => { if (pendingQuote) removeHighlight(pendingQuote.id); setCommentInput(""); setPendingQuote(null); setTriggerBtnY(null); }; const removeComment = (id) => { removeHighlight(id); setComments(prev => prev.filter(c => c.id !== id)); setUnmatchedIds(prev => { const s = new Set(prev); s.delete(id); return s; }); setRejectedIds(prev => { const s = new Set(prev); s.delete(id); return s; }); }; const applyChanges = async () => { if (!reportId) return; const allComments = comments.filter(c => !rejectedIds.has(c.id)); if (!allComments.length) return; // Pre-flight: validate quotes still exist in draft try { const vRes = await fetch(`/api/report/${reportId}/revise/validate`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ comments: allComments }), }); if (vRes.ok) { const vData = await vRes.json(); if (vData.unmatched && vData.unmatched.length > 0) { setUnmatchedIds(new Set(vData.unmatched)); rightPanelRef.current?.scrollTo({ top: 0, behavior: "smooth" }); } else { setUnmatchedIds(new Set()); } } } catch (_) { /* non-blocking */ } setRevising(true); setRevisionStuck(false); setRevisionLog([]); setRevisionPhase("classifying"); setApplyDone(false); setAppliedIds(new Set()); setResearchBlockedInfo(null); setShowDiff(false); // Stuck detection: fire if no data received for 5 minutes lastRevisionDataRef.current = Date.now(); const stuckIntervalId = setInterval(() => { if (lastRevisionDataRef.current && Date.now() - lastRevisionDataRef.current > 300000) { setRevisionStuck(true); } }, 30000); const controller = new AbortController(); abortControllerRef.current = controller; try { const res = await fetch(`/api/report/${reportId}/revise/stream`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ comments: allComments }), signal: controller.signal, }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const reader = res.body.getReader(); const decoder = new TextDecoder(); let buf = ""; let localRejectedIds = new Set(); let localWritingRejectedIds = new Set(); while (true) { const { done, value } = await reader.read(); if (done) break; lastRevisionDataRef.current = Date.now(); setRevisionStuck(false); buf += decoder.decode(value, { stream: true }); const parts = buf.split("\n\n"); buf = parts.pop(); for (const part of parts) { if (!part.startsWith("data:")) continue; let data; try { data = JSON.parse(part.slice(5).trim()); } catch { continue; } if (data.text !== undefined) { setRevisionLog(prev => [...prev, data.text]); const t = data.text; if (/classif/i.test(t)) setRevisionPhase("classifying"); else if (/research/i.test(t)) setRevisionPhase("researching"); else if (/writ/i.test(t)) setRevisionPhase("writing"); } if (data.applied_ids && data.applied_ids.length > 0) { setAppliedIds(prev => new Set([...prev, ...data.applied_ids])); } if (data.writing_rejected) { const { id, reason } = data.writing_rejected; console.log("[writing_rejected]", id, reason?.slice(0, 80)); localRejectedIds.add(id); localWritingRejectedIds.add(id); removeHighlight(id); setRejectedIds(prev => new Set([...prev, id])); setWritingRejectionReasons(prev => ({ ...prev, [id]: reason })); } if (data.rejected_ids && data.rejected_ids.length > 0) { data.rejected_ids.forEach(id => localRejectedIds.add(id)); setRejectedIds(prev => new Set([...prev, ...data.rejected_ids])); } if (data.research_blocked) { const { comment_ids, explanation } = data.research_blocked; (comment_ids || []).forEach(id => { localRejectedIds.add(id); removeHighlight(id); setRejectedIds(prev => new Set([...prev, id])); setWritingRejectionReasons(prev => ({ ...prev, [id]: explanation })); }); setResearchBlockedInfo(null); } if (data.done) { if (data.markdown && setDraft && data.markdown !== draft) { setPreviousDraft(draft); setDraft(data.markdown); } setComments(prev => prev.filter(c => localRejectedIds.has(c.id))); setAppliedIds(new Set()); setUnmatchedIds(new Set()); setApplyDone(data.markdown !== draft && localWritingRejectedIds.size === 0); setRevisionIteration(n => n + 1); setRevisionPhase("done"); setRevising(false); // Refresh revision history and re-export if already approved fetch(`/api/report/${reportId}/revisions`) .then(r => r.json()) .then(d => setRevisionHistory(d.revisions || [])) .catch(() => {}); onRevisionComplete?.(); } } } } catch (e) { if (e.name !== "AbortError") { console.error(e); setRevisionLog(prev => [...prev, `Error: ${e.message}`]); } setRevising(false); } finally { clearInterval(stuckIntervalId); lastRevisionDataRef.current = null; abortControllerRef.current = null; } }; // Position input card and comment cards relative to right panel top const CARD_HEIGHT_EST = 108; const CARD_GAP = 8; // Input card position: align with selection Y const inputCardTop = pendingQuote && triggerBtnY === null ? Math.max(28, pendingQuote.docY - panelTopY.current) : null; // Comment card positions: each aligned to its docY, pushed down if it would overlap const cardTops = (() => { let floor = 0; return comments.map(c => { const ideal = Math.max(0, c.docY - panelTopY.current); const top = Math.max(floor, ideal); floor = top + CARD_HEIGHT_EST + CARD_GAP; return top; }); })(); // Height of the comments container (enough to hold all cards) const containerHeight = comments.length ? (cardTops[cardTops.length - 1] || 0) + CARD_HEIGHT_EST + 48 : 0; if (!draft) { return (
No draft available
Complete the research phase first.
); } return (
{/* Fixed pill — sits at right edge at selection Y */} {triggerBtnY !== null && ( )}
{/* Document */}
{new Date().toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })}
{previousDraft && ( )}
{diffSegments ? (
{diffSegments.map((seg, idx) => (
))}
) : (
)}
Highlight text to request a change or ask for deeper research.
{/* Right panel — comment input + cards, absolutely positioned by docY */}
{/* Revision progress overlay — replaces comment list while agents work */} {revising ? (() => { const phases = [ { key: "classifying", label: "Classifying comments" }, { key: "researching", label: "Researching" }, { key: "writing", label: "Writing draft" }, ]; const phaseOrder = ["classifying", "researching", "writing", "done"]; const currentIdx = phaseOrder.indexOf(revisionPhase); return (
Revision in progress
{phases.map((p, i) => { const pIdx = phaseOrder.indexOf(p.key); const isDone = currentIdx > pIdx; const isActive = currentIdx === pIdx; return (
{isDone ? "✓ " : ""}{p.label}
); })}
{revisionStuck && (
⚠️ Revision appears stuck.
)}
{revisionLog.map((logLine, i) => (
{logLine || " "}
))}
); })() : (<> {revisionIteration > 0 && (
Revision {revisionIteration} applied
)} {applyDone && (
✓ Changes applied to draft.
)} {revisionHistory.length > 0 && (
Revision log ({revisionHistory.length})
{revisionHistory.map((rev, i) => (
{new Date(rev.ts).toLocaleString()}
{rev.comments.map((c, j) => (
{c.quote ? «{c.quote.slice(0, 60)}{c.quote.length > 60 ? "…" : ""}» — : null} {c.body}
))}
))}
)}
{comments.length === 0 ? "Inline comments" : `${comments.length} inline comment${comments.length !== 1 ? "s" : ""}`}
{comments.length === 0 && inputCardTop === null && (
Refine this draft
Highlight any text and click Add comment to request a change. You can ask for edits, rewrites, or deeper research on any section. The report structure is set at this stage, but the content within each section can be fully revised.
)} {/* New comment input — positioned at selection Y */} {inputCardTop !== null && pendingQuote && (
«{pendingQuote.quote.slice(0, 60)}{pendingQuote.quote.length > 60 ? "…" : ""}»