// Atlas · Explore — conversational pre-brief assistant (Phase 1) const { useState: useStateExplore, useEffect: useEffectExplore, useRef: useRefExplore } = React; // Read live (not once at load) so toggling the OS setting mid-session applies // to the next render without a reload. const exploreReducedMotion = () => typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches; // Long replies animate only their first words; the rest render instantly so a // wall of text never stacks hundreds of simultaneous animation layers. const EXPLORE_REVEAL_WORD_CAP = 150; // Minimal rich-text rendering for assistant replies: **bold**, "- " bullets, // and [n] / [n,m] citation markers linked to the numbered source list. // When `reveal` is set ({ i, step }), plain words and inline tokens are wrapped // in staggered .explore-word spans to animate the reply in word-by-word. function renderExploreInline(text, sources, reveal) { const parts = String(text).split(/(\*\*[^*]+\*\*|\[\d+(?:\s*,\s*\d+)*\])/g); const wrap = (node, key) => { if (!reveal) return node; // Words past the cap share the final beat: they fade in together right // after the head finishes, so the reveal stays strictly top-to-bottom. const delay = Math.min(reveal.i, EXPLORE_REVEAL_WORD_CAP) * reveal.step; reveal.i++; return {node}; }; return parts.map((part, i) => { if (/^\*\*[^*]+\*\*$/.test(part)) return wrap({part.slice(2, -2)}, i); const cite = /^\[(\d+(?:\s*,\s*\d+)*)\]$/.exec(part); if (cite) { const nums = cite[1].split(",").map((n) => parseInt(n.trim(), 10)); if (nums.every((n) => sources[n - 1] && /^https?:\/\//i.test(sources[n - 1].url || ""))) { return wrap( {nums.map((n, j) => ( {n} ))} , i ); } } if (!reveal) return part; // Split plain text into words, keeping whitespace outside the animated spans. return part.split(/(\s+)/).map((w, j) => /^\s*$/.test(w) ? w : wrap(w, `${i}-${j}`) ); }); } function renderExploreRich(text, sources, reveal) { return String(text).split(/\n{2,}/).map((block, i) => { const lines = block.split("\n").filter((l) => l.trim() !== ""); if (lines.length > 0 && lines.every((l) => l.trim().startsWith("- "))) { return ( ); } return

{renderExploreInline(block, sources, reveal)}

