"use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { Citation, ClaimGrounding, VisualGrounding, VisualHighlight } from "@/lib/api"; import { API_BASE } from "@/lib/api"; // ── Color palette ────────────────────────────────────────────────────────── const CITATION_BG = [ "rgba(59,130,246,0.30)", // blue [1] "rgba(16,185,129,0.30)", // green [2] "rgba(245,158,11,0.30)", // amber [3] "rgba(139,92,246,0.30)", // purple[4] "rgba(236,72,153,0.30)", // pink [5] "rgba(14,165,233,0.30)", // sky [6] "rgba(251,191,36,0.30)", // yellow[7] "rgba(239,68,68,0.30)", // red [8] ]; const CITATION_BORDER = [ "rgb(59,130,246)", "rgb(16,185,129)", "rgb(245,158,11)", "rgb(139,92,246)", "rgb(236,72,153)", "rgb(14,165,233)", "rgb(251,191,36)", "rgb(239,68,68)", ]; const SUPPORT_BG: Record = { supported: "rgba(16,185,129,0.25)", partial: "rgba(245,158,11,0.25)", weak: "rgba(245,158,11,0.20)", unsupported: "rgba(239,68,68,0.20)", }; const SUPPORT_BORDER: Record = { supported: "rgb(16,185,129)", partial: "rgb(245,158,11)", weak: "rgb(245,158,11)", unsupported: "rgb(239,68,68)", }; const SUPPORT_LABEL: Record = { supported: "Supported", partial: "Partial", weak: "Weak", unsupported: "Unsupported", }; function citBg(idx: number) { return CITATION_BG[(idx - 1) % CITATION_BG.length]; } function citBorder(idx: number) { return CITATION_BORDER[(idx - 1) % CITATION_BORDER.length]; } // ── Types ────────────────────────────────────────────────────────────────── interface PageKey { docId: string; page: number } function pageKey(p: PageKey) { return `${p.docId}:${p.page}`; } // ── Highlight overlay box ────────────────────────────────────────────────── function HighlightBox({ ev, active, hovered, onHover, onSelect, }: { ev: VisualHighlight; active: boolean; hovered: boolean; onHover: (id: string | null) => void; onSelect: (ev: VisualHighlight) => void; }) { if (!ev.normalized_bbox) return null; const [x0, y0, x1, y1] = ev.normalized_bbox; const bg = active ? SUPPORT_BG[ev.support_type] ?? citBg(ev.color_index) : citBg(ev.color_index); const border = active ? SUPPORT_BORDER[ev.support_type] ?? citBorder(ev.color_index) : citBorder(ev.color_index); const elevated = active || hovered; return (
onHover(ev.citation_id)} onMouseLeave={() => onHover(null)} onClick={() => onSelect(ev)} onKeyDown={(e) => e.key === "Enter" && onSelect(ev)} title={`[${ev.citation_id}] ${ev.block_type} · ${(ev.relevance * 100).toFixed(0)}% relevance`} > [{ev.citation_id}]
); } // ── Large page viewer (center panel) ────────────────────────────────────── function WorkspacePageViewer({ docId, page, highlights, zoom, activeCitation, hoveredCitation, selectedHighlight, onHoverCitation, onSelectHighlight, }: { docId: string; page: number; highlights: VisualHighlight[]; zoom: number; activeCitation: string | null; hoveredCitation: string | null; selectedHighlight: VisualHighlight | null; onHoverCitation: (id: string | null) => void; onSelectHighlight: (ev: VisualHighlight) => void; }) { const [imgLoaded, setImgLoaded] = useState(false); const [imgError, setImgError] = useState(false); const imgRef = useRef(null); const imgUrl = `${API_BASE}/documents/${encodeURIComponent(docId)}/pages/${page}/image`; const pageHighlights = highlights.filter( h => h.page === page && h.normalized_bbox && h.support_type !== "unavailable" ); return (
= 1 ? "none" : "100%", }} > {imgError ? (
📄

Page image not available

This document may need reindexing to generate source page images. Use the Ingest panel to reindex and enable source highlights.

) : ( <> {`Document setImgLoaded(true)} onError={() => setImgError(true)} /> {imgLoaded && pageHighlights.map((ev, i) => ( ))} {!imgLoaded && !imgError && (
Loading page {page}…
)} )}
); } // ── Left panel: Citations + claim grounding ──────────────────────────────── function CitationPanel({ citations, highlights, claimGrounding, activeCitation, hoveredCitation, currentPage, onSelectCitation, onHoverCitation, }: { citations: Citation[]; highlights: VisualHighlight[]; claimGrounding: ClaimGrounding[]; activeCitation: string | null; hoveredCitation: string | null; currentPage: number; onSelectCitation: (cid: string, page: number) => void; onHoverCitation: (id: string | null) => void; }) { return (

Citations

{citations.map(c => { const cid = String(c.marker); const hl = highlights.find(h => h.citation_id === cid); const page = hl?.page ?? c.page ?? null; const isActive = activeCitation === cid; const isHovered = hoveredCitation === cid; const bg = citBg(c.marker); const border = citBorder(c.marker); return ( ); })}
{claimGrounding.length > 0 && ( <>

Claim grounding

{claimGrounding.map(cg => { const cls = { supported: "border-ok/30 bg-ok/5 text-ok", partial: "border-warn/30 bg-warn/5 text-warn", weak: "border-warn/20 bg-warn/5 text-warn", unsupported: "border-bad/30 bg-bad/5 text-bad", }[cg.support_status] ?? "border-edge text-fg3"; return (
[{SUPPORT_LABEL[cg.support_status] ?? cg.support_status}] {cg.text.length > 90 ? cg.text.slice(0, 90) + "…" : cg.text}
); })}
)}
); } // ── Right panel: Evidence details for current page ───────────────────────── function EvidencePanel({ highlights, currentPage, selectedHighlight, hoveredCitation, onSelectHighlight, onHoverCitation, }: { highlights: VisualHighlight[]; currentPage: number; selectedHighlight: VisualHighlight | null; hoveredCitation: string | null; onSelectHighlight: (ev: VisualHighlight | null) => void; onHoverCitation: (id: string | null) => void; }) { const pageHighlights = highlights.filter(h => h.page === currentPage); return (

Evidence · Page {currentPage} {pageHighlights.length > 0 && ( {pageHighlights.length} )}

{pageHighlights.length === 0 && (

No cited evidence on this page.

)}
{pageHighlights.map((ev, i) => { const isSelected = selectedHighlight?.chunk_id === ev.chunk_id; const isHovered = hoveredCitation === ev.citation_id; const border = citBorder(ev.color_index); const bg = citBg(ev.color_index); return ( ); })}
{/* Legend */}

Legend

{[ { label: "Span-level", color: "rgb(16,185,129)", desc: "Exact text match" }, { label: "Page-level", color: "rgb(245,158,11)", desc: "Whole page evidence" }, ].map(({ label, color, desc }) => (

{label}

{desc}

))}
); } // ── Zoom preset buttons ──────────────────────────────────────────────────── const ZOOM_PRESETS = [0.5, 0.75, 1.0, 1.25, 1.5] as const; const ZOOM_LABELS: Record = { 0.5: "50%", 0.75: "75%", 1.0: "fit", 1.25: "125%", 1.5: "150%" }; // ── Main SourceWorkspaceModal ────────────────────────────────────────────── export interface SourceWorkspaceModalProps { grounding: VisualGrounding | null | undefined; citations: Citation[]; activeCitation: string | null; onClose: () => void; } export function SourceWorkspaceModal({ grounding, citations, activeCitation, onClose, }: SourceWorkspaceModalProps) { const highlights = grounding?.highlights ?? []; const claimGrounding = grounding?.claim_grounding ?? []; const warnings = grounding?.warnings ?? []; // Build sorted list of unique (docId, page) pairs from highlights const pageKeys = useMemo(() => { const seen = new Set(); const result: PageKey[] = []; for (const h of highlights) { if (h.page == null || !h.doc_id) continue; const k = pageKey({ docId: h.doc_id, page: h.page }); if (!seen.has(k)) { seen.add(k); result.push({ docId: h.doc_id, page: h.page }); } } return result.sort((a, b) => a.page - b.page); }, [highlights]); // Find initial page from activeCitation const initialPageKey = useMemo(() => { if (activeCitation) { const hl = highlights.find(h => h.citation_id === activeCitation && h.page != null); if (hl?.page != null && hl.doc_id) return { docId: hl.doc_id, page: hl.page }; } return pageKeys[0] ?? null; }, [activeCitation, highlights, pageKeys]); const [currentPageKey, setCurrentPageKey] = useState(initialPageKey); const [zoom, setZoom] = useState(1.0); const [selectedCitation, setSelectedCitation] = useState(activeCitation); const [hoveredCitation, setHoveredCitation] = useState(null); const [selectedHighlight, setSelectedHighlight] = useState(null); const [fullscreen, setFullscreen] = useState(false); const viewerRef = useRef(null); // Keep initial page in sync when activeCitation changes useEffect(() => { if (activeCitation) { const hl = highlights.find(h => h.citation_id === activeCitation && h.page != null); if (hl?.page != null && hl.doc_id) setCurrentPageKey({ docId: hl.doc_id, page: hl.page }); setSelectedCitation(activeCitation); } }, [activeCitation, highlights]); // Escape closes the workspace useEffect(() => { function onKey(e: KeyboardEvent) { if (e.key === "Escape") onClose(); if (e.key === "ArrowRight" || e.key === "ArrowDown") navigatePage(1); if (e.key === "ArrowLeft" || e.key === "ArrowUp") navigatePage(-1); } window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); // eslint-disable-next-line react-hooks/exhaustive-deps }, [onClose, currentPageKey, pageKeys]); function navigatePage(dir: 1 | -1) { if (!currentPageKey) return; const idx = pageKeys.findIndex(p => pageKey(p) === pageKey(currentPageKey)); const next = pageKeys[idx + dir]; if (next) setCurrentPageKey(next); } function handleSelectCitation(cid: string, page: number) { setSelectedCitation(cid); const hl = highlights.find(h => h.citation_id === cid && h.page === page); if (hl?.doc_id) setCurrentPageKey({ docId: hl.doc_id, page }); // Scroll viewer to top when navigating viewerRef.current?.scrollTo({ top: 0, behavior: "smooth" }); } function handleSelectHighlight(ev: VisualHighlight | null) { setSelectedHighlight(ev); if (ev) setSelectedCitation(ev.citation_id); } const currentDoc = currentPageKey?.docId ?? ""; const currentPage = currentPageKey?.page ?? 1; const pageIdx = pageKeys.findIndex(p => pageKey(p) === pageKey(currentPageKey!)); const hasPrev = pageIdx > 0; const hasNext = pageIdx < pageKeys.length - 1; // Stage display const stage = grounding?.grounding_stage ?? "unavailable"; const stageCls = stage === "span" ? "text-ok" : stage === "page" ? "text-warn" : "text-fg3"; const stageDot = stage === "span" ? "bg-ok" : stage === "page" ? "bg-warn" : "bg-fg3"; if (!grounding?.visual_grounding_available) { return (
Grounded Source Workspace
🔍

Visual grounding not available

{warnings.map((w, i) => (

{w}

))}

Reindex your documents to enable exact source highlights.

); } return (
{/* ── Header ──────────────────────────────────────────────────────── */}
Grounded Source Workspace {stage} grounding
{/* Zoom controls */}
{ZOOM_PRESETS.map(z => ( ))} {Math.round(zoom * 100)}%
{/* Page navigation */} {pageKeys.length > 0 && (
p.{currentPage} {pageKeys.length > 1 && ({pageIdx + 1}/{pageKeys.length})}
)} {/* Full-screen toggle */} {/* Warnings badge */} {warnings.length > 0 && ( ⚠ {warnings.length} )}
{/* ── Three-panel body ─────────────────────────────────────────────── */}
{/* LEFT: Citations + claim grounding */} {!fullscreen && ( )} {/* CENTER: Large PDF viewer */}
{/* Page thumbnails / quick-nav when multiple pages */} {pageKeys.length > 1 && (
{pageKeys.map((pk, i) => { const isCurrent = pageKey(pk) === pageKey(currentPageKey!); const hasHL = highlights.some(h => h.page === pk.page && h.doc_id === pk.docId); return ( ); })}
)} {/* Scrollable viewer area */}
{currentPageKey ? ( ) : (
No pages available
)}
{/* RIGHT: Evidence details */} {!fullscreen && ( )}
); }