mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-19 18:45:15 +02:00
CitationPanelContent uses a single instance across different citations (RightPanel.tsx:251 renders without a key), so when the user clicks a new citation while the panel is open, the prior expanded state leaks into the new citation. The reset effect had an empty dependency array, so it only fired on mount. Add chunkId to the effect deps so the expanded state resets each time the citation changes.
230 lines
7.5 KiB
TypeScript
230 lines
7.5 KiB
TypeScript
"use client";
|
|
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { useSetAtom } from "jotai";
|
|
import { ChevronDown, ChevronUp, ExternalLink, XIcon } from "lucide-react";
|
|
import type { FC } from "react";
|
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import { openEditorPanelAtom } from "@/atoms/editor/editor-panel.atom";
|
|
import { MarkdownViewer } from "@/components/markdown-viewer";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Spinner } from "@/components/ui/spinner";
|
|
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
|
|
|
const DEFAULT_CHUNK_WINDOW = 5;
|
|
const EXPANDED_CHUNK_WINDOW = 50;
|
|
|
|
interface CitationPanelContentProps {
|
|
chunkId: number;
|
|
onClose?: () => void;
|
|
}
|
|
|
|
/**
|
|
* Right-panel citation viewer. Shows the cited chunk surrounded by
|
|
* adjacent chunks (±N chunks via the API's `chunk_window` parameter),
|
|
* with the cited one visually highlighted and auto-scrolled into view.
|
|
* The window can be expanded to a wider range, or the user can jump to
|
|
* the full document via the editor panel.
|
|
*/
|
|
export const CitationPanelContent: FC<CitationPanelContentProps> = ({ chunkId, onClose }) => {
|
|
const openEditorPanel = useSetAtom(openEditorPanelAtom);
|
|
const [expanded, setExpanded] = useState(false);
|
|
|
|
useEffect(() => {
|
|
setExpanded(false);
|
|
}, [chunkId]);
|
|
|
|
const chunkWindow = expanded ? EXPANDED_CHUNK_WINDOW : DEFAULT_CHUNK_WINDOW;
|
|
|
|
const { data, isLoading, error } = useQuery({
|
|
queryKey: ["citation-panel", chunkId, chunkWindow] as const,
|
|
queryFn: () =>
|
|
documentsApiService.getDocumentByChunk({
|
|
chunk_id: chunkId,
|
|
chunk_window: chunkWindow,
|
|
}),
|
|
staleTime: 5 * 60 * 1000,
|
|
});
|
|
|
|
const cited = useMemo(() => data?.chunks.find((c) => c.id === chunkId) ?? null, [data, chunkId]);
|
|
|
|
const totalChunks = data?.total_chunks ?? data?.chunks.length ?? 0;
|
|
const startIndex = data?.chunk_start_index ?? 0;
|
|
const citedIndexInWindow = data
|
|
? Math.max(
|
|
0,
|
|
data.chunks.findIndex((c) => c.id === chunkId)
|
|
)
|
|
: 0;
|
|
const shownAbove = citedIndexInWindow;
|
|
const shownBelow = data ? Math.max(0, data.chunks.length - 1 - citedIndexInWindow) : 0;
|
|
const hasMoreAbove = startIndex > 0;
|
|
const hasMoreBelow = data ? startIndex + data.chunks.length < totalChunks : false;
|
|
|
|
// Scroll the cited chunk into view inside the panel's scroll container
|
|
// (not the page). We anchor the scroll to the panel's scroll element
|
|
// so opening the citation doesn't yank the chat scroll on the left.
|
|
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
|
const citedRef = useRef<HTMLDivElement | null>(null);
|
|
useEffect(() => {
|
|
if (!cited) return;
|
|
const id = requestAnimationFrame(() => {
|
|
const container = scrollContainerRef.current;
|
|
const target = citedRef.current;
|
|
if (!container || !target) return;
|
|
const containerRect = container.getBoundingClientRect();
|
|
const targetRect = target.getBoundingClientRect();
|
|
const offset = targetRect.top - containerRect.top + container.scrollTop;
|
|
container.scrollTo({
|
|
top: Math.max(0, offset - 16),
|
|
behavior: "smooth",
|
|
});
|
|
});
|
|
return () => cancelAnimationFrame(id);
|
|
}, [cited]);
|
|
|
|
const handleOpenFullDocument = () => {
|
|
if (!data) return;
|
|
openEditorPanel({
|
|
documentId: data.id,
|
|
searchSpaceId: data.search_space_id,
|
|
title: data.title,
|
|
});
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<div className="shrink-0 border-b">
|
|
<div className="flex h-14 items-center justify-between px-4">
|
|
<h2 className="text-lg font-medium text-muted-foreground select-none">Citation</h2>
|
|
<div className="flex items-center gap-1 shrink-0">
|
|
{onClose && (
|
|
<Button variant="ghost" size="icon" onClick={onClose} className="size-7 shrink-0">
|
|
<XIcon className="size-4" />
|
|
<span className="sr-only">Close citation panel</span>
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex h-10 items-center justify-between gap-2 border-t px-4">
|
|
<div className="min-w-0 flex flex-1 items-center gap-2">
|
|
<p className="truncate text-sm text-muted-foreground">
|
|
{data?.title ?? (isLoading ? "Loading…" : `Chunk #${chunkId}`)}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2 shrink-0 text-[11px] text-muted-foreground">
|
|
<span>Chunk #{chunkId}</span>
|
|
{totalChunks > 0 && <span>· {totalChunks} chunks</span>}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div ref={scrollContainerRef} className="flex-1 overflow-y-auto px-5 py-4">
|
|
{isLoading && (
|
|
<div className="flex items-center gap-2 py-8 text-muted-foreground">
|
|
<Spinner size="sm" />
|
|
<span className="text-sm">Loading citation…</span>
|
|
</div>
|
|
)}
|
|
|
|
{error && (
|
|
<p className="py-8 text-sm text-destructive">
|
|
{error instanceof Error ? error.message : "Failed to load citation"}
|
|
</p>
|
|
)}
|
|
|
|
{!isLoading && !error && data && (
|
|
<>
|
|
{hasMoreAbove && (
|
|
<p className="mb-3 text-center text-[11px] text-muted-foreground">
|
|
… {startIndex} earlier chunk{startIndex === 1 ? "" : "s"} not shown
|
|
</p>
|
|
)}
|
|
<div className="space-y-3">
|
|
{data.chunks.map((chunk) => {
|
|
const isCited = chunk.id === chunkId;
|
|
return (
|
|
<div
|
|
key={chunk.id}
|
|
ref={isCited ? citedRef : null}
|
|
data-cited={isCited || undefined}
|
|
className={
|
|
isCited
|
|
? "rounded-md border-2 border-primary bg-primary/5 px-4 py-3 shadow-sm"
|
|
: "rounded-md border border-border/40 bg-muted/20 px-4 py-3 opacity-70 transition-opacity hover:opacity-100"
|
|
}
|
|
>
|
|
<div className="mb-1.5 flex items-center justify-between">
|
|
<span
|
|
className={
|
|
isCited
|
|
? "text-[11px] font-semibold text-primary"
|
|
: "text-[11px] font-medium text-muted-foreground"
|
|
}
|
|
>
|
|
{isCited ? "Cited chunk" : `Chunk #${chunk.id}`}
|
|
</span>
|
|
{isCited && (
|
|
<span className="text-[11px] text-muted-foreground">#{chunk.id}</span>
|
|
)}
|
|
</div>
|
|
<div className="text-sm">
|
|
<MarkdownViewer content={chunk.content} enableCitations />
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
{hasMoreBelow && (
|
|
<p className="mt-3 text-center text-[11px] text-muted-foreground">
|
|
… {totalChunks - (startIndex + data.chunks.length)} later chunk
|
|
{totalChunks - (startIndex + data.chunks.length) === 1 ? "" : "s"} not shown
|
|
</p>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{!isLoading && !error && data && (
|
|
<div className="shrink-0 flex flex-wrap items-center justify-between gap-2 border-t px-4 py-3">
|
|
<div className="text-[11px] text-muted-foreground">
|
|
Showing {shownAbove} above · cited · {shownBelow} below
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{(hasMoreAbove || hasMoreBelow) && !expanded && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-8 text-xs"
|
|
onClick={() => setExpanded(true)}
|
|
>
|
|
<ChevronDown className="mr-1 size-3.5" />
|
|
More context
|
|
</Button>
|
|
)}
|
|
{expanded && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-8 text-xs"
|
|
onClick={() => setExpanded(false)}
|
|
>
|
|
<ChevronUp className="mr-1 size-3.5" />
|
|
Less
|
|
</Button>
|
|
)}
|
|
<Button
|
|
variant="default"
|
|
size="sm"
|
|
className="h-8 text-xs"
|
|
onClick={handleOpenFullDocument}
|
|
>
|
|
<ExternalLink className="mr-1 size-3.5" />
|
|
Open full document
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
};
|