mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-04 21:32:39 +02:00
Stagger setTimeout calls (50ms, 150ms, 300ms, 600ms, 1000ms) and the final state-update timer were never cleared when the panel was closed or unmounted, causing setState on unmounted component warnings. Added scrollTimersRef to track all timeout IDs and clear them when 'open' becomes false or on unmount. Fixes #1092
724 lines
24 KiB
TypeScript
724 lines
24 KiB
TypeScript
"use client";
|
||
|
||
import { useQuery } from "@tanstack/react-query";
|
||
import {
|
||
BookOpen,
|
||
ChevronDown,
|
||
ChevronUp,
|
||
ExternalLink,
|
||
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 [activeChunkIndex, setActiveChunkIndex] = useState<number | null>(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<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]
|
||
);
|
||
|
||
// Track stagger scroll timers so they can be cleared on close/unmount
|
||
const scrollTimersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
|
||
|
||
// 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];
|
||
|
||
// Track all timeout IDs so they can be cleared on close/unmount
|
||
scrollAttempts.forEach((delay) => {
|
||
const id = setTimeout(() => {
|
||
scrollToCitedChunk();
|
||
}, delay);
|
||
scrollTimersRef.current.push(id);
|
||
});
|
||
|
||
// After final attempt, mark state as scrolled
|
||
const doneTimer = setTimeout(
|
||
() => {
|
||
setHasScrolledToCited(true);
|
||
setActiveChunkIndex(citedChunkIndex);
|
||
},
|
||
scrollAttempts[scrollAttempts.length - 1] + 50
|
||
);
|
||
scrollTimersRef.current.push(doneTimer);
|
||
}
|
||
},
|
||
[open, citedChunkIndex]
|
||
);
|
||
|
||
// Clear scroll timers and reset scroll state when panel closes
|
||
useEffect(() => {
|
||
if (!open) {
|
||
scrollTimersRef.current.forEach(clearTimeout);
|
||
scrollTimersRef.current = [];
|
||
hasScrolledRef.current = false;
|
||
setHasScrolledToCited(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-destructive/10 flex items-center justify-center">
|
||
<X className="h-10 w-10 text-destructive" />
|
||
</div>
|
||
<div>
|
||
<p className="font-semibold text-destructive text-lg">
|
||
Failed to load document
|
||
</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)}
|
||
</>
|
||
);
|
||
}
|