// Atlas · Step 1 — Brief const { useState, useRef, useEffect } = React; const stripHtml = (html) => (html || "").replace(/<[^>]+>/g, "").replace(/ /g, " ").trim(); function StepIntake({ state, setState, goNext, goHome, goForward, loading, error, readOnly }) { const { REPORT_TYPES, FORMATS, AUDIENCES } = window.ATLAS_DATA; const briefRef = useRef(null); const fileInputRef = useRef(null); const [urlInput, setUrlInput] = useState(""); const [urlLoading, setUrlLoading] = useState(false); const [attErrors, setAttErrors] = useState({}); // key: name|url → error string const [attUploading, setAttUploading] = useState(new Set()); const setAttError = (key, msg) => setAttErrors(prev => ({ ...prev, [key]: msg })); const clearAttError = (key) => setAttErrors(prev => { const n = { ...prev }; delete n[key]; return n; }); const attachments = state.attachments || []; const pdfCount = attachments.filter(a => a.type === "pdf").length; const imageCount = attachments.filter(a => a.type === "image").length; const urlCount = attachments.filter(a => a.type === "url").length; const pdfAtLimit = pdfCount >= 1; const imageAtLimit = imageCount >= 2; const urlAtLimit = urlCount >= 2; const handleFileUpload = async (files) => { for (const file of Array.from(files)) { const key = file.name; clearAttError(key); // Client-side size pre-check const isImage = /\.(jpg|jpeg|png|gif|webp)$/i.test(file.name); const cap = isImage ? 5 * 1024 * 1024 : 10 * 1024 * 1024; if (isImage && imageAtLimit) { setAttError(key, "Image limit reached (2 max). Remove one to add another."); continue; } if (!isImage && pdfAtLimit) { setAttError(key, "Only 1 PDF supported per report."); continue; } const allowedExt = /\.(pdf|jpg|jpeg|png|gif|webp)$/i.test(file.name); if (!allowedExt) { setAttError(key, "Unsupported file type. Accepted: PDF, JPG, PNG, GIF, WebP."); continue; } if (file.size > cap) { const limitMb = cap / (1024 * 1024); setAttError(key, isImage ? `Image too large — max ${limitMb} MB per image.` : `File too large — PDFs and documents are capped at ${limitMb} MB. Try splitting the file or pasting the key section into the brief.` ); continue; } setAttUploading(prev => new Set(prev).add(key)); try { const form = new FormData(); form.append("file", file); const resp = await fetch("/api/upload", { method: "POST", body: form }); if (!resp.ok) { const data = await resp.json().catch(() => ({})); setAttError(key, data.detail || "Upload failed. Please try again."); continue; } const data = await resp.json(); const att = { name: data.filename, path: data.path, type: data.type, ...(data.media_type ? { media_type: data.media_type } : {}), ...(data.truncated_pages ? { truncated_pages: true, total_pages: data.total_pages } : {}), }; setState(prev => ({ ...prev, attachments: [...(prev.attachments || []), att] })); } catch { setAttError(key, "Upload failed. Please try again."); } finally { setAttUploading(prev => { const n = new Set(prev); n.delete(key); return n; }); } } if (fileInputRef.current) fileInputRef.current.value = ""; }; const handleAddUrl = async () => { const url = urlInput.trim(); if (!url) return; if (urlAtLimit) return; const key = url; clearAttError(key); if (!/^https?:\/\//i.test(url)) { setAttError(key, "This URL can't be accessed — only public web addresses are supported."); return; } setUrlLoading(true); try { const resp = await fetch("/api/fetch-url", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url }), }); const data = await resp.json(); if (!data.ok) { setAttError(key, "This URL can't be accessed — only public web addresses are supported."); return; } const att = { name: new URL(url).hostname, url, type: "url", extracted_text: data.extracted_text }; setState(prev => ({ ...prev, attachments: [...(prev.attachments || []), att] })); setUrlInput(""); } catch { setAttError(key, "Couldn't retrieve this URL. Check that it's publicly accessible and try again."); } finally { setUrlLoading(false); } }; const removeAttachment = (idx) => { setState(prev => { const next = (prev.attachments || []).filter((_, i) => i !== idx); return { ...prev, attachments: next }; }); }; useEffect(() => { if (briefRef.current) briefRef.current.innerHTML = state.brief || ""; }, []); // run only on mount — restores brief when navigating back const briefPlain = stripHtml(state.brief); const wordCount = briefPlain.split(/\s+/).filter(Boolean).length; const valid = state.title.trim().length > 2 && state.reportType && state.format && briefPlain.length > 40 && wordCount <= 750 && state.audience && (state.reportType !== "position" || (state.anchorEntity || "").trim().length > 1); const execBriefCmd = (cmd) => { document.execCommand(cmd, false, null); briefRef.current?.focus(); if (briefRef.current) setState({ ...state, brief: briefRef.current.innerHTML }); }; const formatSize = (bytes) => { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; }; return (
{readOnly && (
This report has been researched. The brief is view-only.
)}
{/* — left column — */}
{/* Report type */}
Report type
{REPORT_TYPES.map((t) => ( ))}
{/* Report title */}

