/** * Search-box.ai * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai * File: apps/web/components/SessionView.tsx * Description: Live research view — event-sourced UI (bloom graph, pastille timeline, claims, answer). */ "use client"; import { useEffect, useMemo, useReducer, useRef, useState } from "react"; import type { ResearchEventPayload, CitationMapEntry } from "@search-box/events"; import type { Claim, Contradiction, SessionStatus, Source } from "@search-box/shared"; import { AnswerPanel } from "./AnswerPanel"; import { ResearchGraph } from "./ResearchGraph"; import { Glyph, Pastille, type PastilleKind } from "./Pastille"; /* ------------------------------- view state ------------------------------- */ interface FeedItem { key: string; kind: PastilleKind; label: string; title: string; detail?: string; quote?: string; stance?: string; status?: string; confidence?: number; objectives?: string[]; pending?: boolean; } interface ViewState { status: SessionStatus; feed: FeedItem[]; objectives: string[]; answer: string; answerDone: boolean; citations: CitationMapEntry[]; sources: Map; claims: Map; contradictions: Contradiction[]; error: string | null; seq: number; } function initialState(status: SessionStatus): ViewState { return { status, feed: [], objectives: [], answer: "", answerDone: false, citations: [], sources: new Map(), claims: new Map(), contradictions: [], error: null, seq: 0 }; } function reduce(state: ViewState, ev: { seq: number; payload: ResearchEventPayload }): ViewState { const p = ev.payload; const next: ViewState = { ...state, seq: ev.seq }; const push = (item: FeedItem) => { next.feed = [...next.feed, item]; }; switch (p.type) { case "session.status": next.status = p.status; break; case "session.failed": next.error = p.error; push({ key: `e${ev.seq}`, kind: "failed", label: "failed", title: p.error }); break; case "session.completed": push({ key: `e${ev.seq}`, kind: "done", label: "research complete", title: "Answer delivered with full provenance." }); break; case "plan.updated": next.objectives = p.objectives; push({ key: `e${ev.seq}`, kind: "plan", label: "plan", title: p.publicReason, objectives: p.objectives }); break; case "thought": push({ key: `e${ev.seq}`, kind: "thought", label: "reasoning", title: p.publicReason }); break; case "action.started": push({ key: p.actionId, kind: p.kind === "search" ? "search" : "fetch", label: p.kind === "search" ? "searching" : "reading", title: p.label, pending: true }); break; case "action.completed": next.feed = next.feed.map((item) => item.key === p.actionId ? { ...item, pending: false, label: p.ok ? (item.kind === "search" ? "searched" : "read source") : `${item.kind} failed`, kind: p.ok && item.kind === "fetch" ? "read" : item.kind, detail: p.summary } : item ); break; case "source.added": case "source.updated": { const sources = new Map(next.sources); sources.set(p.source.id, p.source); next.sources = sources; break; } case "claim.added": { const claims = new Map(next.claims); claims.set(p.claim.id, p.claim); next.claims = claims; push({ key: `e${ev.seq}`, kind: "claim", label: "new claim", title: p.claim.text }); break; } case "claim.updated": { const claims = new Map(next.claims); claims.set(p.claim.id, p.claim); next.claims = claims; push({ key: `e${ev.seq}`, kind: "belief", label: "belief update", title: p.claim.text, status: p.claim.status, confidence: p.claim.confidence, detail: p.claim.publicReason ?? undefined }); break; } case "evidence.added": push({ key: `e${ev.seq}`, kind: "evidence", label: `evidence · ${p.evidence.stance}`, title: p.sourceTitle ?? p.sourceUrl, quote: p.evidence.quote, stance: p.evidence.stance }); break; case "contradiction.added": next.contradictions = [...next.contradictions, p.contradiction]; push({ key: `e${ev.seq}`, kind: "contradiction", label: "contradiction", title: p.contradiction.description }); break; case "synthesis.started": push({ key: `e${ev.seq}`, kind: "synthesis", label: "writing", title: "Evidence base closed — writing the answer." }); break; case "answer.delta": next.answer = state.answer + p.delta; break; case "answer.completed": next.answer = p.answer; next.answerDone = true; next.citations = p.citations; break; default: break; } return next; } /* --------------------------------- view ---------------------------------- */ type Tab = "live" | "answer" | "evidence"; export function SessionView({ id, question, initialStatus }: { id: string; question: string; initialStatus: SessionStatus; }) { const [state, dispatch] = useReducer( (s: ViewState, ev: { seq: number; payload: ResearchEventPayload }) => reduce(s, ev), initialStatus, initialState ); const [tab, setTab] = useState("live"); const feedEndRef = useRef(null); const autoTabbed = useRef(false); // Event-sourced: replay from seq 0; EventSource reconnects with Last-Event-ID. useEffect(() => { const es = new EventSource(`/api/research/${id}/stream?from=0`); es.onmessage = (msg) => { try { const payload = JSON.parse(msg.data) as ResearchEventPayload; dispatch({ seq: Number(msg.lastEventId || 0), payload }); } catch { // ignore malformed frames } }; es.addEventListener("end", () => es.close()); return () => es.close(); }, [id]); useEffect(() => { if (state.answer.length > 0 && !autoTabbed.current) { autoTabbed.current = true; if (window.innerWidth < 1024) setTab("answer"); } }, [state.answer]); useEffect(() => { if (tab === "live") feedEndRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest" }); }, [state.feed.length, tab]); const claims = useMemo(() => [...state.claims.values()], [state.claims]); const stats = useMemo(() => { const fetched = [...state.sources.values()].filter((s) => s.status === "fetched").length; const avg = claims.length > 0 ? claims.reduce((a, c) => a + c.confidence, 0) / claims.length : null; return { sources: fetched, claims: claims.length, contradictions: state.contradictions.length, confidence: avg }; }, [state.sources, claims, state.contradictions]); const live = state.status === "running" || state.status === "synthesizing" || state.status === "pending"; const citedSources = useMemo(() => { if (state.citations.length > 0) return state.citations; return [...state.sources.values()] .filter((s) => s.citationIndex !== null) .sort((a, b) => (a.citationIndex ?? 0) - (b.citationIndex ?? 0)) .map((s) => ({ index: s.citationIndex as number, sourceId: s.id, url: s.url, title: s.title })); }, [state.citations, state.sources]); const phaseLabel = state.status === "failed" ? "failed" : state.status === "completed" ? "complete" : state.status === "synthesizing" ? "writing answer" : "researching"; return (
research session

{question}

{phaseLabel} sources {stats.sources} claims {stats.claims} contradictions {stats.contradictions} confidence {stats.confidence === null ? "—" : `${Math.round(stats.confidence * 100)}%`}
{(state.objectives.length > 0 || claims.length > 0) && ( 0} /> )} {state.error && (
research failed: {state.error}
)}
{( [ ["live", "plan"], ["answer", "synthesis"], ["evidence", "evidence"] ] as Array<[Tab, PastilleKind]> ).map(([t, icon]) => ( ))}

