// 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)}}
)}
{/* 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. */}
);
}
// 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 ? (
// 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}.
) : (
)}
{submitted && (
)}
);
}
function ScoutingPanel({ reportId, user }) {
// "loading" until the mount GET answers, so we never flash the idle card at a
// user whose run is already finished or in flight.
const [phase, setPhase] = useStateSc("loading");
const [phaseState, setPhaseState] = useStateSc({}); // {distill: "start"|"done", …}
const [actionLine, setActionLine] = useStateSc("");
const [sources, setSources] = useStateSc([]); // [{domain, delay}]
const [partners, setPartners] = useStateSc([]);
const [stats, setStats] = useStateSc(null); // {verified, considered, dropped_count}
const [selected, setSelected] = useStateSc([]); // partner ids ticked right now
const [requestedIds, setRequestedIds] = useStateSc([]);// partner ids already requested
// A custom search ("beyond this list") already asked for against THIS results
// generation. Server-derived, so it survives a reload like the partner badges do.
const [noFitRequested, setNoFitRequested] = useStateSc(false);
// generated_at of the results currently rendered. Sent with an intro request so
// the server can refuse one written against a list this user never saw.
const [generatedAt, setGeneratedAt] = useStateSc(null);
// false | "partners" | "nofit" — the no-fit variant is the same modal and the same
// write path, minus the partner list.
const [showIntroModal, setShowIntroModal] = useStateSc(false);
const [reportTitle, setReportTitle] = useStateSc("");
const [topic, setTopic] = useStateSc("");
const [stalled, setStalled] = useStateSc(false);
// Set by the server's {"stuck": true} frame (30 min of real silence), distinct
// from the client-side SSE-silence notice above. Only a real event clears it.
const [serverStuck, setServerStuck] = useStateSc(false);
// One quiet line on the unavailable card when a reset was refused (409: the
// previous run is still winding down).
const [resetNote, setResetNote] = useStateSc("");
const lastEventRef = useRefSc(Date.now());
const esRef = useRefSc(null);
const reconnectRef = useRefSc(null);
const reconnectAttemptsRef = useRefSc(0);
const mountedRef = useRefSc(true);
const startedRef = useRefSc(false); // double-click guard
const seenSourcesRef = useRefSc(null);
const burstRef = useRefSc({ last: 0, n: 0 });
const MAX_RECONNECT_ATTEMPTS = 5;
const resetDerived = () => {
setPhaseState({});
setActionLine("");
setSources([]);
setPartners([]);
setStats(null);
setSelected([]);
seenSourcesRef.current = new Set();
burstRef.current = { last: 0, n: 0 };
};
// Envelope fields that live OUTSIDE `results` (frozen schema — see server.py).
const applyEnvelope = (d) => {
if (Array.isArray(d.requested_partner_ids)) setRequestedIds(d.requested_partner_ids);
setNoFitRequested(d.no_fit_requested === true);
if (d.report_title) setReportTitle(d.report_title);
if (d.topic) setTopic(d.topic);
};
const toggleSelected = (id) =>
setSelected((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
// Terminal render decision. PREFER `results` when present: a report scouted before
// the status endpoints existed has top-level state "pending" with complete results.
const applyResults = (results) => {
const list = (results && results.partners) || [];
setPartners(list);
setGeneratedAt((results && results.generated_at) ?? null);
setPhase(list.length > 0 && results.state === "complete" ? "ready" : "unavailable");
};
// Re-read the server's view of this report and repaint. Used when an intro
// request is refused because the list moved under the user (409 stale_results):
// the selection they made no longer refers to anyone they saw, so it goes.
const reloadResults = async () => {
try {
const r = await fetch(`/api/scouting/${reportId}`);
if (!r.ok) return;
const d = await r.json();
if (!mountedRef.current) return;
applyEnvelope(d);
setSelected([]);
if (d.results) applyResults(d.results);
} catch (err) {}
};
// The "checked N candidates" line comes from the verify-done event, which the
// results file does not carry. Without this, the line only ever shows to the one
// session that watched the run live — every later page load loses it. The stream
// replays the log and terminates immediately for a finished run, so re-read it
// once rather than adding an endpoint. Reports scouted before the log existed
// simply yield nothing and the line stays hidden.
const harvestStats = () => {
const es = new EventSource(`/api/scouting/${reportId}/stream`);
const stop = () => { try { es.close(); } catch (e) {} };
es.onmessage = (e) => {
try {
const d = JSON.parse(e.data);
// Keep reading past verify-done: a review-cycle removal supersedes those
// totals on the review-done event (audit F13), and the later numbers are
// the ones that match the cards on screen.
if (d.considered !== undefined && mountedRef.current) {
setStats({ verified: d.verified, considered: d.considered, dropped: d.dropped_count });
}
if (d.done || (d.phase === "review" && d.status === "done")) stop();
} catch (err) {}
};
es.onerror = stop;
setTimeout(stop, 15000); // never leave a socket open on a stalled replay
};
const attachStream = (isReconnect) => {
if (esRef.current) esRef.current.close();
if (!isReconnect) resetDerived();
const es = new EventSource(`/api/scouting/${reportId}/stream`);
esRef.current = es;
lastEventRef.current = Date.now();
es.onmessage = (e) => {
try {
if (reconnectAttemptsRef.current > 0) reconnectAttemptsRef.current = 0;
const d = JSON.parse(e.data);
// A stuck frame is the server saying the run has gone silent. It must not
// count as liveness and must not clear the stall notice — parse BEFORE
// stamping, or the panel gets calmer the more certain the server is that
// the run is dead (audit F12).
if (d.stuck) {
setServerStuck(true);
return;
}
// Any real event — heartbeats included — proves the run is alive.
lastEventRef.current = Date.now();
setStalled((prev) => (prev ? false : prev));
setServerStuck((prev) => (prev ? false : prev));
// Heartbeats are liveness plumbing, never rendered. Drop after the
// liveness stamp above.
if (d.heartbeat !== undefined) return;
if (d.phase && d.status) {
setPhaseState((prev) => ({ ...prev, [d.phase]: d.status }));
// Counts ride on verify-done, and again on review-done when the review
// gate removed entries after verification (audit F13). Read them
// wherever they appear so the last word wins and the line always
// matches the cards.
if (d.considered !== undefined) {
setStats({
verified: d.verified,
considered: d.considered,
dropped: d.dropped_count,
});
}
} else if (d.source) {
// Bursts: consecutive events land within milliseconds of each other. Give
// each item in a burst a +40ms delay so a batch reads as an arrival.
const now = Date.now();
const b = burstRef.current;
b.n = now - b.last > 400 ? 0 : b.n + 1;
b.last = now;
const delay = Math.min(b.n * 40, 800);
setSources((prev) => {
if (seenSourcesRef.current.has(d.source)) return prev;
seenSourcesRef.current.add(d.source);
return [...prev, { domain: d.source, delay }];
});
} else if (d.partner && typeof d.partner === "object") {
// Each partner is emitted twice — skeleton at verify, filled after profiling.
// Dedupe by id and let the second event overwrite the first.
setPartners((prev) => {
const i = prev.findIndex((p) => p.id === d.partner.id);
if (i === -1) return [...prev, d.partner];
const next = prev.slice();
next[i] = d.partner;
return next;
});
} else if (d.done) {
es.close();
finishRun();
} else if (d.text !== undefined) {
setActionLine(d.text); // one line, replaced not appended
}
// {"dropped": {…}} is deliberately not rendered — drop reasons are internal QA.
} catch (err) {}
};
es.onerror = () => {
es.close();
if (!mountedRef.current) return;
reconnectAttemptsRef.current++;
if (reconnectAttemptsRef.current >= MAX_RECONNECT_ATTEMPTS) {
// Fall back to the status endpoint rather than stranding the panel mid-run.
finishRun();
return;
}
if (reconnectRef.current) clearTimeout(reconnectRef.current);
reconnectRef.current = setTimeout(() => {
if (mountedRef.current) attachStream(true);
}, 3000);
};
};
// The real polling fallback behind the SSE stream. Reached on the `done` event
// (where the results file can still race the GET, hence the short retries) and
// when reconnects are exhausted mid-run. The one thing it must never do is call
// a run the server still says is `running` unavailable — that puts a Try again
// button over a live, paid-for run (audit F2). So: server says running → keep
// the running UI, poll, and re-attempt the stream; server unreachable → keep
// polling; unavailable only when the server actually says so.
const SC_POLL_MS = 4000;
const finishRun = async (attempt = 0) => {
if (!mountedRef.current) return;
try {
const r = await fetch(`/api/scouting/${reportId}`);
if (!r.ok) throw new Error(`status ${r.status}`);
const d = await r.json();
if (!mountedRef.current) return;
applyEnvelope(d);
if (d.results) return applyResults(d.results);
if (d.state === "running") {
setPhase("running");
if (d.stuck) setServerStuck(true);
reconnectAttemptsRef.current = 0;
if (reconnectRef.current) clearTimeout(reconnectRef.current);
reconnectRef.current = setTimeout(() => {
if (mountedRef.current) attachStream(true);
}, SC_POLL_MS);
return;
}
// Terminal state with no results yet: brief retry covers the write race on a
// fast `done`; after that the server has genuinely said the run is over.
if (attempt < 3) return setTimeout(() => finishRun(attempt + 1), 700);
setPhase("unavailable");
} catch (err) {
// Network outage or a 5xx. The run may well still be alive server-side, so
// never land on unavailable from here — keep polling until the server answers.
if (mountedRef.current) setTimeout(() => finishRun(attempt), SC_POLL_MS);
}
};
const startScouting = async () => {
if (startedRef.current) return; // double-click guard
startedRef.current = true;
resetDerived();
setResetNote("");
setPhase("running");
// Near-fire-and-forget: the POST is idempotent server-side, and a 200
// already-running still leaves the stream as the right thing to attach to. But
// a refusal (409 precondition, 5xx) means nothing is running — take the fake
// running UI back down instead of streaming an empty log forever.
fetch(`/api/scouting/${reportId}`, { method: "POST" })
.then((res) => {
if (res.ok || !mountedRef.current) return;
startedRef.current = false;
if (esRef.current) esRef.current.close();
if (reconnectRef.current) clearTimeout(reconnectRef.current);
setPhase("unavailable");
})
.catch(() => {});
attachStream(false);
};
const tryAgain = async () => {
startedRef.current = false;
setResetNote("");
try {
const res = await fetch(`/api/scouting/${reportId}/reset`, { method: "POST" });
if (!res.ok) {
// 409: the previous run is still winding down and still holds its slot.
// Restarting now would stream an empty log over a held lock. Stay put,
// say why, and leave the button live for a retry in a few seconds.
setResetNote("The previous search is still winding down. Wait a few seconds and try again.");
return;
}
} catch (err) {
setResetNote("Couldn't reach the server. Check your connection and try again.");
return;
}
startScouting();
};
// Silence watchdog, only while a run is on screen. Checks the timestamp rather
// than accumulating its own state, so an event resuming clears it within a tick.
useEffectSc(() => {
if (phase !== "running") { setStalled(false); setServerStuck(false); return; }
const t = setInterval(() => {
setStalled(Date.now() - lastEventRef.current > SC_STALL_MS);
}, 5000);
return () => clearInterval(t);
}, [phase]);
// Mount: ask the status endpoint what world we are in before rendering anything.
useEffectSc(() => {
mountedRef.current = true;
seenSourcesRef.current = new Set();
if (!reportId) { setPhase("idle"); return; }
const load = async () => {
try {
const r = await fetch(`/api/scouting/${reportId}`);
// A 500 must not render the pristine idle card over a run the status file
// says is live (audit F16) — fall to the quiet degraded state instead.
if (!r.ok) throw new Error(`status ${r.status}`);
const d = await r.json();
if (!mountedRef.current) return;
applyEnvelope(d);
if (d.results) {
applyResults(d.results);
if ((d.results.partners || []).length > 0) harvestStats();
return;
}
if (d.state === "running" && !d.stuck) {
startedRef.current = true;
setPhase("running");
attachStream(false);
return;
}
if (d.stuck || d.state === "unavailable" || d.state === "failed") {
setPhase("unavailable");
return;
}
setPhase("idle");
} catch (err) {
// Server unreachable or errored: we do not know what world we are in, so
// offer neither "Find partners" (could double over a live run) nor
// "Try again" (resets a live run). Show the quiet card and keep checking.
if (!mountedRef.current) return;
setPhase("unknown");
if (reconnectRef.current) clearTimeout(reconnectRef.current);
reconnectRef.current = setTimeout(() => {
if (mountedRef.current) load();
}, 5000);
}
};
load();
return () => {
mountedRef.current = false;
if (esRef.current) esRef.current.close();
if (reconnectRef.current) clearTimeout(reconnectRef.current);
};
}, [reportId]);
if (phase === "loading") return null;
// Degraded state: the status endpoint is unreachable or erroring, so we cannot
// tell idle from running. No "Find partners" (could pile onto a live run) and no
// "Try again" (would reset one). The mount loader keeps retrying behind this.
if (phase === "unknown") {
return (
Partners
We couldn't check the partner search status. Retrying automatically.
);
}
if (phase === "idle") {
return (
{/* The attribution lockup replaces the generic "PARTNERS" eyebrow.
NotedSource is a separate company, and this box is the one place in
Atlas where that is the headline fact rather than a footnote — which is
also why this state is a bounded box and not the next paragraph of the
delivery screen. Two logo files, one visible per theme: see
.sc-entry-logo in app/styles.css for why a CSS filter won't do. */}
Powered by
Work with the partners behind this research
{/* Two sentences, because one would overreach. Claiming NotedSource
"searches its network of vetted experts" implies everyone on the
shortlist is already in it -- and they are not, since the scout follows
work wherever it leads. A partner who saw themselves listed
under that claim would rightly find it strange. */}
NotedSource identifies people working on the questions in this report,
starting with its network of vetted experts and looking beyond it to
whoever the work points to. Every partner is verified before it
reaches your shortlist.
{/* Fee before the click, not after. No figure: engagements are scoped
individually, so any number here would be wrong for someone. */}
Finding partners is free. Introductions and engagements
are arranged by NotedSource for a fee, quoted before anything is committed.
Takes 5-15 minutes.
);
}
if (phase === "running") {
// The panel is at its emptiest before the first source chip lands — roughly the
// first 90 seconds, during distill, which is also the moment of peak attention.
// Until then the expectation-setting line is promoted into that empty area; once
// chips start arriving it falls back to the small grey position at the bottom.
const preChips = sources.length === 0;
// Live counts on the active row. These are honest lengths of things that have
// actually happened — never progress toward a total we cannot know.
const stepCount = (key) => {
if (key === "identify" && sources.length > 0) {
return `${sources.length} source${sources.length === 1 ? "" : "s"}`;
}
if (key === "verify" && partners.length > 0) {
return `${partners.length} verified`;
}
return null;
};
return (
Finding partners
{SC_PHASES.map((p) => {
const st = phaseState[p.key];
const cls = st === "done" ? "done" : st === "start" ? "active" : "pending";
const count = stepCount(p.key);
return (
{/* One quiet line when the run has gone silent. Two triggers: the client
noticing SSE silence (clears itself when events resume), and the
server's own stuck frame after 30 minutes of real inactivity, which is
firmer and persists until a real event arrives. */}
{(stalled || serverStuck) && (
{serverStuck
? "The search has gone quiet and may not finish. You can keep waiting, or come back later."
: "Still working. This is taking longer than usual."}
)}
{preChips && (
This usually takes 5-15 minutes. You can leave this page and the search keeps running.
)}
{sources.length > 0 && (
// A real run consults ~35 domains. Rendering all of them turns the panel
// into a wall of chips on a narrow viewport, so cap the list and let the
// trailing counter carry the sense of accumulation instead.
)}
{/* The promoted note above already sets the time expectation; once chips
land it falls back to this quieter line. Only one is ever on screen. */}
{!preChips && (
Typically 5-15 minutes. You can download your report or leave this page
and the search keeps running.
)}
{/* Where NotedSource fits in. The wait is the moment of peak attention,
so it carries the positioning story rather than a countdown. */}
{/* Three labelled blocks. The wait is where there is time and attention to
read, so the trust argument lives here, not on the results screen. The
fee is named but never numbered, and there are still no expected partner
counts and no animation. */}
NotedSource reviewed {stats.considered} candidate
{stats.considered === 1 ? "" : "s"}; {stats.verified} met the evidence bar.
) : null}
{/* The offer, in one sentence. Approved copy — verbatim. This is an action
screen: the sales argument lives on the wait screen, not here. */}
Select anyone you would like to meet. NotedSource will send you a brief on
why they fit and arrange the introduction. {SC_AVAILABILITY_PROMISE}
{selectable.length > 1 && (
)}
{partners.map((p) => (
))}
{/* The CTA is visible from the start, disabled until a selection exists —
the button itself teaches the interaction. Hidden only when every
partner is already requested and there is nothing left to select. */}
{selectable.length > 0 && (
)}
{/* Fallback path for users who want an expert but not one of these — same
modal and write path as a partner request, flagged no_fit so the team
can tell "shortlist missed" from "shortlist hit". */}
{/* Opportunity framing, not deficit framing. "Not the right partners?"
invited the user to read the shortlist they just received as a
failure; this offers a larger service instead. */}
Want to go further?
NotedSource can source and vet additional partners for your report,
beyond the ones this search surfaced, and will quote the fee before
starting.
{/* One custom search per shortlist. Repeat clicks used to write a fresh
row into the fulfilment queue every time, with nothing on screen
saying the first had landed. */}
{noFitRequested ? (
Custom search requested
) : (
)}
{showIntroModal && (
setShowIntroModal(false)}
onSubmitted={(data) => {
// Server is authoritative on what has been requested; it returns the
// full set, so a second request cannot drop the first one's ids.
if (Array.isArray(data.requested_partner_ids)) setRequestedIds(data.requested_partner_ids);
if (data.no_fit_requested === true) setNoFitRequested(true);
setSelected([]);
}}
// 409. stale_results means the list moved under this user, so repaint
// from the server and drop the selection; already_requested just needs
// the CTA to catch up. Either way the modal shows the server's message.
onStale={(code) => {
if (code === "already_requested") setNoFitRequested(true);
else reloadResults();
}}
/>
)}
);
}
// Unavailable. Quiet, not alarming — the report itself is complete and unaffected.
return (