Report title

setState({ ...state, title: e.target.value })} placeholder="e.g. ASO & siRNA Technologies in Animal Health" style={{ width: "100%", marginBottom: 24, fontSize: 14, fontFamily: "var(--serif)", fontWeight: 500 }} /> {/* Anchor entity — only shown for Competitive Position */} {state.reportType === "position" && (

Your company or technology

Atlas will anchor the entire report on this entity and benchmark it against peers.

setState({ ...state, anchorEntity: e.target.value })} placeholder="e.g. Acme Bio — GalNAc-conjugated siRNA platform" style={{ width: "100%", fontSize: 14 }} />
)} {/* Brief composer */}

The brief

Write freely. Include questions to answer, parameters every entry should capture, references to mirror, and anything that's out of scope. Atlas will structure it in the next step.

Brief
{[ { cmd: "bold", label: "B", title: "Bold", style: { fontWeight: 700 } }, { cmd: "italic", label: "I", title: "Italic", style: { fontStyle: "italic" } }, { cmd: "underline", label: "U", title: "Underline", style: { textDecoration: "underline" } }, { cmd: "insertUnorderedList", label: "•", title: "Bullet list", style: {} }, { cmd: "insertOrderedList", label: "1.", title: "Numbered list", style: {} }, ].map(({ cmd, label, title, style }) => ( ))}
750 ? { color: "var(--red, #c0392b)", fontWeight: 600 } : wordCount > 500 ? { color: "var(--amber, #d97706)" } : {}}> {wordCount} / 750 words{wordCount > 750 ? " — too long" : wordCount > 500 ? " — getting long" : ""}
{ if (briefRef.current) { const html = briefRef.current.innerHTML; setState(prev => ({ ...prev, brief: html })); } }} />
Tip · the richer the brief, the more precise the agents can be. Atlas will pull out questions, parameters, and references automatically.
{/* Reference Material */}

Reference material optional

Attach PDFs, images, or URLs to give the scoping agent richer context. Content is read at brief-submission time and influences the questions and themes generated.

{!readOnly && (
{/* File + URL inputs */}
handleFileUpload(e.target.files)} /> { if (urlInput.trim()) clearAttError(urlInput.trim()); setUrlInput(e.target.value); }} onKeyDown={(e) => e.key === "Enter" && !urlAtLimit && handleAddUrl()} />
{/* Helper text */}
· PDFs: first 10 pages extracted as text · max 10 MB · 1 PDF max
· Images: uploaded as-is for visual context · max 5 MB · JPG, PNG, GIF, WebP · 2 images max
· URLs: public pages only · ~4,000 tokens extracted · 2 URLs max
· Tip: if your PDF is mostly charts or slides, export those pages as images and attach them. Image content is read visually, not as text.
{/* Per-type limit messages */} {(pdfAtLimit || imageAtLimit || urlAtLimit) && (
{pdfAtLimit &&
· Only 1 PDF supported per report.
} {imageAtLimit &&
· Image limit reached — remove one to upload another.
} {urlAtLimit &&
· URL limit reached — remove one to add another.
}
)} {/* Pending uploads indicator */} {attUploading.size > 0 && (
Uploading {attUploading.size} file{attUploading.size > 1 ? "s" : ""}…
)} {/* File-level errors for rejected uploads (never made it into state.attachments) */} {Object.entries(attErrors) .filter(([key]) => !(state.attachments || []).some(a => (a.url || a.name) === key) && key !== urlInput.trim()) .map(([key, msg]) => (
{key}: {msg}
)) } {/* URL-level errors (before attachment is added) */} {urlInput && attErrors[urlInput.trim()] && (
{attErrors[urlInput.trim()]}
)}
)} {/* Attachment list */} {(state.attachments || []).length > 0 && (
{(state.attachments || []).map((att, idx) => { const key = att.url || att.name; const err = attErrors[key]; return (
{att.type === "url" ? att.url : att.name} {att.type === "pdf" && att.truncated_pages && ( First 10 pages extracted )} {att.type === "image" && ( image )} {att.type === "url" && ( url )} {!readOnly && ( )}
{err && (
{err}
)}
); })}
)} {/* Parameters */}

Run parameters

Defaults that bound the search. Override at any step.

Audience
Output format
{FORMATS.map((f) => { const selected = state.format === f.id; return ( ); })}
Date range
{state.reportType !== "lit" && (
Geographic scope
)}
{/* — side rail — */}
A good brief includes

The questions the report must answer.

Parameters every entry should capture (e.g. modality, stage, sponsor).

Prior reports or papers to mirror in tone and depth.

Anything that's explicitly out of scope.

Human-in-the-loop

Atlas pauses for sign-off at scope, outline, and draft review before continuing.

{readOnly ? View only : <>Saves automatically as brief.md}
{!readOnly && goHome && } {readOnly && } {!readOnly && ( )}
); } window.StepIntake = StepIntake;