// Atlas · Partner scouting panel (delivery stage, step 6) // Four states: idle → running → ready | unavailable. // Events arrive in BURSTS, not a stream (see PARTNER-SCOUTING-session1-handoff.md §4): // all ~35 source events fire at once, all partner events fire after scout completes. // So there is no progressive card reveal here by design — the wait is narrated by the // phase checklist, the rotating action line, chip arrivals, and the NotedSource card. const { useState: useStateSc, useEffect: useEffectSc, useRef: useRefSc } = React; const SC_PHASES = [ { key: "distill", label: "Reading your report" }, { key: "identify", label: "Searching for partners" }, { key: "verify", label: "Verifying each candidate" }, { key: "review", label: "Reviewing the shortlist" }, ]; const SC_MAX_CHIPS = 18; // Liveness: the backend emits {"heartbeat": N} every 20s purely to prove the turn // is alive. If NOTHING (heartbeats included) has arrived for this long on an open // stream, say so quietly. Dropped connections are handled separately by the // reconnect/finishRun path — this covers a stream that stays open while silent. const SC_STALL_MS = 90000; // Display-only colour map, keyed on the label rather than the stored value so a // hyphen variant of the "SME – " prefix (the stored types carry an en dash, // U+2013) still lands on the right hue. Green is deliberately absent: it stays // reserved for the "Introduction requested" state pill, so a type can never be // mistaken for a status. Anything unrecognised falls back to gray. const SC_TYPE_CLASSES = { "academia": "blue", "industry": "violet", "startup": "teal", "vendor": "plum", "supplier": "olive", "emerging technology": "clay", }; function scTypeClass(type) { return SC_TYPE_CLASSES[scTypeLabel(type).trim().toLowerCase()] || "gray"; } // Display map only. The stored `type` values ("SME – Academia", "SME – Industry") // are internal vocabulary shared with cs_research_flow and asserted by tests; // the client just should not see the jargon prefix. function scTypeLabel(type) { const t = String(type || "").trim(); return t.replace(/^SME\s*[–-]\s*/i, "") || t; } const SC_NOTE_MAX = 1000; // mirrors INTRO_REQUEST_MAX_NOTE_CHARS in app/server.py // S6, the second of the two mitigations that ship with the liveness gate. One // promise, three surfaces (ready screen, request modal, confirmation) — held in a // constant precisely so the three cannot drift apart. It is true regardless of any // gate: NotedSource does confirm availability before it introduces anyone, and // saying so is what makes an occasional stale card recoverable rather than // embarrassing. S9 folded the fee restatement in here for the same reason the // promise lives in one constant — the three surfaces must not drift on either // claim, and the fee is the one thing a user must never learn late. const SC_AVAILABILITY_PROMISE = "NotedSource confirms each partner is available and quotes the fee before any " + "introduction is made."; function ScoutingPartnerCard({ partner, requested, selected, onToggle }) { return (
{/* Display map only: source_url / source_label stay in the stored schema (fulfilment and QA need them) but are not shown. One link cannot carry a claim assembled from several sources, so showing one invites misreading (audit F5); the claim-support gate is the Session 3 pair to this change. */} {partner.name} {partner.type && {scTypeLabel(partner.type)}}
{(partner.title || partner.affiliation) && (
{partner.title} {partner.title && partner.affiliation ? " · " : ""} {partner.affiliation}
)} {/* S6. The date the evidence behind this card carries, sitting where the source link used to. Quiet and small: it is a caveat, not a headline. Results stored before S6 have no evidence_date, so a missing or empty value must render nothing at all rather than an empty line. */} {partner.evidence_date ? (
{partner.evidence_date}
) : null} {partner.bio &&
{partner.bio}
} {partner.relevance && (
Why NotedSource matched them
{partner.relevance}
)} {/* Requested cards get no checkbox at all. A disabled empty box reads as broken; the "Introduction requested" badge says why instead. */} {requested ? (
Introduction requested
) : ( )}
); } // The NotedSource positioning copy, shown on the wait screen — the moment of peak // attention, so it carries the positioning story rather than a countdown. function ScoutingNsBody() { return (

NotedSource is running this search across its network of vetted experts and the wider research landscape. The result is directional: it surfaces the people whose work sits closest to the questions in your report, so you can see the kinds of partners available to you.

What happens next

You will get a shortlist of partners whose public work matches this report. Select anyone you want to meet and NotedSource will send you a brief on why they fit, quote the fee for the introduction, and arrange it once you approve. NotedSource can also go further, sourcing and vetting additional partners built around your report.

Why work with partners through NotedSource

NotedSource handles contracting, confidentiality, IP protections, and payment on your behalf, and supports every engagement from the first introduction to the final deliverable. Running this search costs you nothing; you only pay for an introduction or engagement you have agreed to.

{/* The only outbound link in the panel, and deliberately only here. The entry box has one job (the click), and the ready screen has two CTAs of its own, so an exit on either competes. The wait screen is the one place where the user has nothing else to do and leaving costs nothing: the run continues server-side and the ready email brings them back. The utm pair is what lets NotedSource attribute the traffic to this panel. */}

