spb/search-box Public
Agentic web research engine — hypotheses, verbatim evidence, contradictions, sourced answers streamed live. Claude Opus 5 + Firecrawl + PostgreSQL.
TypeScript 76.9%
CSS 18.7%
SQL 2.1%
JavaScript 1.8%
Shell 0.5%
1/**2 * Search-box.ai3 * Author: Simon-Pierre Boucher4 * Contact: contact@spboucher.ai5 * File: apps/web/components/SessionView.tsx6 * Description: Live research view — event-sourced UI (bloom graph, pastille timeline, claims, answer).7 */89"use client";1011import { useEffect, useMemo, useReducer, useRef, useState } from "react";12import type { ResearchEventPayload, CitationMapEntry } from "@search-box/events";13import type { Claim, Contradiction, SessionStatus, Source } from "@search-box/shared";14import { AnswerPanel } from "./AnswerPanel";15import { ResearchGraph } from "./ResearchGraph";16import { Glyph, Pastille, type PastilleKind } from "./Pastille";1718/* ------------------------------- view state ------------------------------- */1920interface FeedItem {21 key: string;22 kind: PastilleKind;23 label: string;24 title: string;25 detail?: string;26 quote?: string;27 stance?: string;28 status?: string;29 confidence?: number;30 objectives?: string[];31 pending?: boolean;32}3334interface ViewState {35 status: SessionStatus;36 feed: FeedItem[];37 objectives: string[];38 answer: string;39 answerDone: boolean;40 citations: CitationMapEntry[];41 sources: Map<string, Source>;42 claims: Map<string, Claim>;43 contradictions: Contradiction[];44 error: string | null;45 seq: number;46}4748function initialState(status: SessionStatus): ViewState {49 return {50 status,51 feed: [],52 objectives: [],53 answer: "",54 answerDone: false,55 citations: [],56 sources: new Map(),57 claims: new Map(),58 contradictions: [],59 error: null,60 seq: 061 };62}6364function reduce(state: ViewState, ev: { seq: number; payload: ResearchEventPayload }): ViewState {65 const p = ev.payload;66 const next: ViewState = { ...state, seq: ev.seq };67 const push = (item: FeedItem) => {68 next.feed = [...next.feed, item];69 };7071 switch (p.type) {72 case "session.status":73 next.status = p.status;74 break;75 case "session.failed":76 next.error = p.error;77 push({ key: `e${ev.seq}`, kind: "failed", label: "failed", title: p.error });78 break;79 case "session.completed":80 push({ key: `e${ev.seq}`, kind: "done", label: "research complete", title: "Answer delivered with full provenance." });81 break;82 case "plan.updated":83 next.objectives = p.objectives;84 push({ key: `e${ev.seq}`, kind: "plan", label: "plan", title: p.publicReason, objectives: p.objectives });85 break;86 case "thought":87 push({ key: `e${ev.seq}`, kind: "thought", label: "reasoning", title: p.publicReason });88 break;89 case "action.started":90 push({91 key: p.actionId,92 kind: p.kind === "search" ? "search" : "fetch",93 label: p.kind === "search" ? "searching" : "reading",94 title: p.label,95 pending: true96 });97 break;98 case "action.completed":99 next.feed = next.feed.map((item) =>100 item.key === p.actionId101 ? {102 ...item,103 pending: false,104 label: p.ok ? (item.kind === "search" ? "searched" : "read source") : `${item.kind} failed`,105 kind: p.ok && item.kind === "fetch" ? "read" : item.kind,106 detail: p.summary107 }108 : item109 );110 break;111 case "source.added":112 case "source.updated": {113 const sources = new Map(next.sources);114 sources.set(p.source.id, p.source);115 next.sources = sources;116 break;117 }118 case "claim.added": {119 const claims = new Map(next.claims);120 claims.set(p.claim.id, p.claim);121 next.claims = claims;122 push({ key: `e${ev.seq}`, kind: "claim", label: "new claim", title: p.claim.text });123 break;124 }125 case "claim.updated": {126 const claims = new Map(next.claims);127 claims.set(p.claim.id, p.claim);128 next.claims = claims;129 push({130 key: `e${ev.seq}`,131 kind: "belief",132 label: "belief update",133 title: p.claim.text,134 status: p.claim.status,135 confidence: p.claim.confidence,136 detail: p.claim.publicReason ?? undefined137 });138 break;139 }140 case "evidence.added":141 push({142 key: `e${ev.seq}`,143 kind: "evidence",144 label: `evidence · ${p.evidence.stance}`,145 title: p.sourceTitle ?? p.sourceUrl,146 quote: p.evidence.quote,147 stance: p.evidence.stance148 });149 break;150 case "contradiction.added":151 next.contradictions = [...next.contradictions, p.contradiction];152 push({ key: `e${ev.seq}`, kind: "contradiction", label: "contradiction", title: p.contradiction.description });153 break;154 case "synthesis.started":155 push({ key: `e${ev.seq}`, kind: "synthesis", label: "writing", title: "Evidence base closed — writing the answer." });156 break;157 case "answer.delta":158 next.answer = state.answer + p.delta;159 break;160 case "answer.completed":161 next.answer = p.answer;162 next.answerDone = true;163 next.citations = p.citations;164 break;165 default:166 break;167 }168 return next;169}170171/* --------------------------------- view ---------------------------------- */172173type Tab = "live" | "answer" | "evidence";174175export function SessionView({176 id,177 question,178 initialStatus179}: {180 id: string;181 question: string;182 initialStatus: SessionStatus;183}) {184 const [state, dispatch] = useReducer(185 (s: ViewState, ev: { seq: number; payload: ResearchEventPayload }) => reduce(s, ev),186 initialStatus,187 initialState188 );189 const [tab, setTab] = useState<Tab>("live");190 const feedEndRef = useRef<HTMLDivElement>(null);191 const autoTabbed = useRef(false);192193 // Event-sourced: replay from seq 0; EventSource reconnects with Last-Event-ID.194 useEffect(() => {195 const es = new EventSource(`/api/research/${id}/stream?from=0`);196 es.onmessage = (msg) => {197 try {198 const payload = JSON.parse(msg.data) as ResearchEventPayload;199 dispatch({ seq: Number(msg.lastEventId || 0), payload });200 } catch {201 // ignore malformed frames202 }203 };204 es.addEventListener("end", () => es.close());205 return () => es.close();206 }, [id]);207208 useEffect(() => {209 if (state.answer.length > 0 && !autoTabbed.current) {210 autoTabbed.current = true;211 if (window.innerWidth < 1024) setTab("answer");212 }213 }, [state.answer]);214215 useEffect(() => {216 if (tab === "live") feedEndRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest" });217 }, [state.feed.length, tab]);218219 const claims = useMemo(() => [...state.claims.values()], [state.claims]);220221 const stats = useMemo(() => {222 const fetched = [...state.sources.values()].filter((s) => s.status === "fetched").length;223 const avg = claims.length > 0 ? claims.reduce((a, c) => a + c.confidence, 0) / claims.length : null;224 return { sources: fetched, claims: claims.length, contradictions: state.contradictions.length, confidence: avg };225 }, [state.sources, claims, state.contradictions]);226227 const live = state.status === "running" || state.status === "synthesizing" || state.status === "pending";228229 const citedSources = useMemo(() => {230 if (state.citations.length > 0) return state.citations;231 return [...state.sources.values()]232 .filter((s) => s.citationIndex !== null)233 .sort((a, b) => (a.citationIndex ?? 0) - (b.citationIndex ?? 0))234 .map((s) => ({ index: s.citationIndex as number, sourceId: s.id, url: s.url, title: s.title }));235 }, [state.citations, state.sources]);236237 const phaseLabel =238 state.status === "failed"239 ? "failed"240 : state.status === "completed"241 ? "complete"242 : state.status === "synthesizing"243 ? "writing answer"244 : "researching";245246 return (247 <main>248 <div className="question-head">249 <div className="kicker">research session</div>250 <h1>{question}</h1>251 </div>252253 <div className="statsbar" role="status">254 <span className={`stat-seg phase ${state.status === "completed" ? "done" : ""} ${state.status === "failed" ? "dead" : ""}`}>255 <span className={`pulse ${live ? "" : "still"}`} />256 <b>{phaseLabel}</b>257 </span>258 <span className="stat-seg">259 <Pastille kind="source" size="sm" />260 sources <b>{stats.sources}</b>261 </span>262 <span className="stat-seg">263 <Pastille kind="claim" size="sm" />264 claims <b>{stats.claims}</b>265 </span>266 <span className="stat-seg">267 <Pastille kind="contradiction" size="sm" />268 contradictions <b>{stats.contradictions}</b>269 </span>270 <span className="stat-seg">271 <Pastille kind="confidence" size="sm" />272 confidence <b>{stats.confidence === null ? "—" : `${Math.round(stats.confidence * 100)}%`}</b>273 </span>274 </div>275276 {(state.objectives.length > 0 || claims.length > 0) && (277 <ResearchGraph278 objectives={state.objectives}279 claims={claims}280 live={live}281 hasContradiction={state.contradictions.length > 0}282 />283 )}284285 {state.error && (286 <div className="error-box">287 <Pastille kind="failed" />288 research failed: {state.error}289 </div>290 )}291292 <div className="tabbar">293 {(294 [295 ["live", "plan"],296 ["answer", "synthesis"],297 ["evidence", "evidence"]298 ] as Array<[Tab, PastilleKind]>299 ).map(([t, icon]) => (300 <button key={t} className={tab === t ? "active" : ""} onClick={() => setTab(t)}>301 <Glyph kind={icon} size={13} />302 {t}303 </button>304 ))}305 </div>306307 <div className="panes">308 <section className={`pane ${tab === "live" ? "visible" : ""}`} aria-label="Live research feed">309 <h2>310 <Glyph kind="plan" size={13} /> Live research311 </h2>312 <div className="tl">313 {state.feed.map((item) => (314 <TimelineRow key={item.key} item={item} />315 ))}316 {state.feed.length === 0 && (317 <div className="tl-empty">318 <Pastille kind="search" spinning={live} />319 waiting for the first research event…320 </div>321 )}322 <div ref={feedEndRef} />323 </div>324 </section>325326 <section className={`pane ${tab === "answer" ? "visible" : ""}`} aria-label="Answer">327 <h2>328 <Glyph kind="synthesis" size={13} /> Answer329 </h2>330 <AnswerPanel markdown={state.answer} done={state.answerDone} live={live} />331 </section>332333 <section className={`pane evidence-pane ${tab === "evidence" ? "visible" : ""}`} aria-label="Claims and sources">334 {claims.length > 0 && (335 <div className="board">336 <h2>337 <Glyph kind="claim" size={13} /> Claims under investigation338 </h2>339 <div className="claims-grid">340 {claims.map((c) => (341 <ClaimCard key={c.id} claim={c} />342 ))}343 </div>344 </div>345 )}346347 <div className="board">348 <h2>349 <Glyph kind="source" size={13} /> Sources{" "}350 {citedSources.length > 0 ? `— ${citedSources.length} cited` : `— ${state.sources.size} seen`}351 </h2>352 <ul className="source-list">353 {(citedSources.length > 0354 ? citedSources.map((c) => ({355 key: c.sourceId,356 idx: String(c.index),357 title: c.title ?? c.url,358 url: c.url,359 anchor: `src-${c.index}`360 }))361 : [...state.sources.values()]362 .filter((s) => s.status === "fetched")363 .map((s) => ({364 key: s.id,365 idx: null as string | null,366 title: s.title ?? s.url,367 url: s.url,368 anchor: undefined as string | undefined369 }))370 ).map((s) => (371 <li key={s.key} id={s.anchor}>372 <a className="source-card" href={s.url} target="_blank" rel="noreferrer noopener">373 <DomainTile url={s.url} />374 <span className="meta">375 <span className="title">{s.title}</span>376 <span className="domain">{hostname(s.url)}</span>377 </span>378 {s.idx && <span className="cite-idx">[{s.idx}]</span>}379 </a>380 </li>381 ))}382 </ul>383 </div>384 </section>385 </div>386 </main>387 );388}389390function TimelineRow({ item }: { item: FeedItem }) {391 return (392 <div className={`tl-row ${item.kind === "contradiction" ? "is-contradiction" : ""}`}>393 <Pastille kind={item.kind} spinning={item.pending === true} />394 <div className="tl-card">395 <div className="tl-head">396 {item.label}397 {item.status && (398 <span className={`status-chip ${item.status}`}>399 {item.status}400 {item.confidence !== undefined ? ` · ${Math.round(item.confidence * 100)}%` : ""}401 </span>402 )}403 </div>404 <div className="tl-body">405 {item.title}406 {item.detail && (407 <>408 {" "}409 <span className="mono">— {item.detail}</span>410 </>411 )}412 {item.objectives && (413 <ul className="tl-objectives">414 {item.objectives.map((o, i) => (415 <li key={i}>{o}</li>416 ))}417 </ul>418 )}419 {item.quote && (420 <div className={`tl-quote ${item.stance === "contradicts" ? "contradicts" : ""}`}>“{item.quote}”</div>421 )}422 </div>423 </div>424 </div>425 );426}427428function ClaimCard({ claim }: { claim: Claim }) {429 return (430 <div className="claim-card">431 <div className="tl-head">432 <span className={`status-chip ${claim.status}`}>{claim.status}</span>433 </div>434 <div className="txt">{claim.text}</div>435 {claim.publicReason && <div className="why">{claim.publicReason}</div>}436 <div className={`meter ${claim.status}`}>437 <span className="bar">438 <span className="fill" style={{ width: `${Math.round(claim.confidence * 100)}%` }} />439 </span>440 <span className="pct">{Math.round(claim.confidence * 100)}%</span>441 </div>442 </div>443 );444}445446/** Home-made favicon substitute: deterministic hue tile from the domain. */447function DomainTile({ url }: { url: string }) {448 const domain = hostname(url);449 let hash = 0;450 for (let i = 0; i < domain.length; i++) hash = (hash * 31 + domain.charCodeAt(i)) | 0;451 const hue = Math.abs(hash) % 360;452 return (453 <span className="tile" style={{ background: `hsl(${hue} 42% 42%)` }}>454 {domain.replace(/^www\./, "").charAt(0)}455 </span>456 );457}458459function hostname(url: string): string {460 try {461 return new URL(url).hostname;462 } catch {463 return url;464 }465}466