Live research

{state.feed.map((item) => ( ))} {state.feed.length === 0 && (
waiting for the first research event…
)}

Answer

{claims.length > 0 && (

Claims under investigation

{claims.map((c) => ( ))}
)}

Sources{" "} {citedSources.length > 0 ? `— ${citedSources.length} cited` : `— ${state.sources.size} seen`}

    {(citedSources.length > 0 ? citedSources.map((c) => ({ key: c.sourceId, idx: String(c.index), title: c.title ?? c.url, url: c.url, anchor: `src-${c.index}` })) : [...state.sources.values()] .filter((s) => s.status === "fetched") .map((s) => ({ key: s.id, idx: null as string | null, title: s.title ?? s.url, url: s.url, anchor: undefined as string | undefined })) ).map((s) => (
  • {s.title} {hostname(s.url)} {s.idx && [{s.idx}]}
  • ))}
); } function TimelineRow({ item }: { item: FeedItem }) { return (
{item.label} {item.status && ( {item.status} {item.confidence !== undefined ? ` · ${Math.round(item.confidence * 100)}%` : ""} )}
{item.title} {item.detail && ( <> {" "} — {item.detail} )} {item.objectives && (
    {item.objectives.map((o, i) => (
  • {o}
  • ))}
)} {item.quote && (
“{item.quote}”
)}
); } function ClaimCard({ claim }: { claim: Claim }) { return (
{claim.status}
{claim.text}
{claim.publicReason &&
{claim.publicReason}
}
{Math.round(claim.confidence * 100)}%
); } /** Home-made favicon substitute: deterministic hue tile from the domain. */ function DomainTile({ url }: { url: string }) { const domain = hostname(url); let hash = 0; for (let i = 0; i < domain.length; i++) hash = (hash * 31 + domain.charCodeAt(i)) | 0; const hue = Math.abs(hash) % 360; return ( {domain.replace(/^www\./, "").charAt(0)} ); } function hostname(url: string): string { try { return new URL(url).hostname; } catch { return url; } }