mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-26 21:39:43 +02:00
211 lines
6.9 KiB
TypeScript
211 lines
6.9 KiB
TypeScript
"use client";
|
||
|
||
import { useQuery } from "@tanstack/react-query";
|
||
import { useSetAtom } from "jotai";
|
||
import { XIcon } from "lucide-react";
|
||
import type { FC } from "react";
|
||
import { useEffect, useMemo, useRef } 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;
|
||
|
||
interface CitationPanelContentProps {
|
||
chunkId: number;
|
||
onClose?: () => void;
|
||
showHeader?: boolean;
|
||
}
|
||
|
||
/**
|
||
* 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 user can jump to the full document via the editor panel.
|
||
*/
|
||
export const CitationPanelContent: FC<CitationPanelContentProps> = ({
|
||
chunkId,
|
||
onClose,
|
||
showHeader = true,
|
||
}) => {
|
||
const openEditorPanel = useSetAtom(openEditorPanelAtom);
|
||
|
||
const chunkWindow = 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 citedLineLabel = useMemo(() => {
|
||
const start = data?.cited_start_line;
|
||
const end = data?.cited_end_line;
|
||
if (start == null || end == null) return null;
|
||
return start === end ? `Line ${start}` : `Lines ${start}–${end}`;
|
||
}, [data?.cited_start_line, data?.cited_end_line]);
|
||
|
||
const totalChunks = data?.total_chunks ?? data?.chunks.length ?? 0;
|
||
const startIndex = data?.chunk_start_index ?? 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;
|
||
const hasLineAnchor = data.cited_start_line != null && data.cited_end_line != null;
|
||
openEditorPanel({
|
||
documentId: data.id,
|
||
searchSpaceId: data.search_space_id,
|
||
title: data.title,
|
||
highlightLines: hasLineAnchor
|
||
? { start: data.cited_start_line as number, end: data.cited_end_line as number }
|
||
: null,
|
||
forceSourceView: hasLineAnchor,
|
||
});
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<div className="shrink-0">
|
||
{showHeader && (
|
||
<div className="shrink-0 flex h-12 items-center justify-between px-3 border-b">
|
||
<h2 className="select-none text-lg font-semibold">Citation</h2>
|
||
<div className="flex items-center gap-1 shrink-0">
|
||
{onClose && (
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
onClick={onClose}
|
||
className="h-8 w-8 rounded-full shrink-0 text-muted-foreground hover:text-accent-foreground"
|
||
>
|
||
<XIcon className="h-4 w-4" />
|
||
<span className="sr-only">Close citation panel</span>
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
<div className="grid h-10 grid-cols-[minmax(0,1fr)_auto] items-center gap-3 border-b 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-3 shrink-0 text-[11px] text-muted-foreground">
|
||
{citedLineLabel && <span>{citedLineLabel}</span>}
|
||
{totalChunks > 0 && <span>{totalChunks} chunks</span>}
|
||
{!isLoading && !error && data && (
|
||
<Button
|
||
variant="default"
|
||
size="sm"
|
||
className="h-6 px-1.5 text-[11px]"
|
||
onClick={handleOpenFullDocument}
|
||
>
|
||
Open
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div ref={scrollContainerRef} className="flex-1 overflow-y-auto px-5 py-4">
|
||
{isLoading && (
|
||
<div className="flex min-h-full items-center justify-center text-muted-foreground">
|
||
<Spinner size="md" />
|
||
</div>
|
||
)}
|
||
|
||
{error && (
|
||
<div className="flex min-h-full items-center justify-center text-center">
|
||
<p className="text-sm text-destructive">
|
||
{error instanceof Error ? error.message : "Failed to load citation"}
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{!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-accent px-4 py-3 shadow-sm"
|
||
: "rounded-md bg-accent 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] text-muted-foreground"
|
||
: "text-[11px] font-medium text-muted-foreground"
|
||
}
|
||
>
|
||
Chunk #{chunk.id}
|
||
</span>
|
||
{isCited && (
|
||
<span className="text-[11px] font-semibold text-primary">
|
||
{citedLineLabel ? `Cited chunk · ${citedLineLabel}` : "Cited chunk"}
|
||
</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>
|
||
</>
|
||
);
|
||
};
|