More about NotedSource

); } // The lead-form pattern from app/upgrade-modal.jsx: same .modal-backdrop / .modal / // .modal-header / .modal-body primitives, same read-only email, same in-modal // confirmation state with no redirect. What differs is that this form is a // CONFIRMATION, not an intake — the report, the topic and the partner names are // already known, so the only thing the user types is an optional note. function ScoutingIntroModal({ reportId, reportTitle, topic, partners, user, noFit, resultsGeneratedAt, onClose, onSubmitted, onStale }) { const [note, setNote] = useStateSc(""); const [submitting, setSubmitting] = useStateSc(false); const [submitted, setSubmitted] = useStateSc(false); // Snapshot of how many partners the request named. The parent clears its selection // on submit, which empties the `partners` prop while the confirmation is on screen. const [sentCount, setSentCount] = useStateSc(0); const [error, setError] = useStateSc(null); // Single-flight guard. `submitting` drives the button's disabled attribute, but // it is React state: it lands on the next render, not on the call that sets it. // A fast double-click, or Enter-then-click, can therefore run handleSubmit twice // before the button ever renders disabled, POSTing the same request twice. A ref // mutates synchronously, so the second call returns before it reaches fetch. // Server-side no-fit idempotence is the backstop for what slips through, not the // fix: it 409s the duplicate, which would surface here as a spurious error on a // request that actually succeeded. const inFlightRef = useRefSc(false); const email = user?.email || ""; async function handleSubmit(e) { e.preventDefault(); if (inFlightRef.current || submitted) return; inFlightRef.current = true; setSubmitting(true); setError(null); try { const res = await fetch(`/api/scouting/${reportId}/intro-request`, { method: "POST", headers: { "Content-Type": "application/json" }, // Ids only. The server joins the names and the internal long-form assessment // itself, off disk — nothing about a partner is trusted from the browser. // (Deliberately not naming the field here: Babel-in-browser puts this comment // into a DOM script node, and a grep for the field name is a useful canary.) // results_generated_at pins the request to the generation on screen. If a // re-scout landed in another tab, the server refuses rather than asking // NotedSource to introduce someone this user never saw. body: JSON.stringify( noFit ? { partner_ids: [], no_fit: true, note, results_generated_at: resultsGeneratedAt } : { partner_ids: partners.map((p) => p.id), note, results_generated_at: resultsGeneratedAt, } ), }); const data = await res.json().catch(() => ({})); if (!res.ok) { // detail is an object on the two 409s and a plain string elsewhere. const d = data.detail; const msg = (d && typeof d === "object" ? d.message : d) || "Could not send your request."; if (res.status === 409 && onStale) onStale(d && typeof d === "object" ? d.code : ""); throw new Error(msg); } setSentCount(partners.length); setSubmitted(true); onSubmitted(data); } catch (err) { setError(err.message || "Could not send your request."); } finally { // Released only here, so a failed attempt stays retryable while an // in-flight one cannot be duplicated. Closing the modal mid-flight unmounts // this component and the ref goes with it; the request still completes // server-side, which is the correct outcome for a request already sent. inFlightRef.current = false; setSubmitting(false); } } return (
{ if (e.target === e.currentTarget) onClose(); }}>

{submitted ? "Request received" : noFit ? "Request a custom search" : partners.length === 1 ? "Request an introduction" : "Request introductions"}

{submitted ? ( // Left-aligned, not centred: four stacked sentences of centred text are // harder to read than they are celebratory, and centring no longer // matches the left-aligned form this state replaces.

{noFit ? "NotedSource will be in touch about your search." : sentCount === 1 ? "NotedSource will be in touch to set up your introduction." : "NotedSource will be in touch to set up your introductions."}

{noFit ? `NotedSource will contact you at ${email} about additional partners for this report, including the fee.` : `NotedSource will contact you at ${email} with a brief on ${sentCount === 1 ? "your partner" : `each of your ${sentCount} partners`}, the fee, and next steps.`}

{SC_AVAILABILITY_PROMISE}

{/* The confirmation email is what makes this state survive a reload -- this panel forgets, the inbox does not. */}

A confirmation with a summary of this request is on its way to {email}.

) : (

{noFit ? "Tell NotedSource what you are looking for. They will source and vet additional partners for your report, send you a brief on why each one fits, and quote the fee before arranging any introduction. Nothing is committed by sending this request." : partners.length === 1 ? "NotedSource will send you a brief on why this partner fits and quote the fee before arranging the introduction. Nothing is committed by sending this request." : "NotedSource will send you a brief on why each partner fits and quote the fee before arranging the introductions. Nothing is committed by sending this request."} {" "} {SC_AVAILABILITY_PROMISE}

{reportTitle || reportId}
{topic && topic !== reportTitle && (
{topic}
)} {!noFit && (
{partners.map((p) => (
{p.name} {p.affiliation ? · {p.affiliation} : null}
))}
)}