// 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 && (
)}
);
}
// ── 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 (
);
}
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();