// PapersVal — Analysis Detail / Review Workspace // Composition: TopBar + Sidebar + ContractViewer + ReviewPanel/FindingDetail const { useState, useEffect, useMemo, useRef, useCallback } = React; // ---------- Auto-growing textarea ---------- function AutoTextarea({ value, onChange, className, placeholder, autoFocus, rows = 3, ...props }) { const ref = useRef(null); const resize = () => { const el = ref.current; if (!el) return; el.style.height = "auto"; el.style.height = el.scrollHeight + "px"; }; useEffect(() => { resize(); }, [value]); return (
)} {acceptOpen && (

Accept this risk

)}
{!resolution && !noteOpen && !acceptOpen && ( <> {!isMissing && ( )} {isMissing && ( )} )} {resolution && ( )}
); } // ============================================================================ // Drawers: Upload + Playbook // ============================================================================ function UploadDrawer({ data, onClose, onAnalyzeFile, onAnalyzeText, onImportGoogleDoc, onSaveApiKey }) { const [file, setFile] = useState(null); const [text, setText] = useState(""); const [googleUrl, setGoogleUrl] = useState(""); const [apiKey, setApiKey] = useState(getApiKey()); const [busy, setBusy] = useState(false); const run = async (fn) => { setBusy(true); try { await fn(); onClose(); } finally { setBusy(false); } }; return ( <>

Analyze new contract

