// Atlas · App shell — wired to FastAPI backend const { useState: useStateApp, useEffect: useEffectApp, useRef: useRefApp } = React; class ErrorBoundary extends React.Component { constructor(props) { super(props); this.state = { error: null }; } static getDerivedStateFromError(e) { return { error: e }; } render() { if (this.state.error) { return (
Render error (check console for full trace):
{String(this.state.error)}
); } return this.props.children; } } // ── Theme helper ───────────────────────────────────────────────────────────── function applyTheme(t) { document.documentElement.setAttribute("data-theme", t === "dark" ? "dark" : "light"); localStorage.setItem("atlas-theme", t); } // ── User dropdown ───────────────────────────────────────────────────────────── function UserMenu({ user, onSettings, onSignOut }) { const [open, setOpen] = useStateApp(false); const ref = useRefApp(null); useEffectApp(() => { const handler = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }; document.addEventListener("mousedown", handler); return () => document.removeEventListener("mousedown", handler); }, []); const initials = user?.initials || "?"; const name = user?.name || "Account"; return (
{open && (
{name}
{user?.email}
)}
); } // ── Helpers ─────────────────────────────────────────────────────────────────── const stripHtml = (html) => (html || "").replace(/<[^>]+>/g, "").replace(/ /g, " ").trim(); // ── API helpers ─────────────────────────────────────────────────────────────── async function apiPost(path, body) { const res = await fetch(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); if (!res.ok) { const err = await res.json().catch(() => ({ detail: res.statusText })); throw new Error(err.detail || "Request failed"); } return res.json(); } async function apiGet(path) { const res = await fetch(path); if (!res.ok) { const err = await res.json().catch(() => ({ detail: res.statusText })); throw new Error(err.detail || "Request failed"); } return res.json(); } // ── App ─────────────────────────────────────────────────────────────────────── function App() { // Auth const [user, setUser] = useStateApp(null); const [authLoading, setAuthLoading] = useStateApp(true); // UI state const [showSettings, setShowSettings] = useStateApp(false); const [showUpgrade, setShowUpgrade] = useStateApp(false); const [theme, setThemeState] = useStateApp(() => localStorage.getItem("atlas-theme") || "light"); const setTheme = (t) => { setThemeState(t); applyTheme(t); }; // Apply theme on mount useEffectApp(() => { applyTheme(theme); }, []); // Step navigation: "home" | 0..5 const [step, setStep] = useStateApp("home"); // Loading / error for async transitions const [loading, setLoading] = useStateApp(false); const [loadingStatus, setLoadingStatus] = useStateApp(""); const [error, setError] = useStateApp(null); // Report state (populated progressively as the pipeline runs) const [reportId, setReportId] = useStateApp(null); const [parsedScope, setParsedScope] = useStateApp(null); const [outline, setOutline] = useStateApp([]); const [draft, setDraft] = useStateApp(null); const [exportFiles, setExportFiles] = useStateApp([]); const [reportResearched, setReportResearched] = useStateApp(false); const [refreshedFrom, setRefreshedFrom] = useStateApp(null); // Intake form state const [intake, setIntake] = useStateApp({ title: "", reportType: "tech", format: "pdf", brief: "", attachments: [], audience: "Internal R&D / science team", dateRange: "Last 10 years (2016–2026)", geo: "Global", anchorEntity: "", }); // Past reports list const [reports, setReports] = useStateApp([]); // ── Persist navigation state across reloads ───────────────────────────── useEffectApp(() => { if (step !== "home" && reportId) { try { sessionStorage.setItem("atlas_step", String(step)); sessionStorage.setItem("atlas_reportId", reportId); } catch {} } try { if (window.posthog && typeof posthog.capture === 'function') { posthog.capture('navigation', { step: step, report_id: reportId }); } } catch (err) { console.warn("PostHog capture failed:", err); } }, [step, reportId]); // ── Load report list on home ───────────────────────────────────────────── useEffectApp(() => { if (step === "home" && user) { apiGet("/api/reports") .then((d) => setReports(d.reports || [])) .catch(() => {}); } }, [step, user]); // ── Poll for background research completion ────────────────────────────── useEffectApp(() => { if (step !== "home" || !user) return; const hasResearching = reports.some((r) => r.isResearching); if (!hasResearching) return; const interval = setInterval(() => { apiGet("/api/reports") .then((d) => setReports(d.reports || [])) .catch(() => {}); }, 5000); return () => clearInterval(interval); }, [step, user, reports]); // ── Step transitions ───────────────────────────────────────────────────── // Step 0 → 1: submit brief, parse scope const handleSubmitBrief = async () => { setLoading(true); setLoadingStatus("Defining scope..."); setError(null); try { const result = await apiPost("/api/intake", { ...intake, brief: stripHtml(intake.brief), existingReportId: reportId || null }); // Check for report limit reached (apiPost throws on non-ok, but we handle 403 specifically here) // Actually apiPost currently throws, so we'll catch it below. // But let's check if we can handle the 403 more gracefully if needed. setReportId(result.reportId); setParsedScope(result.scope); setStep(1); } catch (e) { if (e.message === "report_limit_reached") { setShowUpgrade(true); } else { setError(e.message); } } finally { setLoading(false); setLoadingStatus(""); } }; // Step 2 → 3: generate outline (acceptedType passed when user accepts a type recommendation) const handleGenerateOutline = async (acceptedType) => { setLoading(true); setError(null); try { const body = acceptedType ? { reportType: acceptedType } : {}; if (acceptedType) setIntake(prev => ({ ...prev, reportType: acceptedType })); const result = await apiPost(`/api/outline/${reportId}`, body); setOutline(result.outline); setStep(2); } catch (e) { setError(e.message); } finally { setLoading(false); } }; // Step 3 → 4: approve outline, go to research console const handleApproveOutline = () => { setReportResearched(true); setStep(3); }; // Step 4 → 5: research + draft done (auto-generated in background), just fetch the draft const handleResearchDone = async () => { setLoading(true); setError(null); try { const draftData = await apiGet(`/api/report/${reportId}/draft`); setDraft(draftData.markdown); setStep(4); } catch (e) { setError(e.message); } finally { setLoading(false); } }; // Re-export after a post-approve revision const handleReExport = async () => { if (!reportId || !intake.format || !exportFiles.length) return; const formats = [intake.format, "md"].filter(Boolean); try { const result = await apiPost(`/api/export/${reportId}`, { formats }); setExportFiles(result.files || []); } catch (e) { // non-fatal — user can re-download on next approval } }; // Step 5 → 6: approve draft, export files const handleApproveDraft = async () => { setLoading(true); setError(null); try { // Mark approved first — this is what makes the report "Complete" await apiPost(`/api/report/${reportId}/approve`, {}); const formats = [intake.format, "md"].filter(Boolean); const result = await apiPost(`/api/export/${reportId}`, { formats }); setExportFiles(result.files || []); setStep(5); } catch (e) { setError(e.message); } finally { setLoading(false); } }; const goBack = () => setStep((s) => Math.max((typeof s === "number" ? s : 0) - 1, 0)); const goHome = () => { sessionStorage.removeItem("atlas_step"); sessionStorage.removeItem("atlas_reportId"); setStep("home"); setError(null); setReportResearched(false); setRefreshedFrom(null); fetch("/api/me").then(r => r.json()).then(d => { if (d.authenticated) setUser(d); }).catch(() => {}); }; const goNew = () => { sessionStorage.removeItem("atlas_step"); sessionStorage.removeItem("atlas_reportId"); setStep(0); setReportId(null); setParsedScope(null); setDraft(null); setOutline([]); setExportFiles([]); setReportResearched(false); setRefreshedFrom(null); setIntake({ title: "", reportType: "tech", format: "pdf", brief: "", attachments: [], audience: "Internal R&D / science team", dateRange: "Last 10 years (2016–2026)", geo: "Global", anchorEntity: "" }); setError(null); }; const goView = async (r) => { const id = r.id || r; const stage = typeof r.stage === "number" ? r.stage : null; setReportId(id); setRefreshedFrom(r.refreshedFrom || null); setDraft(null); setOutline([]); setParsedScope(null); try { if (stage === 5 || stage === 4) { const fetches = [ apiGet(`/api/report/${id}/draft`), apiGet(`/api/report/${id}/scope`).catch(() => null), apiGet(`/api/report/${id}/outline`).catch(() => null), ]; const [draftData, scopeData, outlineData] = await Promise.all(fetches); setDraft(draftData.markdown); if (scopeData) { setParsedScope(scopeData.scope); if (scopeData.intake) setIntake(prev => ({ ...prev, ...scopeData.intake, title: scopeData.scope?.title || prev.title, attachments: scopeData.scope?.attachment_names !== undefined ? scopeData.scope.attachment_names.map(n => n.startsWith("http") ? { type: "url", url: n } : { type: "file", name: n }) : prev.attachments })); } if (outlineData) { setOutline(outlineData.outline); } setReportResearched(true); setStep(stage); } else if (stage === 3 || r.isResearching) { const [scopeData, outlineData] = await Promise.all([ apiGet(`/api/report/${id}/scope`).catch(() => null), apiGet(`/api/report/${id}/outline`).catch(() => null), ]); if (scopeData) { setParsedScope(scopeData.scope); if (scopeData.intake) setIntake(prev => ({ ...prev, ...scopeData.intake, title: scopeData.scope?.title || prev.title, attachments: scopeData.scope?.attachment_names !== undefined ? scopeData.scope.attachment_names.map(n => n.startsWith("http") ? { type: "url", url: n } : { type: "file", name: n }) : prev.attachments })); } if (outlineData) { setOutline(outlineData.outline); } setReportResearched(true); setStep(3); } else if (stage === 2) { const [scopeData, outlineData] = await Promise.all([ apiGet(`/api/report/${id}/scope`), apiGet(`/api/report/${id}/outline`), ]); setParsedScope(scopeData.scope); if (scopeData.intake) setIntake(prev => ({ ...prev, ...scopeData.intake, title: scopeData.scope?.title || prev.title, attachments: scopeData.scope?.attachment_names !== undefined ? scopeData.scope.attachment_names.map(n => n.startsWith("http") ? { type: "url", url: n } : { type: "file", name: n }) : prev.attachments })); setOutline(outlineData.outline); setStep(2); } else if (stage === 1) { const scopeData = await apiGet(`/api/report/${id}/scope`); setParsedScope(scopeData.scope); if (scopeData.intake) setIntake(prev => ({ ...prev, ...scopeData.intake, title: scopeData.scope?.title || prev.title, attachments: scopeData.scope?.attachment_names !== undefined ? scopeData.scope.attachment_names.map(n => n.startsWith("http") ? { type: "url", url: n } : { type: "file", name: n }) : prev.attachments })); setStep(1); } else { // Fallback for bare string id (no stage info, e.g. after refresh) — load scope/outline then check for draft const [scopeData, outlineData] = await Promise.all([ apiGet(`/api/report/${id}/scope`).catch(() => null), apiGet(`/api/report/${id}/outline`).catch(() => null), ]); if (!scopeData && !outlineData) { // No report data at all — bail to home and clear stale session sessionStorage.removeItem("atlas_step"); sessionStorage.removeItem("atlas_reportId"); setStep("home"); return; } if (scopeData) { setParsedScope(scopeData.scope); if (scopeData.intake) setIntake(prev => ({ ...prev, ...scopeData.intake, title: scopeData.scope?.title || prev.title, attachments: scopeData.scope?.attachment_names !== undefined ? scopeData.scope.attachment_names.map(n => n.startsWith("http") ? { type: "url", url: n } : { type: "file", name: n }) : prev.attachments })); } if (outlineData) setOutline(outlineData.outline); setReportResearched(true); try { const d = await apiGet(`/api/report/${id}/draft`); setDraft(d.markdown); setStep(4); } catch { setStep(3); } } } catch (e) { setError(e.message); } }; // ── Auth check on mount (after goView is defined for session restore) ──── useEffectApp(() => { fetch("/api/me") .then((r) => r.json()) .then(async (data) => { // Initialize Sentry if DSN is provided if (data.sentry_dsn && window.Sentry) { window.Sentry.init({ dsn: data.sentry_dsn, integrations: [ window.Sentry.browserTracingIntegration(), window.Sentry.replayIntegration(), ], tracesSampleRate: 1.0, replaysSessionSampleRate: 0.1, replaysOnErrorSampleRate: 1.0, }); if (data.authenticated) { window.Sentry.setUser({ email: data.email, id: data.email, username: data.name }); } } if (data.authenticated) { setUser(data); try { if (window.posthog && typeof posthog.identify === 'function') { posthog.identify(data.email, { email: data.email, name: data.name, ...data.profile }); } } catch (err) { console.warn("PostHog identify failed:", err); } const savedTheme = data.profile?.theme; if (savedTheme) setTheme(savedTheme); // Restore navigation state from previous session const savedReportId = sessionStorage.getItem("atlas_reportId"); const savedStep = parseInt(sessionStorage.getItem("atlas_step"), 10); if (savedReportId) { try { const result = await apiGet("/api/reports"); const match = (result.reports || []).find((r) => r.id === savedReportId); if (match) { // Use the saved UI step rather than server stage so the user // lands back on exactly the page they were on before refresh. const stageOverride = !isNaN(savedStep) ? savedStep : match.stage; await goView({ ...match, stage: stageOverride }); if (match.stage >= 3) setReportResearched(true); } else { sessionStorage.removeItem("atlas_step"); sessionStorage.removeItem("atlas_reportId"); } } catch {} } } }) .catch(() => {}) .finally(() => setAuthLoading(false)); }, []); // ── Render ─────────────────────────────────────────────────────────────── const STEPS = [ { num: "01", lbl: "Brief" }, { num: "02", lbl: "Scope" }, { num: "03", lbl: "Outline" }, { num: "04", lbl: "Research" }, { num: "05", lbl: "Draft" }, { num: "06", lbl: "Delivery" }, ]; const stepHeadlines = [ { eye: "STEP 01 · BRIEF", h: "Tell Atlas what to investigate.", sub: "The richer the brief, the sharper the report." }, { eye: "STEP 02 · SCOPE", h: intake.title || "Atlas parsed your brief.", sub: "Confirm the structured scope before research begins." }, { eye: "STEP 03 · OUTLINE", h: "Approve the report's blueprint.", sub: "Chapters drafted by the Outline Agent." }, { eye: "STEP 04 · RESEARCH", h: "The pipeline is running.", sub: "Agents working in parallel. Feel free to step away." }, { eye: "STEP 05 · DRAFT", h: "Review the working draft.", sub: "Comment any section; the Writing Agent replies in-place." }, { eye: "STEP 06 · DELIVERY", h: "Your report is ready.", sub: "Generated in every format the brief requested." }, ]; const homeHeadline = { eye: "REPORTS", h: "Reports.", sub: "Every landscape, literature review, and competitive analysis you've run." }; const headline = step === "home" ? homeHeadline : stepHeadlines[step]; const userName = user ? user.name : "Guest"; const renderStep = () => { if (step === "home") { return ( setShowUpgrade(true)} /> ); } switch (step) { case 0: return ( setStep(1)} loading={loading} error={error} readOnly={reportResearched && !!reportId} /> ); case 1: return ( setStep(2)} loading={loading} error={error} readOnly={reportResearched && !!reportId} /> ); case 2: return ( setStep(3)} readOnly={reportResearched && !!reportId} /> ); case 3: return ( ); case 4: return ( ); case 5: return ( ); default: return null; } }; // Loading spinner overlay const LoadingOverlay = () => (
{loadingStatus || "Atlas is thinking…"}
); // Auth gate if (authLoading) { return (
Loading…
); } if (!user) { return ; } if (!user.profileComplete) { return ( setUser({ ...user, ...updatedUser, profileComplete: true })} /> ); } return (
{loading && } {/* Impersonation Banner */} {user.impersonated && (
● ADMIN MODE You are impersonating {user.name} ({user.email})
)} {/* Topbar */}
Atlas
{step !== "home" && reportId && ( <> {reportId} )} setShowSettings(true)} onSignOut={() => window.location.href = "/auth/logout"} />
{/* Upgrade / report-limit modal */} {showUpgrade && ( setShowUpgrade(false)} /> )} {/* Settings modal */} {showSettings && ( { setTheme(t); }} onClose={() => setShowSettings(false)} onSave={(form) => { setUser((u) => ({ ...u, name: form.name, profile: { ...u.profile, ...form } })); if (form.theme) setTheme(form.theme); }} /> )} {/* Headline */} {headline && (
{headline.eye}

{headline.h}

{headline.sub}
{step !== "home" && reportId && (
{reportId}
Owner · {userName}
Started · {new Date().toLocaleDateString("en-US", { month: "short", day: "numeric" })}
)}
)} {/* Progress strip */} {step !== "home" && (
{STEPS.map((s, i) => { const cls = i < step ? "done" : i === step ? "active" : "locked"; return (
i < step && setStep(i)} > {s.num} {s.lbl} {i < step && ( )}
); })}
)} {/* Error banner */} {error && (
Error: {error}
)} {/* Canvas */}
{renderStep()}
); } ReactDOM.createRoot(document.getElementById("root")).render();