"use client"; import { useQuery } from "@tanstack/react-query"; import { BookOpen, ChevronDown, ExternalLink, FileText, Hash, Sparkles, X } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { useTranslations } from "next-intl"; import type React from "react"; import { forwardRef, 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 { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; 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 }; index: number; totalChunks: number; isCited: boolean; isActive: boolean; disableLayoutAnimation?: boolean; } const ChunkCard = forwardRef( ({ chunk, index, totalChunks, isCited }, ref) => { return (
{/* Cited indicator glow effect */} {isCited &&
} {/* Header */}
{index + 1}
of {totalChunks} chunks
{isCited && ( Cited Source )}
{/* Content */}
); } ); 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 [summaryOpen, setSummaryOpen] = useState(false); const [activeChunkIndex, setActiveChunkIndex] = useState(null); const [mounted, setMounted] = useState(false); const [_hasScrolledToCited, setHasScrolledToCited] = 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 }); }, enabled: !!chunkId && open, staleTime: 5 * 60 * 1000, }); const isDirectRenderSource = sourceType === "TAVILY_API" || sourceType === "LINKUP_API" || sourceType === "SEARXNG_API" || sourceType === "BAIDU_SEARCH_API"; // Find cited chunk index const citedChunkIndex = documentData?.chunks?.findIndex((chunk) => chunk.id === chunkId) ?? -1; // 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) => { setTimeout(() => { scrollToCitedChunk(); }, delay); }); // After final attempt, mark state as scrolled setTimeout( () => { setHasScrolledToCited(true); setActiveChunkIndex(citedChunkIndex); }, scrollAttempts[scrollAttempts.length - 1] + 50 ); } }, [open, citedChunkIndex] ); // Reset scroll state when panel closes useEffect(() => { if (!open) { hasScrolledRef.current = false; setHasScrolledToCited(false); setActiveChunkIndex(null); } }, [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)} {documentData?.chunks && ( • {documentData.chunks.length} chunk {documentData.chunks.length !== 1 ? "s" : ""} )}

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

{t("loading_document")}

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

Failed to load document

{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 */} {documentData.chunks.length > 1 && (
{documentData.chunks.map((chunk, idx) => { 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 ${idx + 1} (Cited)` : `Chunk ${idx + 1}`} > {idx + 1} {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)}
))}
)} {/* Summary Collapsible */} {documentData.content && ( Document Summary )} {/* Chunks Header */}

Content Chunks

{citedChunkIndex !== -1 && ( )}
{/* Chunks */}
{documentData.chunks.map((chunk, idx) => { const isCited = chunk.id === chunkId; return ( 30} /> ); })}
)}
)}
); if (!mounted) return <>{children}; return ( <> {children} {createPortal(panelContent, globalThis.document.body)} ); }