API key (optional)
setApiKey(event.target.value)} onBlur={() => onSaveApiKey(apiKey)} placeholder="Used only when this workspace requires X-API-Key" />
1 · Choose playbook
2 · Add contract
DOCX DOC PDF (text) MD TXT Scanned PDF Image
OR
Paste contract text
Import from Google Doc
setGoogleUrl(event.target.value)} placeholder="https://docs.google.com/document/d/..." />
Must be a publicly viewable link.
Matter (optional)
); } function ExportDrawer({ data, findingsState, onClose, onCopy }) { const [includeMatches, setIncludeMatches] = useState(false); const [includeMatter, setIncludeMatter] = useState(true); const [includeSuggested, setIncludeSuggested] = useState(true); const [includeNotes, setIncludeNotes] = useState(true); const [includeRedline, setIncludeRedline] = useState(true); const [audience, setAudience] = useState("internal"); const [format, setFormat] = useState("docx"); const sevCount = (sev) => data.findings.filter(f => f.severity === sev).length; const replacedCount = Object.values(findingsState).filter(s => s?.resolution === "replaced").length; const acceptedCount = Object.values(findingsState).filter(s => s?.resolution === "accepted").length; return ( <>

Export report

{data.contract.filename}
Audience
{audience === "client" ? (
Hides AI reasoning, confidence scores, model notes, and internal team comments. Keeps the issue, what was found, and the suggested wording — formatted for sharing with the counterparty or business stakeholder.
) : (
Full review pack with model reasoning, confidence, source quality, and team notes. Use when handing off to another lawyer on the matter.
)}
Format
Include
{format === "redline" && ( )}
Preview
Legal · M-2026-118
{format === "redline" ? "Acme MSA — Redlined" : "Contract Review Report"}
{data.contract.filename}
{audience === "internal" && includeMatter && (
{data.contract.counterparty} · {data.playbook.name} {data.playbook.version}
)}
{format === "docx" && ( <>
Summary
3 high-risk deviations
5 medium-risk deviations
2 required clauses not found
2 extras outside playbook
{(replacedCount + acceptedCount > 0) && (
{replacedCount} replaced, {acceptedCount} accepted by reviewer
)}
Issue 1 of 12 — Liability cap (High · Section 11.2)
"Supplier's total cumulative liability... twelve (12) months..."
"Notwithstanding the foregoing, the limitations in this Section 11 shall not apply to confidentiality, IP, data breach..."
{audience === "internal" && (
Why it matters: 12-month cap with no carve-outs; firm standard requires fees-paid + carve-outs.
)}
Issue 2 of 12 — Indemnification scope...
[...]
)} {format === "redline" && ( <>
11. Limitation of Liability
EXCEPT FOR LIABILITY ARISING FROM A PARTY'S GROSS NEGLIGENCE...
Supplier's total cumulative liability... shall not exceed the total fees paid by Customer to Supplier during the twelve (12) months immediately preceding the event giving rise to the claim. {" "} Notwithstanding the foregoing, the limitations in this Section 11 shall not apply to (a) either party's indemnification obligations; (b) breach of Section 12 (Confidentiality)...
[1 change · L. Voss · today]
12. Confidentiality
[3 sub-changes pending]
)} {format === "csv" && (
{`# id,severity,status,area,section,title,resolution
f-liability,high,does_not_match,Liability,11.2,Liability cap,replaced
f-termination,high,not_found,Termination,—,Customer termination for convenience,unresolved
f-indemnity,high,does_not_match,Indemnity,13.1,Indemnification scope,unresolved
f-payment,medium,partial,Payment,4.2,Late fee cap,unresolved
...`}
)}
); } function TemplatesDrawer({ data, onClose, onEditTemplate, onRunWithTemplate, onSetActive }) { const [running, setRunning] = useState(null); const handleRun = (t) => { setRunning(t.id); setTimeout(() => { setRunning(null); onRunWithTemplate(t.id); }, 1200); }; return ( <>

Templates

Current contract
{data.contract.filename}
M-2026-118 · Acme onboarding
Run this contract through {data.templates.length} templates
{data.templates.map(t => { const isRunning = running === t.id; return (
{t.active && }
{t.name} {t.active && Active}
{t.criteriaCount} criteria · {t.version} · {t.lastEdited}
{t.tags.map(tag => {tag})}
{isRunning ? (
Running…
) : t.active ? ( Currently viewing this analysis ) : t.result ? ( <> ) : ( )}
); })}
); } function PlaybookDrawer({ data, onClose, prefocusCriterionId, prefocusTemplateId, onSaveCriterion, onAddCriterion, onDeleteCriterion }) { const activeTemplate = (data.templates || []).find(t => t.id === (prefocusTemplateId || data.playbook.id)) || data.playbook; const criteriaSource = data.playbook.criteria || []; const [drafts, setDrafts] = useState({}); const [adding, setAdding] = useState(false); const [newName, setNewName] = useState(""); const [newRequirements, setNewRequirements] = useState(""); const [busyId, setBusyId] = useState(null); useEffect(() => { const next = {}; criteriaSource.forEach(c => { next[c.id] = c.preferred || c.requirements || ""; }); setDrafts(next); }, [data.playbook.id, criteriaSource.length]); // Header shows the focused template const headerName = activeTemplate.name; const headerCount = activeTemplate.criteriaCount || data.playbook.criteriaCount; const headerEdited = activeTemplate.lastEdited ? `Edited ${activeTemplate.lastEdited}${activeTemplate.author ? " by " + activeTemplate.author : ""}` : data.playbook.lastEdited; const grouped = useMemo(() => { if (criteriaSource.length > 0) { const byArea = {}; criteriaSource.forEach(criterion => { const area = criterion.area || criterion.title || "General"; if (!byArea[area]) byArea[area] = []; byArea[area].push(criterion); }); return Object.entries(byArea).map(([area, criteria]) => ({ area, criteria })); } // Mock: invent 4 criteria from clause areas for display const cas = data.playbook.clauseAreas; return cas.map((area, i) => ({ area, criteria: [ // For "Liability" — surface the liability cap criterion as the prefocused one ...(area === "Liability" ? [{ id: "crit-liability-cap", title: "Liability cap", severity: "high", preferred: "Cap = total fees paid under the Agreement, with carve-outs for confidentiality, IP, data, indemnity, gross negligence/willful misconduct.", fallback: "12-month look-back with confidentiality + IP + data carve-outs.", unacceptable: "Less than 12 months fees; no carve-outs.", edited: "Edited yesterday" }] : []), ...(area === "Term & Renewal" ? [{ id: "crit-term-conv", title: "Customer termination for convenience", severity: "high", preferred: "30 days notice, refund of prepaid unused fees.", fallback: "60 days notice acceptable for multi-year subscriptions.", unacceptable: "No customer termination right.", edited: "Edited 3 days ago" }, { id: "crit-auto-renew", title: "Auto-renewal opt-in", severity: "medium", preferred: "Opt-in renewal preferred; if not, 60-day opt-out window + 90-day supplier notice.", fallback: "30-day opt-out with 60-day supplier notice.", unacceptable: "Auto-renewal without notice from supplier." }] : []), ...(area === "Confidentiality" ? [{ id: "crit-conf", title: "Confidentiality survival", severity: "medium", preferred: "5 years post-termination; indefinite for trade secrets." }] : []), ...(area === "Indemnity" ? [{ id: "crit-indem", title: "Indemnification scope", severity: "high", preferred: "Supplier indemnity for IP infringement, data breach, confidentiality breach, gross negligence." }] : []) ] })); }, [data.playbook, criteriaSource]); const saveCriterion = async (criterion) => { setBusyId(criterion.id); try { await onSaveCriterion(criterion.id, drafts[criterion.id] || ""); } finally { setBusyId(null); } }; const deleteCriterion = async (criterion) => { setBusyId(criterion.id); try { await onDeleteCriterion(criterion.id); } finally { setBusyId(null); } }; const addCriterion = async () => { if (!newName.trim() || !newRequirements.trim()) return; setBusyId("new"); try { await onAddCriterion({ name: newName.trim(), requirements: newRequirements.trim(), }); setNewName(""); setNewRequirements(""); setAdding(false); } finally { setBusyId(null); } }; return ( <>

{headerName}

{headerCount} criteria · {headerEdited}
i
Editing here updates the playbook for future analyses. Past analyses (including this one) keep the playbook version used at the time of review.
{adding && (
Criterion name
setNewName(event.target.value)} placeholder="Payment terms" />
Standard position
setNewRequirements(event.target.value)} placeholder="Preferred position, fallback, unacceptable language..." />
)} {grouped.map(g => (
Clause area
{g.area}
{g.criteria.length || 0} criteria
{g.criteria.length === 0 ? (
Other criteria in this area are collapsed for clarity.
) : g.criteria.map(c => (
{c.title}
{c.edited &&
{c.edited}
}
{c.preferred && (
Preferred
{criteriaSource.length > 0 ? ( setDrafts(current => ({ ...current, [c.id]: event.target.value }))} /> ) : (
{c.preferred}
)}
)} {c.fallback && (
Fallback
{c.fallback}
)} {c.unacceptable && (
Unacceptable
{c.unacceptable}
)}
))}
))}
); } // ============================================================================ // Live API adapter: map Playbook backend shape into the PapersVal UI model. // This keeps the imported prototype reusable while the backend can stay stable. // ============================================================================ function getApiKey() { return localStorage.getItem("playbook_api_key") || ""; } async function api(path, options = {}) { const headers = new Headers(options.headers || {}); const apiKey = getApiKey(); if (apiKey) headers.set("X-API-Key", apiKey); const response = await fetch(path, { ...options, headers }); if (!response.ok) { throw new Error((await response.text()) || `Request failed: ${response.status}`); } if (response.status === 204) return null; const text = await response.text(); return text ? JSON.parse(text) : null; } function queryParam(name) { return new URLSearchParams(window.location.search).get(name); } function appLink(analysisId, findingId = null) { const url = new URL(window.location.href); url.searchParams.set("analysis", String(analysisId)); if (findingId) url.searchParams.set("check", String(findingId)); else url.searchParams.delete("check"); return `${url.pathname}${url.search}`; } function setRoute(analysisId, findingId = null) { window.history.replaceState({}, "", appLink(analysisId, findingId)); } function normalizeStatus(status) { return { meets: "match", partially_meets: "partial", not_meets: "does_not_match", missing: "not_found", not_applicable: "n_a", error: "needs_review", }[status] || status || "needs_review"; } function normalizeSeverity(severity, status) { if (status === "meets" || status === "not_applicable") return "low"; if (severity === "none") return "low"; if (["high", "medium", "low", "info"].includes(severity)) return severity; return "medium"; } function areaFromCheck(check) { if (check.criteria_name && !String(check.criteria_name).startsWith("Criterion ")) { return check.criteria_name; } if (check.essence_tag) { return String(check.essence_tag) .split("_") .map(part => part.charAt(0).toUpperCase() + part.slice(1)) .join(" "); } return `Criterion ${check.criteria_id}`; } function fieldFromRequirements(requirements, label) { if (!requirements) return null; const found = String(requirements).match(new RegExp(`^${label}:\\s*(.+)$`, "m")); return found ? found[1].trim() : null; } function buildSections(lines) { const safeLines = lines && lines.length ? lines : [ { line_no: 1, text: "No parsed contract text is available yet." } ]; const sections = []; let current = null; const startSection = (line, fallbackIndex) => { const heading = String(line.text || "").replace(/^#+\s*/, "").trim(); const numberMatch = heading.match(/^(\d+)[.)]?\s*(.*)$/); const number = numberMatch ? numberMatch[1] : String(fallbackIndex); const title = numberMatch ? (numberMatch[2] || heading) : heading; current = { id: `s-${line.line_no}`, number, title: title || `Section ${number}`, lineStart: line.line_no, lineEnd: line.line_no, paragraphs: [], paragraphLines: [], marks: [], }; sections.push(current); }; safeLines.forEach((line) => { const text = String(line.text || "").trim(); if (!text) return; const isHeading = text.startsWith("#") || /^(\d+)[.)]\s+\S/.test(text); if (!current || isHeading) { startSection(line, sections.length + 1); if (text.startsWith("#")) return; } current.paragraphs.push(text); current.paragraphLines.push(line.line_no); current.lineEnd = line.line_no; }); return sections.length ? sections : [{ id: "s-1", number: "1", title: "Contract", lineStart: 1, lineEnd: safeLines[safeLines.length - 1].line_no || 1, paragraphs: safeLines.map(line => line.text), paragraphLines: safeLines.map(line => line.line_no), marks: [], }]; } function sectionForLine(sections, lineNo) { return sections.find(section => lineNo >= section.lineStart && lineNo <= section.lineEnd) || sections[0]; } function addFindingMark(section, findingId, evidence) { if (!section || !evidence?.line_start) return; const paragraphIndex = Math.max( 0, section.paragraphLines.findIndex(lineNo => lineNo >= evidence.line_start) ); const text = section.paragraphs[paragraphIndex] || ""; if (!text) return; section.marks.push({ findingId, paragraph: paragraphIndex, start: 0, end: text.length, }); } function countsFromFindings(findings) { const counts = { total: findings.length, high: 0, medium: 0, low: 0, info: 0, extra: 0, match: 0, partial: 0, does_not_match: 0, not_found: 0, needs_review: 0, n_a: 0, }; findings.forEach(finding => { counts[finding.severity] = (counts[finding.severity] || 0) + 1; counts[finding.status] = (counts[finding.status] || 0) + 1; if (finding.status === "extra") counts.extra += 1; }); return counts; } function normalizeCriterion(item, index = 0) { const id = String(item.contract_type_criteria_id || item.id || item.criteria_id || `criterion-${index + 1}`); const title = item.name || item.title || `Criterion ${item.criteria_id || index + 1}`; const requirements = item.requirements || item.preferred || ""; return { id, contractTypeCriteriaId: item.contract_type_criteria_id || item.contractTypeCriteriaId || item.id || null, criteriaId: item.criteria_id || item.criteriaId || null, title, name: title, area: title, essenceTag: item.essence_tag || item.essenceTag || null, priority: item.priority || index + 1, severity: item.criticality || item.severity || "medium", preferred: requirements, requirements, }; } function refreshPlaybookShape(playbook, criteria) { const normalizedCriteria = (criteria || []).map(normalizeCriterion); const clauseAreas = [...new Set(normalizedCriteria.map(item => item.area).filter(Boolean))].slice(0, 12); return { ...playbook, criteria: normalizedCriteria, criteriaCount: normalizedCriteria.length || playbook.criteriaCount || 40, clauseAreas: clauseAreas.length ? clauseAreas : playbook.clauseAreas, }; } function transformAnalysisDetail(detail, context = {}) { const analysis = detail.analysis || {}; const sections = buildSections(detail.lines || []); const findings = (detail.checks || []).map((check, index) => { const evidence = (check.evidence || [])[0] || null; const section = evidence ? sectionForLine(sections, evidence.line_start) : null; const status = normalizeStatus(check.status); const severity = normalizeSeverity(check.severity, check.status); const area = areaFromCheck(check); const id = String(check.id || `${analysis.id || "analysis"}-${check.criteria_id || index + 1}`); const finding = { id, title: area, area, severity, status, sectionRef: section ? `Section ${section.number}` : "—", sectionId: section ? section.id : null, summary: check.reason || "No reason recorded.", firmPosition: fieldFromRequirements(check.requirements, "Preferred position") || fieldFromRequirements(check.requirements, "Preferred wording") || check.requirements || "No playbook requirement text is available.", foundQuote: evidence?.text || null, whyItMatters: check.reason || "Review against the active playbook position.", suggested: check.suggested_redline || "", candidates: evidence ? null : [area], }; if (section && status !== "match" && status !== "n_a") { addFindingMark(section, id, evidence); } return finding; }); const file = analysis.filename || "Contract analysis"; return { analysisId: analysis.id || context.analysisId || "demo", contract: { filename: file, party: file.replace(/\.[^.]+$/, ""), counterparty: analysis.contract_type || "Active playbook", effectiveDate: analysis.uploaded_at ? new Date(analysis.uploaded_at).toLocaleDateString() : "Demo", sections, }, findings, counts: countsFromFindings(findings), playbook: context.playbook || { id: String(analysis.contract_type_id || "active-playbook"), contractTypeId: analysis.contract_type_id || null, playbookId: null, name: analysis.contract_type || "Demo: Synthetic MSA Standard", version: "v1.0", versionId: "current", criteriaCount: findings.length, criteria: [], clauseAreas: [...new Set(findings.map(f => f.area))].slice(0, 8), lastEdited: "Loaded from current workspace", }, templates: context.templates || [], history: context.history || [], }; } function localDetailFromDemoDocument(documentInfo, criteria = []) { const criteriaById = {}; (criteria || []).forEach((criterion, index) => { const normalized = normalizeCriterion(criterion, index); criteriaById[String(criterion.contract_type_criteria_id)] = normalized; criteriaById[String(criterion.criteria_id)] = normalized; criteriaById[String(normalized.id)] = normalized; }); const lines = String(documentInfo.text || "") .split(/\r?\n/) .map(line => line.trim()) .filter(Boolean) .map((text, index) => ({ id: `local-${index + 1}`, line_no: index + 1, page_no: 1, text })); return { analysis: { id: documentInfo.document_id, filename: documentInfo.filename, uploaded_at: null, contract_type_id: "demo-local", contract_type: "Demo: Synthetic MSA Standard", batch_name: "Demo Synthetic MSA Fixtures", status: "parsed", result_count: documentInfo.results?.length || 0, status_counts: {}, severity_counts: {}, }, lines, checks: (documentInfo.results || []).map((result, index) => ({ id: `${documentInfo.document_id}-${result.criteria_id || index + 1}`, criteria_id: result.criteria_id, contract_type_criteria_id: result.contract_type_criteria_id || result.criteria_id, criteria_name: criteriaById[String(result.contract_type_criteria_id || result.criteria_id)]?.title || `Criterion ${result.criteria_id}`, essence_tag: criteriaById[String(result.contract_type_criteria_id || result.criteria_id)]?.essenceTag || null, requirements: criteriaById[String(result.contract_type_criteria_id || result.criteria_id)]?.requirements || null, status: result.status, reason: result.reason, deviation_type: result.deviation_type, severity: result.severity, suggested_redline: result.suggested_redline, evidence: [], source_state: result.status === "missing" ? "not_found" : "not_applicable", })), }; } function transformHistory(analyses) { return (analyses || []).map(item => { const statusCounts = item.status_counts || {}; const severityCounts = item.severity_counts || {}; return { id: String(item.id), name: item.filename || `Analysis ${item.id}`, matter: item.batch_name || item.contract_type || "Unfiled", status: item.result_count ? "ready" : item.status === "failed" ? "failed" : "comparing", high: severityCounts.high || 0, medium: severityCounts.medium || 0, missing: statusCounts.missing || 0, when: item.uploaded_at ? new Date(item.uploaded_at).toLocaleString() : "Demo", progress: item.result_count || 0, total: 40, }; }); } function playbookFromDemo(demo, criteria) { const normalizedCriteria = (criteria || []).map(normalizeCriterion); const clauseAreas = [...new Set(normalizedCriteria.map(item => item.area).filter(Boolean))].slice(0, 12); return { id: String(demo.contract_type_id || demo.playbook_id || "demo-playbook"), contractTypeId: demo.contract_type_id || null, playbookId: demo.playbook_id || null, name: "Demo: Synthetic MSA Standard", version: "v1.0", versionId: String(demo.playbook_id || "demo-playbook"), criteriaCount: normalizedCriteria.length || 40, criteria: normalizedCriteria, clauseAreas, lastEdited: "Seeded demo standard", }; } function templatesFor(playbook) { return [ { ...playbook, active: true, author: "Demo", lastEdited: playbook.lastEdited || "Current", tags: ["MSA", "Vendor paper", "Demo"], result: null, }, { id: "future-saas-vendor", name: "SaaS Vendor MSA", docType: "Master Services Agreement", version: "vNext", criteriaCount: 40, author: "Template library", lastEdited: "Sample", active: false, tags: ["SaaS", "Vendor paper"], result: null, }, ]; } function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } async function loadWorkspace(analysisId = queryParam("analysis"), options = {}) { const allowFallback = options.allowFallback !== false; try { const demo = await api("/api/demo/seed", { method: "POST" }); const criteria = demo.criteria || []; const playbook = playbookFromDemo(demo, criteria); const templates = templatesFor(playbook); if (demo.local_demo) { const docs = demo.incoming_documents || []; const history = transformHistory(docs.map(doc => ({ id: doc.document_id, filename: doc.filename, batch_name: "Demo Synthetic MSA Fixtures", result_count: doc.report_result_count, status_counts: {}, severity_counts: {}, }))); const selectedDoc = docs.find(doc => String(doc.document_id) === String(analysisId)) || docs[0]; const detail = localDetailFromDemoDocument(selectedDoc, criteria); return transformAnalysisDetail(detail, { playbook, templates, history }); } const analysesPayload = await api("/api/analyses/"); const history = transformHistory(analysesPayload.analyses || []); const selected = (analysisId && history.find(item => String(item.id) === String(analysisId))) || history[0]; const selectedId = selected?.id || demo.incoming_documents?.[0]?.document_id; const detail = await api(`/api/analyses/${selectedId}`); return transformAnalysisDetail(detail, { playbook, templates, history }); } catch (error) { if (!allowFallback) throw error; console.warn("Falling back to bundled PapersVal demo data", error); return window.DATA; } } async function waitForAnalysisReady(analysisId, options = {}) { const timeoutMs = options.timeoutMs || 5 * 60 * 1000; const intervalMs = options.intervalMs || 3000; const startedAt = Date.now(); let latest = null; while (Date.now() - startedAt < timeoutMs) { latest = await loadWorkspace(analysisId, { allowFallback: false }); const historyItem = (latest.history || []).find(item => String(item.id) === String(analysisId)); if ((latest.findings || []).length > 0 || historyItem?.status === "ready" || historyItem?.status === "failed") { return latest; } await sleep(intervalMs); } return latest; } // ============================================================================ // App // ============================================================================ function App() { const [data, setData] = useState(window.DATA); const [loadingLiveData, setLoadingLiveData] = useState(true); const findingsById = useMemo(() => { const m = {}; data.findings.forEach(f => { m[f.id] = f; }); return m; }, [data.findings]); // Default-select the highest-priority finding so the demo lands on the moment const [selectedFindingId, setSelectedFindingId] = useState("f-liability"); const [activeAnalysisId, setActiveAnalysisId] = useState(String(data.analysisId || "a-103")); const [filters, setFilters] = useState({ cats: new Set(), resolution: "unresolved", showMatches: false }); // Per-finding resolution state. Stored as { [findingId]: { resolution: 'accepted'|'replaced'|'noted', note?, replaced? } } const [findingsState, setFindingsState] = useState({}); const [drawerUpload, setDrawerUpload] = useState(false); const [drawerTemplates, setDrawerTemplates] = useState(false); const [drawerPlaybook, setDrawerPlaybook] = useState(null); // null | { templateId } const [drawerExport, setDrawerExport] = useState(false); const [activeTemplateId, setActiveTemplateId] = useState(data.playbook.id); const [sidebarOpen, setSidebarOpen] = useState(true); const [reanalyzing, setReanalyzing] = useState(false); const [viewMode, setViewMode] = useState("review"); // 'review' | 'final' const [toast, setToast] = useState(null); const activeTemplate = useMemo( () => data.templates.find(t => t.id === activeTemplateId) || data.templates[0], [data.templates, activeTemplateId] ); const viewerRef = useRef(null); useEffect(() => { let cancelled = false; loadWorkspace().then(nextData => { if (cancelled) return; const firstFinding = nextData.findings.find(f => f.status !== "match" && f.status !== "n_a") || nextData.findings[0]; const requestedCheck = queryParam("check"); setData(nextData); setActiveAnalysisId(String(nextData.analysisId || nextData.history?.[0]?.id || "demo")); setActiveTemplateId(nextData.playbook.id); setSelectedFindingId(requestedCheck || firstFinding?.id || null); setLoadingLiveData(false); if (nextData.analysisId) setRoute(nextData.analysisId, requestedCheck || firstFinding?.id || null); }); return () => { cancelled = true; }; }, []); const scrollToFinding = useCallback((id, smooth = true) => { const f = findingsById[id]; if (!f) return; const targetId = f.sectionId ? `sec-${f.sectionId}` : `miss-${f.id}`; const el = document.getElementById(targetId); const scroller = viewerRef.current; if (el && scroller) { const offset = el.offsetTop - 72; scroller.scrollTo({ top: offset, behavior: smooth ? "smooth" : "auto" }); } }, [findingsById]); const handleSelectFinding = useCallback((id) => { // Toggle: clicking the same finding closes it setSelectedFindingId(prev => { const next = prev === id ? null : id; setRoute(activeAnalysisId, next); return next; }); if (!id) return; setTimeout(() => scrollToFinding(id, true), 30); }, [activeAnalysisId, scrollToFinding]); // Initial mount: scroll to the default-selected finding so the demo lands on it. useEffect(() => { if (!selectedFindingId) return; let raf; const tick = () => { const f = findingsById[selectedFindingId]; const targetId = f.sectionId ? `sec-${f.sectionId}` : `miss-${f.id}`; const el = document.getElementById(targetId); const scroller = viewerRef.current; if (el && scroller && scroller.scrollHeight > scroller.clientHeight) { scroller.scrollTo({ top: el.offsetTop - 72, behavior: "auto" }); } else { raf = requestAnimationFrame(tick); } }; raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); }, []); // eslint-disable-line useEffect(() => { if (!selectedFindingId) return; const timer = setTimeout(() => scrollToFinding(selectedFindingId, false), 80); return () => clearTimeout(timer); }, [data.analysisId, selectedFindingId, scrollToFinding]); const handleJumpTo = useCallback((sectionId) => { const scroller = viewerRef.current; const el = document.getElementById(`sec-${sectionId}`); if (el && scroller) { const offset = el.offsetTop - 72; scroller.scrollTo({ top: offset, behavior: "smooth" }); } }, []); const showToast = useCallback((msg) => { setToast(msg); setTimeout(() => setToast(null), 1800); }, []); const applyAnalysisData = useCallback((nextData, requestedId = null) => { const firstFinding = nextData.findings.find(f => f.status !== "match" && f.status !== "n_a") || nextData.findings[0]; setData(nextData); setActiveAnalysisId(String(nextData.analysisId || requestedId)); setSelectedFindingId(firstFinding?.id || null); setRoute(nextData.analysisId || requestedId, firstFinding?.id || null); }, []); const handleSelectAnalysis = useCallback(async (id) => { showToast("Loading analysis…"); const nextData = await loadWorkspace(id); applyAnalysisData(nextData, id); }, [applyAnalysisData, showToast]); const setFiltersAndSync = useCallback((newFilters) => { setFilters(newFilters); // If a finding is selected and the new filter excludes it, close detail if (!selectedFindingId) return; const f = findingsById[selectedFindingId]; if (!f) return; const active = newFilters.cats; const passesCat = newFilters.showMatches ? (f.status === "match") : (f.status === "match" || f.status === "n_a") ? false : active.size === 0 ? true : (active.has("high") && f.severity === "high") || (active.has("medium") && f.severity === "medium") || (active.has("low") && f.severity === "low") || (active.has("not_found") && f.status === "not_found") || (active.has("extra") && f.status === "extra"); if (!passesCat) { setSelectedFindingId(null); } }, [selectedFindingId, findingsById]); const handleReanalyze = useCallback(() => { setReanalyzing(true); showToast(`Re-running analysis against ${activeTemplate.name}…`); setTimeout(() => { setReanalyzing(false); showToast("Analysis refreshed"); }, 1500); }, [activeTemplate, showToast]); // Filter predicate — used both to filter the right panel list AND dim/highlight in the contract const filterPredicate = useMemo(() => { const active = filters.cats; const resolutionFilter = filters.resolution; const showMatches = filters.showMatches; return (f) => { if (showMatches) return f.status === "match"; if (f.status === "match" || f.status === "n_a") return false; if (active.size > 0) { let catPass = false; if (active.has("high") && f.severity === "high") catPass = true; if (active.has("medium") && f.severity === "medium") catPass = true; if (active.has("low") && f.severity === "low") catPass = true; if (active.has("not_found") && f.status === "not_found") catPass = true; if (active.has("extra") && f.status === "extra") catPass = true; if (!catPass) return false; } const res = findingsState[f.id]?.resolution; if (resolutionFilter === "unresolved" && res) return false; if (resolutionFilter === "resolved" && !res) return false; return true; }; }, [filters, findingsState]); const filterActive = filters.cats.size > 0 || filters.resolution !== "unresolved" || filters.showMatches; const handleCopyLink = () => { const url = `${window.location.origin}${appLink(activeAnalysisId, selectedFindingId)}`; navigator.clipboard?.writeText(url).catch(() => {}); showToast(`Link copied — ${url}`); }; const handleExport = () => { setDrawerExport(true); }; const activeContractTypeId = () => { const value = data.playbook.contractTypeId || data.playbook.id; return /^\d+$/.test(String(value)) ? Number(value) : null; }; const activePlaybookId = () => { const value = data.playbook.playbookId || data.playbook.versionId; return /^\d+$/.test(String(value)) ? Number(value) : null; }; const setPlaybookCriteria = useCallback((criteria) => { setData(current => { const playbook = refreshPlaybookShape(current.playbook, criteria); const templates = current.templates.map(template => template.id === current.playbook.id ? { ...template, criteriaCount: playbook.criteriaCount, clauseAreas: playbook.clauseAreas } : template ); return { ...current, playbook, templates }; }); }, []); const handleSaveCriterion = useCallback(async (criterionId, requirements) => { const playbookId = activePlaybookId(); if (playbookId && /^\d+$/.test(String(criterionId))) { await api(`/api/playbooks/${playbookId}/criteria/${criterionId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ requirements }), }); } const nextCriteria = (data.playbook.criteria || []).map(criterion => String(criterion.id) === String(criterionId) ? { ...criterion, preferred: requirements, requirements } : criterion ); setPlaybookCriteria(nextCriteria); showToast("Criterion saved"); }, [data.playbook, setPlaybookCriteria, showToast]); const handleAddCriterion = useCallback(async ({ name, requirements }) => { const playbookId = activePlaybookId(); let created = { id: `local-${Date.now()}`, name, title: name, requirements, preferred: requirements, priority: (data.playbook.criteria || []).length + 1, }; if (playbookId) { created = await api(`/api/playbooks/${playbookId}/criteria`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name, requirements, criticality: "medium" }), }); } setPlaybookCriteria([...(data.playbook.criteria || []), created]); showToast("Criterion added"); }, [data.playbook, setPlaybookCriteria, showToast]); const handleDeleteCriterion = useCallback(async (criterionId) => { const playbookId = activePlaybookId(); if (playbookId && /^\d+$/.test(String(criterionId))) { await api(`/api/playbooks/${playbookId}/criteria/${criterionId}`, { method: "DELETE" }); } const nextCriteria = (data.playbook.criteria || []).filter(criterion => String(criterion.id) !== String(criterionId)); setPlaybookCriteria(nextCriteria); showToast("Criterion deleted"); }, [data.playbook, setPlaybookCriteria, showToast]); const openAnalysisAfterCreate = async (documentId, options = {}) => { const shouldQueueCompare = options.queueCompare !== false; if (shouldQueueCompare) { try { await api(`/api/documents/${documentId}/compare`, { method: "POST" }); showToast("Comparison queued"); } catch (error) { showToast("Uploaded; comparison is not queued yet"); } } else { showToast("File uploaded; processing queued"); } await handleSelectAnalysis(documentId); try { const readyData = await waitForAnalysisReady(documentId, { timeoutMs: options.timeoutMs || 6 * 60 * 1000, }); if (readyData) { applyAnalysisData(readyData, documentId); if ((readyData.findings || []).length > 0) showToast("Analysis ready"); } } catch (error) { console.warn("Timed out while waiting for analysis readiness", error); } }; const handleAnalyzeFile = async (file) => { const form = new FormData(); form.append("files", file); form.append("batch_name", `Incoming - ${file.name}`); const contractTypeId = activeContractTypeId(); if (contractTypeId) form.append("contract_type_id", String(contractTypeId)); const result = await api("/api/documents/upload/", { method: "POST", body: form }); const documentId = result.document_ids?.[0]; if (documentId) await openAnalysisAfterCreate(documentId, { queueCompare: false }); }; const handleAnalyzeText = async (text) => { const contractTypeId = activeContractTypeId(); const result = await api("/api/documents/paste", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text, filename: "pasted-contract.txt", batch_name: "Incoming - pasted text", contract_type_id: contractTypeId, }), }); if (result.document_id) await openAnalysisAfterCreate(result.document_id); }; const handleImportGoogleDoc = async (url) => { const contractTypeId = activeContractTypeId(); const result = await api("/api/documents/import-url", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url, filename: "google-doc-contract.txt", batch_name: "Incoming - Google Doc", contract_type_id: contractTypeId, }), }); if (result.document_id) await openAnalysisAfterCreate(result.document_id); }; const handleSaveApiKey = (value) => { const trimmed = value.trim(); if (trimmed) localStorage.setItem("playbook_api_key", trimmed); else localStorage.removeItem("playbook_api_key"); }; // Keyboard navigation: J/K to move through findings, Esc to close drawers useEffect(() => { const onKey = (e) => { if (drawerUpload || drawerTemplates || drawerPlaybook || drawerExport) { if (e.key === "Escape") { setDrawerUpload(false); setDrawerTemplates(false); setDrawerPlaybook(null); setDrawerExport(false); } return; } if (e.target && (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA")) return; const order = data.findings.filter(f => f.status !== "match" && f.status !== "n_a").map(f => f.id); const idx = order.indexOf(selectedFindingId); if (e.key === "j" || e.key === "ArrowDown") { e.preventDefault(); const next = order[Math.min(idx + 1, order.length - 1)]; if (next) handleSelectFinding(next); } if (e.key === "k" || e.key === "ArrowUp") { e.preventDefault(); const prev = order[Math.max(idx - 1, 0)]; if (prev) handleSelectFinding(prev); } if (e.key === "Escape") setSelectedFindingId(null); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [selectedFindingId, drawerUpload, drawerTemplates, drawerPlaybook, drawerExport, data.findings, handleSelectFinding]); const selectedFinding = selectedFindingId ? findingsById[selectedFindingId] : null; return (
setSidebarOpen(s => !s)} activeTemplate={activeTemplate} onOpenUpload={() => setDrawerUpload(true)} onOpenTemplates={() => setDrawerTemplates(true)} onReanalyze={handleReanalyze} onCopyLink={handleCopyLink} onExport={handleExport} />
{sidebarOpen && ( setDrawerUpload(true)} onOpenPlaybook={() => setDrawerTemplates(true)} /> )} { setFindingsState(s => ({ ...s, [id]: { resolution: "replaced" } })); showToast(findingsById[id].status === "not_found" ? "Suggestion inserted" : "Suggestion applied"); }} onAccept={(id) => { setFindingsState(s => ({ ...s, [id]: { resolution: "accepted" } })); showToast("Risk accepted"); }} onAddNote={(id, note) => { setFindingsState(s => ({ ...s, [id]: { resolution: "noted", note } })); showToast("Comment saved"); }} onUnresolve={(id) => { setFindingsState(s => { const n = {...s}; delete n[id]; return n; }); showToast("Reopened"); }} onOpenDetail={() => {}} viewMode={viewMode} onViewModeChange={setViewMode} viewerRef={viewerRef} />
{ setFindingsState(s => ({ ...s, [selectedFinding.id]: { resolution: "accepted", acceptReason: reason } })); showToast("Risk accepted"); }} onReplace={() => { setFindingsState(s => ({ ...s, [selectedFinding.id]: { resolution: "replaced" } })); showToast(selectedFinding.status === "not_found" ? "Clause inserted into contract" : "Suggestion applied"); }} onAddNote={(note) => { setFindingsState(s => ({ ...s, [selectedFinding.id]: { resolution: "noted", note } })); showToast("Note added"); }} onUnresolve={() => { setFindingsState(s => { const n = {...s}; delete n[selectedFinding.id]; return n; }); showToast("Reopened"); }} onCopyLink={handleCopyLink} onAddToExport={() => showToast("Added to export")} onMore={() => showToast("More options menu")} />
{drawerUpload && ( setDrawerUpload(false)} onAnalyzeFile={handleAnalyzeFile} onAnalyzeText={handleAnalyzeText} onImportGoogleDoc={handleImportGoogleDoc} onSaveApiKey={handleSaveApiKey} /> )} {drawerExport && setDrawerExport(false)} onCopy={showToast} />} {drawerTemplates && ( setDrawerTemplates(false)} onEditTemplate={(id) => { setDrawerTemplates(false); setDrawerPlaybook({ templateId: id }); }} onRunWithTemplate={(id) => { showToast(`Analysis complete — switched to template`); setActiveTemplateId(id); setDrawerTemplates(false); }} onSetActive={(id) => { showToast(`Switched active template`); setActiveTemplateId(id); setDrawerTemplates(false); }} /> )} {drawerPlaybook && ( setDrawerPlaybook(null)} onSaveCriterion={handleSaveCriterion} onAddCriterion={handleAddCriterion} onDeleteCriterion={handleDeleteCriterion} prefocusTemplateId={drawerPlaybook.templateId} prefocusCriterionId={ selectedFinding?.id === "f-liability" ? "crit-liability-cap" : selectedFinding?.id === "f-termination" ? "crit-term-conv" : selectedFinding?.id === "f-auto-renew" ? "crit-auto-renew" : null } /> )} {toast &&
{toast}
}
); } ReactDOM.createRoot(document.getElementById("root")).render();