"use client"; import { useQuery } from "@tanstack/react-query"; import { BookOpen, ChevronDown, ChevronUp, ExternalLink, FileQuestionMark, FileText, Hash, Loader2, Sparkles, X, } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { useTranslations } from "next-intl"; import type React from "react"; import { forwardRef, memo, type ReactNode, useCallback, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { MarkdownViewer } from "@/components/markdown-viewer"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Spinner } from "@/components/ui/spinner"; import type { GetDocumentByChunkResponse, GetSurfsenseDocsByChunkResponse, } from "@/contracts/types/document.types"; import { documentsApiService } from "@/lib/apis/documents-api.service"; import { cacheKeys } from "@/lib/query-client/cache-keys"; import { cn } from "@/lib/utils"; type DocumentData = GetDocumentByChunkResponse | GetSurfsenseDocsByChunkResponse; interface SourceDetailPanelProps { open: boolean; onOpenChange: (open: boolean) => void; chunkId: number; sourceType: string; title: string; description?: string; url?: string; children?: ReactNode; isDocsChunk?: boolean; } const formatDocumentType = (type: string) => { if (!type) return ""; return type .split("_") .map((word) => word.charAt(0) + word.slice(1).toLowerCase()) .join(" "); }; // Chunk card component // For large documents (>30 chunks), we disable animation to prevent layout shifts // which break auto-scroll functionality interface ChunkCardProps { chunk: { id: number; content: string }; localIndex: number; chunkNumber: number; totalChunks: number; isCited: boolean; isActive: boolean; disableLayoutAnimation?: boolean; } const ChunkCard = memo( forwardRef( ({ chunk, localIndex, chunkNumber, totalChunks, isCited }, ref) => { return (
{isCited &&
}
{chunkNumber}
Chunk {chunkNumber} of {totalChunks}
{isCited && ( Cited Source )}
); } ) ); ChunkCard.displayName = "ChunkCard"; export function SourceDetailPanel({ open, onOpenChange, chunkId, sourceType, title, description, url, children, isDocsChunk = false, }: SourceDetailPanelProps) { const t = useTranslations("dashboard"); const scrollAreaRef = useRef(null); const hasScrolledRef = useRef(false); // Use ref to avoid stale closures const scrollTimersRef = useRef[]>([]); const [activeChunkIndex, setActiveChunkIndex] = useState(null); const [mounted, setMounted] = useState(false); const shouldReduceMotion = useReducedMotion(); useEffect(() => { setMounted(true); }, []); const { data: documentData, isLoading: isDocumentByChunkFetching, error: documentByChunkFetchingError, } = useQuery({ queryKey: isDocsChunk ? cacheKeys.documents.byChunk(`doc-${chunkId}`) : cacheKeys.documents.byChunk(chunkId.toString()), queryFn: async () => { if (isDocsChunk) { return documentsApiService.getSurfsenseDocByChunk(chunkId); } return documentsApiService.getDocumentByChunk({ chunk_id: chunkId, chunk_window: 5 }); }, enabled: !!chunkId && open, staleTime: 5 * 60 * 1000, }); const totalChunks = documentData && "total_chunks" in documentData ? (documentData.total_chunks ?? documentData.chunks.length) : (documentData?.chunks?.length ?? 0); const [beforeChunks, setBeforeChunks] = useState< Array<{ id: number; content: string; created_at: string }> >([]); const [afterChunks, setAfterChunks] = useState< Array<{ id: number; content: string; created_at: string }> >([]); const [loadingBefore, setLoadingBefore] = useState(false); const [loadingAfter, setLoadingAfter] = useState(false); useEffect(() => { setBeforeChunks([]); setAfterChunks([]); }, [chunkId, open]); const chunkStartIndex = documentData && "chunk_start_index" in documentData ? (documentData.chunk_start_index ?? 0) : 0; const initialChunks = documentData?.chunks ?? []; const allChunks = [...beforeChunks, ...initialChunks, ...afterChunks]; const absoluteStart = chunkStartIndex - beforeChunks.length; const absoluteEnd = chunkStartIndex + initialChunks.length + afterChunks.length; const canLoadBefore = absoluteStart > 0; const canLoadAfter = absoluteEnd < totalChunks; const EXPAND_SIZE = 10; const loadBefore = useCallback(async () => { if (!documentData || !("search_space_id" in documentData) || !canLoadBefore) return; setLoadingBefore(true); try { const count = Math.min(EXPAND_SIZE, absoluteStart); const result = await documentsApiService.getDocumentChunks({ document_id: documentData.id, page: 0, page_size: count, start_offset: absoluteStart - count, }); const existingIds = new Set(allChunks.map((c) => c.id)); const newChunks = result.items .filter((c) => !existingIds.has(c.id)) .map((c) => ({ id: c.id, content: c.content, created_at: c.created_at })); setBeforeChunks((prev) => [...newChunks, ...prev]); } catch (err) { console.error("Failed to load earlier chunks:", err); } finally { setLoadingBefore(false); } }, [documentData, absoluteStart, canLoadBefore, allChunks]); const loadAfter = useCallback(async () => { if (!documentData || !("search_space_id" in documentData) || !canLoadAfter) return; setLoadingAfter(true); try { const result = await documentsApiService.getDocumentChunks({ document_id: documentData.id, page: 0, page_size: EXPAND_SIZE, start_offset: absoluteEnd, }); const existingIds = new Set(allChunks.map((c) => c.id)); const newChunks = result.items .filter((c) => !existingIds.has(c.id)) .map((c) => ({ id: c.id, content: c.content, created_at: c.created_at })); setAfterChunks((prev) => [...prev, ...newChunks]); } catch (err) { console.error("Failed to load later chunks:", err); } finally { setLoadingAfter(false); } }, [documentData, absoluteEnd, canLoadAfter, allChunks]); const isDirectRenderSource = sourceType === "TAVILY_API" || sourceType === "LINKUP_API" || sourceType === "SEARXNG_API" || sourceType === "BAIDU_SEARCH_API"; const citedChunkIndex = allChunks.findIndex((chunk) => chunk.id === chunkId); // Simple scroll function that scrolls to a chunk by index const scrollToChunkByIndex = useCallback( (chunkIndex: number, smooth = true) => { const scrollContainer = scrollAreaRef.current; if (!scrollContainer) return; const viewport = scrollContainer.querySelector( "[data-radix-scroll-area-viewport]" ) as HTMLElement | null; if (!viewport) return; const chunkElement = scrollContainer.querySelector( `[data-chunk-index="${chunkIndex}"]` ) as HTMLElement | null; if (!chunkElement) return; // Get positions using getBoundingClientRect for accuracy const viewportRect = viewport.getBoundingClientRect(); const chunkRect = chunkElement.getBoundingClientRect(); // Calculate where to scroll to center the chunk const currentScrollTop = viewport.scrollTop; const chunkTopRelativeToViewport = chunkRect.top - viewportRect.top + currentScrollTop; const scrollTarget = chunkTopRelativeToViewport - viewportRect.height / 2 + chunkRect.height / 2; viewport.scrollTo({ top: Math.max(0, scrollTarget), behavior: smooth && !shouldReduceMotion ? "smooth" : "auto", }); setActiveChunkIndex(chunkIndex); }, [shouldReduceMotion] ); // Callback ref for the cited chunk - scrolls when the element mounts const citedChunkRefCallback = useCallback( (node: HTMLDivElement | null) => { if (node && !hasScrolledRef.current && open) { hasScrolledRef.current = true; // Mark immediately to prevent duplicate scrolls // Store the node reference for the delayed scroll const scrollToCitedChunk = () => { const scrollContainer = scrollAreaRef.current; if (!scrollContainer || !node.isConnected) return false; const viewport = scrollContainer.querySelector( "[data-radix-scroll-area-viewport]" ) as HTMLElement | null; if (!viewport) return false; // Get positions const viewportRect = viewport.getBoundingClientRect(); const chunkRect = node.getBoundingClientRect(); // Calculate scroll position to center the chunk const currentScrollTop = viewport.scrollTop; const chunkTopRelativeToViewport = chunkRect.top - viewportRect.top + currentScrollTop; const scrollTarget = chunkTopRelativeToViewport - viewportRect.height / 2 + chunkRect.height / 2; viewport.scrollTo({ top: Math.max(0, scrollTarget), behavior: "auto", // Instant scroll for initial positioning }); return true; }; // Scroll multiple times with delays to handle progressive content rendering // Each subsequent scroll will correct for any layout shifts const scrollAttempts = [50, 150, 300, 600, 1000]; scrollAttempts.forEach((delay) => { scrollTimersRef.current.push( setTimeout(() => { scrollToCitedChunk(); }, delay) ); }); // After final attempt, mark the cited chunk as active scrollTimersRef.current.push( setTimeout( () => { setActiveChunkIndex(citedChunkIndex); }, scrollAttempts[scrollAttempts.length - 1] + 50 ) ); } }, [open, citedChunkIndex] ); // Reset scroll state when panel closes useEffect(() => { if (!open) { scrollTimersRef.current.forEach(clearTimeout); scrollTimersRef.current = []; hasScrolledRef.current = false; setActiveChunkIndex(null); } return () => { scrollTimersRef.current.forEach(clearTimeout); scrollTimersRef.current = []; }; }, [open]); // Handle escape key useEffect(() => { const handleEscape = (e: KeyboardEvent) => { if (e.key === "Escape" && open) { onOpenChange(false); } }; window.addEventListener("keydown", handleEscape); return () => window.removeEventListener("keydown", handleEscape); }, [open, onOpenChange]); // Prevent body scroll when open useEffect(() => { if (open) { document.body.style.overflow = "hidden"; } else { document.body.style.overflow = ""; } return () => { document.body.style.overflow = ""; }; }, [open]); const handleUrlClick = (e: React.MouseEvent, clickUrl: string) => { e.preventDefault(); e.stopPropagation(); window.open(clickUrl, "_blank", "noopener,noreferrer"); }; const scrollToChunk = useCallback( (index: number) => { scrollToChunkByIndex(index, true); }, [scrollToChunkByIndex] ); const panelContent = ( {open && ( <> {/* Backdrop */} onOpenChange(false)} /> {/* Panel */} {/* Header */}

{documentData?.title || title || "Source Document"}

{documentData && "document_type" in documentData ? formatDocumentType(documentData.document_type) : sourceType && formatDocumentType(sourceType)} {totalChunks > 0 && ( • {totalChunks} chunk{totalChunks !== 1 ? "s" : ""} {allChunks.length < totalChunks && ` (showing ${allChunks.length})`} )}

{url && ( )}
{/* Loading State */} {!isDirectRenderSource && isDocumentByChunkFetching && (

{t("loading_document")}

)} {/* Error State */} {!isDirectRenderSource && documentByChunkFetchingError && (

Document unavailable

{documentByChunkFetchingError.message || "An unexpected error occurred. Please try again."}

)} {/* Direct render for web search providers */} {isDirectRenderSource && (
{url && ( )}

Source Information

{title || "Untitled"}
{description || "No content available"}
)} {/* API-fetched document content */} {!isDirectRenderSource && documentData && (
{/* Chunk Navigation Sidebar */} {allChunks.length > 1 && (
{allChunks.map((chunk, idx) => { const absNum = absoluteStart + idx + 1; const isCited = chunk.id === chunkId; const isActive = activeChunkIndex === idx; return ( scrollToChunk(idx)} initial={{ opacity: 0, scale: 0.8 }} animate={{ opacity: 1, scale: 1 }} transition={{ delay: Math.min(idx * 0.02, 0.2) }} className={cn( "relative w-11 h-9 mx-auto rounded-lg text-xs font-semibold transition-all duration-200 flex items-center justify-center", isCited ? "bg-primary text-primary-foreground shadow-md" : isActive ? "bg-muted text-foreground" : "bg-muted/50 text-muted-foreground hover:bg-muted hover:text-foreground" )} title={isCited ? `Chunk ${absNum} (Cited)` : `Chunk ${absNum}`} > {absNum} {isCited && ( )} ); })}
)} {/* Main Content */}
{/* Document Metadata */} {"document_metadata" in documentData && documentData.document_metadata && Object.keys(documentData.document_metadata).length > 0 && (

Document Information

{Object.entries(documentData.document_metadata).map(([key, value]) => (
{key.replace(/_/g, " ")}
{String(value)}
))}
)} {/* Chunks Header */}

Chunks {absoluteStart + 1}–{absoluteEnd} of {totalChunks}

{citedChunkIndex !== -1 && ( )}
{/* Load Earlier */} {canLoadBefore && (
)} {/* Chunks */}
{allChunks.map((chunk, idx) => { const isCited = chunk.id === chunkId; const chunkNumber = absoluteStart + idx + 1; return ( 30} /> ); })}
{/* Load Later */} {canLoadAfter && (
)}
)}
)}
); if (!mounted) return <>{children}; return ( <> {children} {createPortal(panelContent, globalThis.document.body)} ); }