mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-25 00:36:31 +02:00
The useState was declared but its value was never read in render (only the setter was called). Each setter call scheduled a re-render that changed nothing visible. The actual scroll/highlight behavior is driven by hasScrolledRef and setActiveChunkIndex; this state was effectively dead. Closes #1249
719 lines
23 KiB
TypeScript
719 lines
23 KiB
TypeScript
"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<HTMLDivElement, ChunkCardProps>(
|
||
({ chunk, localIndex, chunkNumber, totalChunks, isCited }, ref) => {
|
||
return (
|
||
<div
|
||
ref={ref}
|
||
data-chunk-index={localIndex}
|
||
className={cn(
|
||
"group relative rounded-2xl border-2 transition-all duration-300",
|
||
isCited
|
||
? "bg-linear-to-br from-primary/5 via-primary/10 to-primary/5 border-primary shadow-lg shadow-primary/10"
|
||
: "bg-card border-border/50 hover:border-border hover:shadow-md"
|
||
)}
|
||
>
|
||
{isCited && <div className="absolute inset-0 rounded-2xl bg-primary/5 blur-xl -z-10" />}
|
||
|
||
<div className="flex items-center justify-between px-5 py-4 border-b border-border/50">
|
||
<div className="flex items-center gap-3">
|
||
<div
|
||
className={cn(
|
||
"flex items-center justify-center w-8 h-8 rounded-full text-sm font-semibold transition-colors",
|
||
isCited
|
||
? "bg-primary text-primary-foreground"
|
||
: "bg-muted text-muted-foreground group-hover:bg-muted/80"
|
||
)}
|
||
>
|
||
{chunkNumber}
|
||
</div>
|
||
<span className="text-sm text-muted-foreground">
|
||
Chunk {chunkNumber} of {totalChunks}
|
||
</span>
|
||
</div>
|
||
{isCited && (
|
||
<Badge variant="default" className="gap-1.5 px-3 py-1">
|
||
<Sparkles className="h-3 w-3" />
|
||
Cited Source
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
|
||
<div className="p-5 overflow-hidden">
|
||
<MarkdownViewer content={chunk.content} maxLength={100_000} />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
)
|
||
);
|
||
ChunkCard.displayName = "ChunkCard";
|
||
|
||
export function SourceDetailPanel({
|
||
open,
|
||
onOpenChange,
|
||
chunkId,
|
||
sourceType,
|
||
title,
|
||
description,
|
||
url,
|
||
children,
|
||
isDocsChunk = false,
|
||
}: SourceDetailPanelProps) {
|
||
const t = useTranslations("dashboard");
|
||
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
||
const hasScrolledRef = useRef(false); // Use ref to avoid stale closures
|
||
const scrollTimersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
|
||
const [activeChunkIndex, setActiveChunkIndex] = useState<number | null>(null);
|
||
const [mounted, setMounted] = useState(false);
|
||
const shouldReduceMotion = useReducedMotion();
|
||
|
||
useEffect(() => {
|
||
setMounted(true);
|
||
}, []);
|
||
|
||
const {
|
||
data: documentData,
|
||
isLoading: isDocumentByChunkFetching,
|
||
error: documentByChunkFetchingError,
|
||
} = useQuery<DocumentData>({
|
||
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 = (
|
||
<AnimatePresence mode="wait">
|
||
{open && (
|
||
<>
|
||
{/* Backdrop */}
|
||
<motion.div
|
||
key="backdrop"
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
exit={{ opacity: 0 }}
|
||
transition={{ duration: 0.2 }}
|
||
className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm"
|
||
onClick={() => onOpenChange(false)}
|
||
/>
|
||
|
||
{/* Panel */}
|
||
<motion.div
|
||
key="panel"
|
||
initial={shouldReduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.95, y: 20 }}
|
||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||
exit={shouldReduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.95, y: 20 }}
|
||
transition={{
|
||
type: "spring",
|
||
damping: 30,
|
||
stiffness: 300,
|
||
}}
|
||
className="fixed inset-3 sm:inset-6 md:inset-10 lg:inset-16 z-50 flex flex-col bg-background rounded-3xl shadow-2xl border overflow-hidden"
|
||
>
|
||
{/* Header */}
|
||
<motion.div
|
||
initial={{ opacity: 0, y: -10 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ delay: 0.1 }}
|
||
className="flex items-center justify-between px-6 py-5 border-b bg-linear-to-r from-muted/50 to-muted/30"
|
||
>
|
||
<div className="min-w-0 flex-1">
|
||
<h2 className="text-xl font-semibold truncate">
|
||
{documentData?.title || title || "Source Document"}
|
||
</h2>
|
||
<p className="text-sm text-muted-foreground mt-0.5">
|
||
{documentData && "document_type" in documentData
|
||
? formatDocumentType(documentData.document_type)
|
||
: sourceType && formatDocumentType(sourceType)}
|
||
{totalChunks > 0 && (
|
||
<span className="ml-2">
|
||
• {totalChunks} chunk{totalChunks !== 1 ? "s" : ""}
|
||
{allChunks.length < totalChunks && ` (showing ${allChunks.length})`}
|
||
</span>
|
||
)}
|
||
</p>
|
||
</div>
|
||
<div className="flex items-center gap-3 shrink-0">
|
||
{url && (
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={(e) => handleUrlClick(e, url)}
|
||
className="hidden sm:flex gap-2 rounded-xl"
|
||
>
|
||
<ExternalLink className="h-4 w-4" />
|
||
Open Source
|
||
</Button>
|
||
)}
|
||
<Button
|
||
size="icon"
|
||
variant="ghost"
|
||
onClick={() => onOpenChange(false)}
|
||
className="h-8 w-8 rounded-full"
|
||
>
|
||
<X className="h-4 w-4" />
|
||
<span className="sr-only">Close</span>
|
||
</Button>
|
||
</div>
|
||
</motion.div>
|
||
|
||
{/* Loading State */}
|
||
{!isDirectRenderSource && isDocumentByChunkFetching && (
|
||
<div className="flex-1 flex items-center justify-center">
|
||
<motion.div
|
||
initial={{ opacity: 0, scale: 0.9 }}
|
||
animate={{ opacity: 1, scale: 1 }}
|
||
className="flex flex-col items-center gap-4"
|
||
>
|
||
<Spinner size="lg" />
|
||
<p className="text-sm text-muted-foreground font-medium">
|
||
{t("loading_document")}
|
||
</p>
|
||
</motion.div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Error State */}
|
||
{!isDirectRenderSource && documentByChunkFetchingError && (
|
||
<div className="flex-1 flex items-center justify-center">
|
||
<motion.div
|
||
initial={{ opacity: 0, scale: 0.9 }}
|
||
animate={{ opacity: 1, scale: 1 }}
|
||
className="flex flex-col items-center gap-4 text-center px-6"
|
||
>
|
||
<div className="w-20 h-20 rounded-full bg-muted/50 flex items-center justify-center">
|
||
<FileQuestionMark className="h-10 w-10 text-muted-foreground" />
|
||
</div>
|
||
<div>
|
||
<p className="font-semibold text-foreground text-lg">Document unavailable</p>
|
||
<p className="text-sm text-muted-foreground mt-2 max-w-md">
|
||
{documentByChunkFetchingError.message ||
|
||
"An unexpected error occurred. Please try again."}
|
||
</p>
|
||
</div>
|
||
<Button variant="outline" onClick={() => onOpenChange(false)} className="mt-2">
|
||
Close Panel
|
||
</Button>
|
||
</motion.div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Direct render for web search providers */}
|
||
{isDirectRenderSource && (
|
||
<ScrollArea className="flex-1">
|
||
<div className="p-6 max-w-3xl mx-auto">
|
||
{url && (
|
||
<Button
|
||
size="default"
|
||
variant="outline"
|
||
onClick={(e) => handleUrlClick(e, url)}
|
||
className="w-full mb-6 sm:hidden rounded-xl"
|
||
>
|
||
<ExternalLink className="mr-2 h-4 w-4" />
|
||
Open in Browser
|
||
</Button>
|
||
)}
|
||
<motion.div
|
||
initial={{ opacity: 0, y: 10 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
className="p-6 bg-muted/50 rounded-2xl border"
|
||
>
|
||
<h3 className="text-base font-semibold mb-4 flex items-center gap-2">
|
||
<BookOpen className="h-4 w-4" />
|
||
Source Information
|
||
</h3>
|
||
<div className="text-sm text-muted-foreground mb-3 font-medium">
|
||
{title || "Untitled"}
|
||
</div>
|
||
<div className="text-sm text-foreground leading-relaxed">
|
||
{description || "No content available"}
|
||
</div>
|
||
</motion.div>
|
||
</div>
|
||
</ScrollArea>
|
||
)}
|
||
|
||
{/* API-fetched document content */}
|
||
{!isDirectRenderSource && documentData && (
|
||
<div className="flex-1 flex overflow-hidden">
|
||
{/* Chunk Navigation Sidebar */}
|
||
{allChunks.length > 1 && (
|
||
<motion.div
|
||
initial={{ opacity: 0, x: -20 }}
|
||
animate={{ opacity: 1, x: 0 }}
|
||
transition={{ delay: 0.2 }}
|
||
className="hidden lg:flex flex-col w-16 border-r bg-muted/10 overflow-hidden"
|
||
>
|
||
<ScrollArea className="flex-1 h-full">
|
||
<div className="p-2 pt-3 flex flex-col gap-1.5">
|
||
{allChunks.map((chunk, idx) => {
|
||
const absNum = absoluteStart + idx + 1;
|
||
const isCited = chunk.id === chunkId;
|
||
const isActive = activeChunkIndex === idx;
|
||
return (
|
||
<motion.button
|
||
key={chunk.id}
|
||
type="button"
|
||
onClick={() => 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 && (
|
||
<span className="absolute -top-1.5 -right-1.5 flex items-center justify-center w-4 h-4 bg-primary rounded-full border-2 border-background shadow-sm">
|
||
<Sparkles className="h-2.5 w-2.5 text-primary-foreground" />
|
||
</span>
|
||
)}
|
||
</motion.button>
|
||
);
|
||
})}
|
||
</div>
|
||
</ScrollArea>
|
||
</motion.div>
|
||
)}
|
||
|
||
{/* Main Content */}
|
||
<ScrollArea className="flex-1" ref={scrollAreaRef}>
|
||
<div className="p-6 lg:p-8 max-w-4xl mx-auto space-y-6">
|
||
{/* Document Metadata */}
|
||
{"document_metadata" in documentData &&
|
||
documentData.document_metadata &&
|
||
Object.keys(documentData.document_metadata).length > 0 && (
|
||
<motion.div
|
||
initial={{ opacity: 0, y: 10 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ delay: 0.1 }}
|
||
className="p-5 bg-muted/30 rounded-2xl border"
|
||
>
|
||
<h3 className="text-sm font-semibold mb-4 text-muted-foreground uppercase tracking-wider flex items-center gap-2">
|
||
<FileText className="h-4 w-4" />
|
||
Document Information
|
||
</h3>
|
||
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
|
||
{Object.entries(documentData.document_metadata).map(([key, value]) => (
|
||
<div key={key} className="space-y-1">
|
||
<dt className="font-medium text-muted-foreground capitalize text-xs">
|
||
{key.replace(/_/g, " ")}
|
||
</dt>
|
||
<dd className="text-foreground wrap-break-word">{String(value)}</dd>
|
||
</div>
|
||
))}
|
||
</dl>
|
||
</motion.div>
|
||
)}
|
||
|
||
{/* Chunks Header */}
|
||
<div className="flex items-center justify-between pt-2">
|
||
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider flex items-center gap-2">
|
||
<Hash className="h-4 w-4" />
|
||
Chunks {absoluteStart + 1}–{absoluteEnd} of {totalChunks}
|
||
</h3>
|
||
{citedChunkIndex !== -1 && (
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => scrollToChunk(citedChunkIndex)}
|
||
className="gap-2 text-primary hover:text-primary"
|
||
>
|
||
<Sparkles className="h-3.5 w-3.5" />
|
||
Jump to cited
|
||
</Button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Load Earlier */}
|
||
{canLoadBefore && (
|
||
<div className="flex items-center justify-center">
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={loadBefore}
|
||
disabled={loadingBefore}
|
||
className="gap-2"
|
||
>
|
||
{loadingBefore ? (
|
||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||
) : (
|
||
<ChevronUp className="h-3.5 w-3.5" />
|
||
)}
|
||
{loadingBefore
|
||
? "Loading..."
|
||
: `Load ${Math.min(EXPAND_SIZE, absoluteStart)} earlier chunks`}
|
||
</Button>
|
||
</div>
|
||
)}
|
||
|
||
{/* Chunks */}
|
||
<div className="space-y-4">
|
||
{allChunks.map((chunk, idx) => {
|
||
const isCited = chunk.id === chunkId;
|
||
const chunkNumber = absoluteStart + idx + 1;
|
||
return (
|
||
<ChunkCard
|
||
key={chunk.id}
|
||
ref={isCited ? citedChunkRefCallback : undefined}
|
||
chunk={chunk}
|
||
localIndex={idx}
|
||
chunkNumber={chunkNumber}
|
||
totalChunks={totalChunks}
|
||
isCited={isCited}
|
||
isActive={activeChunkIndex === idx}
|
||
disableLayoutAnimation={allChunks.length > 30}
|
||
/>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* Load Later */}
|
||
{canLoadAfter && (
|
||
<div className="flex items-center justify-center py-3">
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={loadAfter}
|
||
disabled={loadingAfter}
|
||
className="gap-2"
|
||
>
|
||
{loadingAfter ? (
|
||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||
) : (
|
||
<ChevronDown className="h-3.5 w-3.5" />
|
||
)}
|
||
{loadingAfter
|
||
? "Loading..."
|
||
: `Load ${Math.min(EXPAND_SIZE, totalChunks - absoluteEnd)} later chunks`}
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</ScrollArea>
|
||
</div>
|
||
)}
|
||
</motion.div>
|
||
</>
|
||
)}
|
||
</AnimatePresence>
|
||
);
|
||
|
||
if (!mounted) return <>{children}</>;
|
||
|
||
return (
|
||
<>
|
||
{children}
|
||
{createPortal(panelContent, globalThis.document.body)}
|
||
</>
|
||
);
|
||
}
|