; }); } function exploreWordCount(text) { return String(text).split(/\s+/).filter(Boolean).length; } // Relative-time label; ts may be epoch seconds, epoch ms, or an ISO string. function exploreRelTime(ts) { if (ts == null) return null; let ms = null; if (typeof ts === "number") ms = ts < 1e12 ? ts * 1000 : ts; else { const p = Date.parse(ts); if (!isNaN(p)) ms = p; } if (ms == null) return null; const diff = Math.max(0, Date.now() - ms); const min = Math.floor(diff / 60000); if (min < 1) return "just now"; if (min < 60) return `${min}m ago`; const hr = Math.floor(min / 60); if (hr < 24) return `${hr}h ago`; const d = Math.floor(hr / 24); return d === 1 ? "yesterday" : `${d}d ago`; } const EXPLORE_STARTERS = [ "Map the GLP-1 competitive landscape", "What's new in CAR-T manufacturing?", "Key players in AI drug discovery", "Emerging biomarkers in oncology trials", ]; // Hard-coded opener rendered as the first assistant bubble in every thread. // Display-only: never added to `messages` or sent to the server, so turn // counts, the Convert gate, and the to-brief transcript ignore it. // The exchange limit is left out: the header and the turns counter already show it. const exploreIntro = () => [ "Hi, I'm Atlas. Explore is a quick chat to help you get to know a topic before you start a report. Ask me about a technology, market, company, or research question. As you type your first question, I'll suggest sharper ways to ask it. I'll answer with sources, then ask a question or two to focus what your report should cover.", "When you're ready, click **Convert to brief**. If you already know what you want researched, skip straight to **Manual brief**. Nothing in this chat is verified. Verified, fully sourced findings come only from the report.", ].join("\n\n"); const EXPLORE_UNVERIFIED_COPY = "This is an unverified conversation to help you explore the space. For verified, sourced findings, run a report."; // Server error slugs → user-facing copy. Anything else falls through to the // server's own (already human-readable) detail string, then to a generic line. const EXPLORE_ERROR_COPY = { empty_conversation: "Send at least one message before converting this exploration into a brief.", report_already_created: "This exploration already became a report, so its brief can't be regenerated.", explore_daily_limit: "You've reached today's Explore limit. Come back tomorrow to keep exploring.", explore_thread_limit: "You've reached the maximum number of explorations for this account. Delete an old one to start another.", exploration_deleted: "This exploration was deleted, so Atlas can't continue it.", turn_limit_reached: "This exploration has used all of its exchanges.", exploration_locked: "This exploration is locked — its brief has already been created.", report_limit_reached: "You've hit your report limit, so Explore is closed. Upgrade to keep exploring.", }; function exploreErrorMessage(data, status, fallback) { const detail = data && data.detail; if (typeof detail === "string" && EXPLORE_ERROR_COPY[detail]) return EXPLORE_ERROR_COPY[detail]; if (status === 410) return EXPLORE_ERROR_COPY.exploration_deleted; if (typeof detail === "string" && detail) return detail; return fallback || "Something went wrong. Please try again."; } window.exploreErrorMessage = exploreErrorMessage; // fetch wrapper that routes 401s through the app-wide session-expired flow // (same contract as apiGet/apiPost in app.jsx) instead of surfacing a raw // auth string. Everything else passes through untouched. async function exploreFetch(url, opts) { const res = await fetch(url, opts); if (res.status === 401) { if (window.handleGlobalSessionExpired) window.handleGlobalSessionExpired(); throw new Error("Session expired. Please log in again."); } return res; } window.exploreFetch = exploreFetch; // Filler words that carry no topical signal for the first-turn suggestion // dropdown: a draft made only of these ("I want to learn more about") must // never trigger a suggestion fetch, no matter how long it is. const EXPLORE_SUGGEST_STOPWORDS = new Set([ "i", "im", "i'm", "id", "i'd", "we", "my", "our", "me", "you", "your", "a", "an", "the", "this", "that", "these", "those", "some", "any", "want", "wants", "wanted", "would", "like", "love", "need", "needs", "learn", "learning", "know", "knowing", "understand", "understanding", "explore", "exploring", "research", "researching", "find", "finding", "look", "looking", "tell", "show", "give", "help", "curious", "interested", "more", "less", "little", "bit", "lot", "really", "very", "just", "kind", "about", "into", "on", "in", "of", "for", "with", "and", "or", "but", "to", "at", "by", "from", "as", "is", "are", "was", "were", "be", "been", "do", "does", "can", "could", "should", "will", "what", "whats", "what's", "which", "who", "how", "why", "when", "where", "there", "out", "up", "please", "hey", "hi", "hello", "thanks", "so", "if", "it", "its", "it's", ]); // True once the draft contains real topical content: at least one substantive // (non-filler) word of 4+ characters, or two shorter ones. function exploreSuggestHasSubstance(draft) { const tokens = draft.toLowerCase().split(/[^a-z0-9'&+-]+/i).filter(Boolean); const substantive = tokens.filter((t) => !EXPLORE_SUGGEST_STOPWORDS.has(t)); return substantive.some((t) => t.length >= 4) || substantive.length >= 2; } function ExploreChat({ threadId, onThreadCreated, onConvert, onOpenReport, openReportLabel, limitReached, onLimitReached, onUpgrade }) { // Errors render only in the in-card line (.explore-error), never the // app-global top banner — one message, shown where the user is looking. const [error, setError] = useStateExplore(null); const [messages, setMessages] = useStateExplore([]); const [input, setInput] = useStateExplore(""); const [sending, setSending] = useStateExplore(false); const [converting, setConverting] = useStateExplore(false); const [hydrating, setHydrating] = useStateExplore(!!threadId); const [turnsRemaining, setTurnsRemaining] = useStateExplore(5); const [maxTurns, setMaxTurns] = useStateExplore(5); const [convertedReportId, setConvertedReportId] = useStateExplore(null); const [locked, setLocked] = useStateExplore(false); // Server said report_limit_reached even though the app's usage count hadn't // caught up yet; treated the same as the limitReached prop. const [limitHit, setLimitHit] = useStateExplore(false); const [showJump, setShowJump] = useStateExplore(false); const [typingState, setTypingState] = useStateExplore("hidden"); // hidden | show | leaving const scrollRef = useRefExplore(null); const taRef = useRefExplore(null); const atBottomRef = useRefExplore(true); const justSentRef = useRefExplore(false); const revealTimerRef = useRefExplore(null); // Bumped whenever the threadId prop changes (reset or thread switch); an // in-flight send from the previous conversation checks it on resolution and // discards its reply instead of appending to the fresh one. const sendGenRef = useRefExplore(0); // Set just before onThreadCreated hands the parent a freshly minted thread // id: the resulting threadId prop change must not re-hydrate — the // conversation is already live, and refetching would replace the first // reply's word-reveal with instantly-rendered static text. const selfCreatedThreadRef = useRefExplore(null); const steerBtnRef = useRefExplore(null); const composerReplacedRef = useRefExplore(false); // First-turn suggestion dropdown: LLM-generated sharper prompts for the // draft the user is typing before any message has been sent. const [suggestions, setSuggestions] = useStateExplore([]); const [suggestOpen, setSuggestOpen] = useStateExplore(false); const [suggestActive, setSuggestActive] = useStateExplore(-1); const suggestGenRef = useRefExplore(0); const suggestTimerRef = useRefExplore(null); const suggestCacheRef = useRefExplore(new Map()); // One-shot: picking a suggestion sets the input to a cached draft, which // must not immediately re-open the dropdown it was picked from. const suggestSuppressRef = useRefExplore(false); const closeSuggest = () => { suggestGenRef.current += 1; if (suggestTimerRef.current) clearTimeout(suggestTimerRef.current); setSuggestOpen(false); setSuggestActive(-1); }; // Resume an existing thread. When threadId goes back to null (Back button to // #/explore after a thread was created), reset to a clean new-conversation // state instead of leaving the orphaned transcript on screen. Deliberately // not a key= remount: onThreadCreated fires right after the first reply, and // a remount there would visibly blank the conversation mid-flow. useEffectExplore(() => { if (threadId && threadId === selfCreatedThreadRef.current) { selfCreatedThreadRef.current = null; return; } // Any other transition (reset, switching threads from home) is a genuine // context change — a stale self-created id must not suppress hydration. selfCreatedThreadRef.current = null; sendGenRef.current += 1; closeSuggest(); // Any in-flight send belongs to the previous thread; its gen-guarded // finally won't clear `sending` anymore, so clear it here. setSending(false); if (!threadId) { setMessages([]); setLocked(false); setConvertedReportId(null); setTurnsRemaining(maxTurns); setHydrating(false); setInput(""); setShowJump(false); setError && setError(null); return; } let cancelled = false; setHydrating(true); exploreFetch(`/api/explore/${threadId}`) .then((r) => (r.ok ? r.json() : Promise.reject(new Error(r.status === 404 ? "This exploration is no longer available." : "Couldn't load this exploration.")))) .then((d) => { if (cancelled) return; // History renders without entrance animation (noAnim) so it doesn't cascade. setMessages((d.messages || []).map((m) => ({ ...m, noAnim: true }))); setTurnsRemaining(d.turnsRemaining ?? 0); setMaxTurns((d.userTurns ?? 0) + (d.turnsRemaining ?? 0)); setConvertedReportId(d.convertedReportId || null); setLocked(!!d.locked); }) .catch((e) => { if (!cancelled) setError && setError(e.message); }) .finally(() => { if (!cancelled) setHydrating(false); }); return () => { cancelled = true; }; }, [threadId]); // Autofocus the composer on a fresh exploration. useEffectExplore(() => { if (!threadId && taRef.current) taRef.current.focus(); }, []); useEffectExplore(() => () => { if (revealTimerRef.current) clearTimeout(revealTimerRef.current); }, []); // First-turn suggestions: fetch 3-5 sharper research prompts once the draft // carries real topical signal (substance gate) — instantly on a word // boundary after a substantive word, otherwise after a short typing pause. // Pure filler ("I want to learn more about…") stays completely silent. // Silent on every failure too — this is ambient help, not a feature the // user asked for, so it must never produce an error state. useEffectExplore(() => { const draft = input.trim(); // `!error` keeps the dropdown down after a failed send: the restored // draft would otherwise re-summon suggestions right under the error line, // inviting a click that would just fail again. const eligible = !threadId && messages.length === 0 && !sending && !hydrating && !error && draft.length >= 6 && exploreSuggestHasSubstance(draft); if (suggestTimerRef.current) clearTimeout(suggestTimerRef.current); if (!eligible) { suggestGenRef.current += 1; setSuggestOpen(false); setSuggestActive(-1); return; } if (suggestSuppressRef.current) { suggestSuppressRef.current = false; return; } suggestGenRef.current += 1; const gen = suggestGenRef.current; const cached = suggestCacheRef.current.get(draft); if (cached) { setSuggestions(cached); setSuggestActive(-1); setSuggestOpen(cached.length > 0); return; } // Word boundary (space/punctuation just typed) after substantive content: // fire immediately. Mid-word: wait for a short pause. const delay = /[\s.,;:!?]$/.test(input) ? 0 : 120; suggestTimerRef.current = setTimeout(() => { exploreFetch("/api/explore/suggest", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ draft }), }) .then((r) => (r.ok ? r.json() : { suggestions: [] })) .then((d) => { if (gen !== suggestGenRef.current) return; const list = Array.isArray(d.suggestions) ? d.suggestions.filter((s) => typeof s === "string" && s.trim()) : []; suggestCacheRef.current.set(draft, list); if (suggestCacheRef.current.size > 10) { suggestCacheRef.current.delete(suggestCacheRef.current.keys().next().value); } setSuggestions(list); setSuggestActive(-1); setSuggestOpen(list.length > 0); }) .catch(() => {}); }, delay); return () => { if (suggestTimerRef.current) clearTimeout(suggestTimerRef.current); }; }, [input, threadId, messages.length, sending, hydrating, error]); const pickSuggestion = (s) => { suggestSuppressRef.current = true; suggestCacheRef.current.set(s.trim(), suggestions); closeSuggest(); setInput(s); setTimeout(() => { if (taRef.current) { taRef.current.focus(); autosize(); } }, 0); }; // Crossfade the thinking indicator out instead of removing it abruptly: // when sending ends, keep it mounted briefly with a fade-out class. useEffectExplore(() => { if (sending) { setTypingState("show"); return; } setTypingState((s) => (s === "show" && !exploreReducedMotion() ? "leaving" : "hidden")); }, [sending]); useEffectExplore(() => { if (typingState !== "leaving") return; const t = setTimeout(() => setTypingState("hidden"), 240); return () => clearTimeout(t); }, [typingState]); // Stick-to-bottom: only auto-scroll when the user was already at the bottom // (or just sent a message); otherwise surface the "jump to latest" pill. const onScroll = () => { const el = scrollRef.current; if (!el) return; const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40; atBottomRef.current = atBottom; if (atBottom) setShowJump(false); }; const scrollToBottom = (smooth) => { const el = scrollRef.current; if (!el) return; el.scrollTo({ top: el.scrollHeight, behavior: smooth && !exploreReducedMotion() ? "smooth" : "auto" }); setShowJump(false); }; useEffectExplore(() => { if (hydrating) return; if (atBottomRef.current || justSentRef.current) { justSentRef.current = false; scrollToBottom(messages.length > 0); } else if (messages.length > 0) { setShowJump(true); } }, [messages, sending, hydrating, typingState]); const hasAssistantReply = messages.some((m) => m.role === "assistant"); const converted = !!convertedReportId; // Out of reports: the thread stays readable but nothing can be sent or // converted. A converted thread keeps its link to the report it became. const blocked = !converted && (limitReached || limitHit); const done = converted || locked || blocked; const atCap = !done && turnsRemaining <= 0 && messages.length > 0; // When the composer is replaced by a steer banner (converted / locked / // at-cap), move focus to the banner's button so keyboard and screen-reader // users aren't stranded on a textarea that no longer exists. useEffectExplore(() => { const replaced = done || atCap; if (replaced && !composerReplacedRef.current && steerBtnRef.current) steerBtnRef.current.focus(); composerReplacedRef.current = replaced; }, [done, atCap]); const autosize = () => { const el = taRef.current; if (!el) return; el.style.height = "auto"; el.style.height = Math.min(el.scrollHeight, 132) + "px"; }; const send = async (override) => { const text = (override != null ? override : input).trim(); if (!text || sending || atCap || done || hydrating) return; const gen = sendGenRef.current; const wasNew = !threadId; closeSuggest(); setSending(true); setError && setError(null); justSentRef.current = true; // Show the user's message immediately; roll it back if the request fails. setInput(""); if (taRef.current) taRef.current.style.height = "auto"; setMessages((prev) => [...prev, { role: "user", text, sources: [], pending: true, ts: Date.now() }]); const rollback = () => setMessages((prev) => prev.filter((m) => !m.pending)); try { const res = await exploreFetch("/api/explore", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ threadId: threadId || null, message: text }), }); const data = await res.json().catch(() => ({})); // The conversation was reset or switched while this request was in // flight — its reply belongs to the old thread, so drop it. If this was // the first turn of a brand-new thread, the server already persisted a // conversation the user never saw — delete it so no orphan row appears. if (gen !== sendGenRef.current) { if (wasNew && data.threadId) { exploreFetch(`/api/explore/${data.threadId}`, { method: "DELETE" }).catch(() => {}); } return; } if (!res.ok) { // Preserve the user's text on a stale-limit rejection, matching the // generic error path — the composer is about to be replaced, but the // text survives if they navigate back to an open exploration. if (data.detail === "turn_limit_reached") { rollback(); setInput(text); setTurnsRemaining(0); return; } if (data.detail === "exploration_locked") { rollback(); setInput(text); setLocked(true); return; } if (data.detail === "report_limit_reached") { rollback(); setInput(text); setLimitHit(true); onLimitReached && onLimitReached(); return; } throw new Error(exploreErrorMessage(data, res.status)); } const revealId = Date.now(); const revealing = !exploreReducedMotion(); justSentRef.current = true; setMessages((prev) => [ ...prev.map((m) => (m.pending ? { role: "user", text: m.text, sources: [], ts: m.ts } : m)), { role: "assistant", text: data.reply, sources: data.sources || [], ts: Date.now(), revealing, revealId }, ]); setTurnsRemaining(data.turnsRemaining ?? 0); if (data.maxTurns) setMaxTurns(data.maxTurns); if (revealing) { const words = exploreWordCount(data.reply); const step = Math.min(18, 2500 / Math.max(words, 1)); revealTimerRef.current = setTimeout(() => { setMessages((prev) => prev.map((m) => (m.revealId === revealId ? { ...m, revealing: false } : m))); }, Math.min(words, EXPLORE_REVEAL_WORD_CAP) * step + 450); } if (!threadId && data.threadId && onThreadCreated) { selfCreatedThreadRef.current = data.threadId; onThreadCreated(data.threadId); } } catch (e) { if (gen === sendGenRef.current) { rollback(); setInput(text); setError && setError(e.message); } } finally { // Gen-guarded: a discarded stale request must not clear the sending // state of a newer in-flight send on the current thread. if (gen === sendGenRef.current) setSending(false); } }; const convert = async () => { // `sending` guard: converting mid-reply would either block on the server's // per-thread lock or distill a transcript missing the in-flight exchange. if (!threadId || converting || sending) return; setConverting(true); setError && setError(null); try { const res = await exploreFetch(`/api/explore/${threadId}/to-brief`, { method: "POST" }); const data = await res.json().catch(() => ({})); if (!res.ok && data.detail === "report_limit_reached") { setLimitHit(true); setConverting(false); onLimitReached && onLimitReached(); return; } if (!res.ok) throw new Error(exploreErrorMessage(data, res.status, "Couldn't build a brief from this conversation.")); setLocked(true); onConvert(data.intake, threadId); } catch (e) { setError && setError(e.message); setConverting(false); } }; const renderMessage = (m, i) => { const anim = m.noAnim || exploreReducedMotion() ? "" : " enter"; const rel = exploreRelTime(m.ts); let reveal = null; if (m.role === "assistant" && m.revealing) { const words = exploreWordCount(m.text); reveal = { i: 0, step: Math.min(18, 2500 / Math.max(words, 1)) }; } return (
{m.role === "assistant" && ( )}
{m.role === "assistant" && m.revealing ? ( // While the word-reveal runs, hide the fragmented animated spans // from screen readers and expose the full text once instead. {m.text} ) : m.role === "assistant" ? renderExploreRich(m.text, m.sources || [], reveal) : m.text.split(/\n{2,}/).map((p, j) =>

{p}

)} {m.role === "assistant" && m.revealing && }
{rel && {rel}}
); }; return (
Explore the space
Ask anything you're weighing up. {maxTurns} exchanges, then Atlas helps you turn it into a brief.
{hasAssistantReply && !done && ( )}
{hydrating &&
Loading your exploration…
} {!hydrating && renderMessage({ role: "assistant", text: exploreIntro(), sources: [], noAnim: true }, "intro")} {!hydrating && messages.length === 0 && !blocked && (
What are you curious about?
{EXPLORE_STARTERS.map((s, i) => ( ))}
)} {messages.map(renderMessage)} {typingState !== "hidden" && (
)}
{showJump && ( )}
{converted ? (
This exploration was converted to a brief.
The conversation is kept here as a read-only record.
{onOpenReport && ( )}
) : blocked ? (
You've hit your report limit.
This conversation is kept here as a read-only record. Upgrade to keep exploring or turn it into a brief.
{onUpgrade && ( )}
) : locked ? (
This exploration is locked — its brief has been created.
The conversation is kept here as a read-only record. Reopening the brief returns the same one.
) : atCap ? (
You've explored enough to scope a report.
Atlas will pull this conversation together into an editable brief.
) : (
{suggestOpen && suggestions.length > 0 && (
Sharper angles
{suggestions.map((s, i) => ( ))}
)}