feat: various UI fixes, prompt optimizations, and allowing duplicate docs

- Updated `content_hash` in the `Document` model to remove global uniqueness, allowing identical content across different paths.
- Enhanced `_create_document` function to handle path uniqueness and prevent session-poisoning from `IntegrityError`.
- Added detailed comments for clarity on the changes and their implications.
- Introduced new citation handling in the editor for improved user experience with citation jumps.
- Updated package dependencies in the frontend for better functionality.
This commit is contained in:
DESKTOP-RTLN3BA\$punk 2026-04-28 21:30:53 -07:00
parent e6433f78c4
commit b9a66cb417
26 changed files with 1540 additions and 852 deletions

View file

@ -1,26 +1,45 @@
"use client";
import { FileText } from "lucide-react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useSetAtom } from "jotai";
import { ExternalLink, FileText } from "lucide-react";
import type { FC } from "react";
import { useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { pendingChunkHighlightAtom } from "@/atoms/document-viewer/pending-chunk-highlight.atom";
import { openEditorPanelAtom } from "@/atoms/editor/editor-panel.atom";
import { useCitationMetadata } from "@/components/assistant-ui/citation-metadata-context";
import { SourceDetailPanel } from "@/components/new-chat/source-detail-panel";
import { MarkdownViewer } from "@/components/markdown-viewer";
import { Citation } from "@/components/tool-ui/citation";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { documentsApiService } from "@/lib/apis/documents-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
interface InlineCitationProps {
chunkId: number;
isDocsChunk?: boolean;
}
const POPOVER_HOVER_CLOSE_DELAY_MS = 150;
/**
* Inline citation for knowledge-base chunks (numeric chunk IDs).
* Renders a clickable badge showing the actual chunk ID that opens the SourceDetailPanel.
* Negative chunk IDs indicate anonymous/synthetic uploads and render as a static badge.
* Inline citation badge for knowledge-base chunks (numeric chunk IDs) and
* Surfsense documentation chunks (`isDocsChunk`). Negative chunk IDs render as
* a static "doc" pill (anonymous/synthetic uploads).
*
* Numeric KB chunks: clicking resolves the parent document via
* `getDocumentByChunk`, opens the document in the right side panel (alongside
* the chat does not replace it), and stages the cited chunk text in
* `pendingChunkHighlightAtom` so `EditorPanelContent` can scroll to and softly
* highlight it inside the rendered markdown.
*
* Surfsense docs chunks: rendered as a hover-controlled shadcn Popover that
* lazily fetches and previews the cited chunk inline, since those docs aren't
* indexed into the user's search space and have no tab to open.
*/
export const InlineCitation: FC<InlineCitationProps> = ({ chunkId, isDocsChunk = false }) => {
const [isOpen, setIsOpen] = useState(false);
if (chunkId < 0) {
return (
<Tooltip>
@ -38,26 +57,185 @@ export const InlineCitation: FC<InlineCitationProps> = ({ chunkId, isDocsChunk =
);
}
if (isDocsChunk) {
return <SurfsenseDocCitation chunkId={chunkId} />;
}
return <NumericChunkCitation chunkId={chunkId} />;
};
const NumericChunkCitation: FC<{ chunkId: number }> = ({ chunkId }) => {
const queryClient = useQueryClient();
const setPendingHighlight = useSetAtom(pendingChunkHighlightAtom);
const openEditorPanel = useSetAtom(openEditorPanelAtom);
const [resolving, setResolving] = useState(false);
const handleClick = useCallback(async () => {
if (resolving) return;
setResolving(true);
console.log("[citation:click] start", { chunkId });
try {
const data = await queryClient.fetchQuery({
// Local key with explicit window. The shared `cacheKeys.documents.byChunk`
// is window-agnostic (latent footgun); namespace the call to avoid
// reusing a different-window cached result.
queryKey: ["documents", "by-chunk", chunkId, "w0"] as const,
queryFn: () =>
documentsApiService.getDocumentByChunk({ chunk_id: chunkId, chunk_window: 0 }),
staleTime: 5 * 60 * 1000,
});
const cited = data.chunks.find((c) => c.id === chunkId) ?? data.chunks[0];
console.log("[citation:click] fetched doc-by-chunk", {
docId: data.id,
docTitle: data.title,
chunksReturned: data.chunks.length,
citedChunkId: cited?.id,
citedChunkContentLen: cited?.content?.length ?? 0,
citedChunkPreview:
cited?.content && cited.content.length > 120
? `${cited.content.slice(0, 120)}…(+${cited.content.length - 120})`
: (cited?.content ?? ""),
});
// Stage the highlight BEFORE opening the panel so `EditorPanelContent`
// already sees the pending intent on its very first render — avoids a
// "fetch → render → no-pending → next-tick render with pending" race.
setPendingHighlight({
documentId: data.id,
chunkId,
chunkText: cited?.content ?? "",
});
openEditorPanel({
documentId: data.id,
searchSpaceId: data.search_space_id,
title: data.title,
});
console.log("[citation:click] staged highlight + opened editor panel", {
documentId: data.id,
});
} catch (err) {
console.warn("[citation:click] failed", err);
toast.error(err instanceof Error ? err.message : "Couldn't open cited document");
} finally {
setResolving(false);
}
}, [chunkId, openEditorPanel, queryClient, resolving, setPendingHighlight]);
return (
<SourceDetailPanel
open={isOpen}
onOpenChange={setIsOpen}
chunkId={chunkId}
sourceType={isDocsChunk ? "SURFSENSE_DOCS" : ""}
title={isDocsChunk ? "Surfsense Documentation" : "Source"}
description=""
url=""
isDocsChunk={isDocsChunk}
<button
type="button"
onClick={handleClick}
disabled={resolving}
className="ml-0.5 inline-flex h-5 min-w-5 cursor-pointer items-center justify-center rounded-md bg-muted/60 px-1.5 text-[11px] font-medium text-muted-foreground align-baseline shadow-sm transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none disabled:cursor-progress disabled:opacity-70"
title={`View source chunk #${chunkId}`}
aria-label={`Jump to cited chunk ${chunkId}`}
>
<button
type="button"
onClick={() => setIsOpen(true)}
className="ml-0.5 inline-flex h-5 min-w-5 cursor-pointer items-center justify-center rounded-md bg-muted/60 px-1.5 text-[11px] font-medium text-muted-foreground align-baseline shadow-sm transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none"
title={`View source chunk #${chunkId}`}
{resolving ? <Spinner size="xs" /> : chunkId}
</button>
);
};
const SurfsenseDocCitation: FC<{ chunkId: number }> = ({ chunkId }) => {
const [open, setOpen] = useState(false);
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const cancelClose = useCallback(() => {
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
}, []);
const scheduleClose = useCallback(() => {
cancelClose();
closeTimerRef.current = setTimeout(() => {
setOpen(false);
closeTimerRef.current = null;
}, POPOVER_HOVER_CLOSE_DELAY_MS);
}, [cancelClose]);
useEffect(() => () => cancelClose(), [cancelClose]);
const { data, isLoading, error } = useQuery({
queryKey: cacheKeys.documents.byChunk(`doc-${chunkId}`),
queryFn: () => documentsApiService.getSurfsenseDocByChunk(chunkId),
enabled: open,
staleTime: 5 * 60 * 1000,
});
const citedChunk = data?.chunks.find((c) => c.id === chunkId) ?? data?.chunks[0];
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
onClick={() => setOpen((prev) => !prev)}
onMouseEnter={() => {
cancelClose();
setOpen(true);
}}
onMouseLeave={scheduleClose}
onFocus={() => {
cancelClose();
setOpen(true);
}}
onBlur={scheduleClose}
className="ml-0.5 inline-flex h-5 min-w-5 cursor-pointer items-center justify-center gap-0.5 rounded-md bg-primary/10 px-1.5 text-[11px] font-medium text-primary align-baseline shadow-sm transition-colors hover:bg-primary/15 focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none"
aria-label={`Show Surfsense documentation chunk ${chunkId}`}
title="Surfsense documentation"
>
<FileText className="size-3" />
doc
</button>
</PopoverTrigger>
<PopoverContent
className="w-96 max-w-[calc(100vw-2rem)] p-0"
align="start"
sideOffset={6}
onMouseEnter={cancelClose}
onMouseLeave={scheduleClose}
onOpenAutoFocus={(e) => e.preventDefault()}
>
{chunkId}
</button>
</SourceDetailPanel>
<div className="flex items-center justify-between gap-2 border-b px-3 py-2">
<div className="min-w-0">
<p className="truncate text-sm font-medium">
{data?.title ?? "Surfsense documentation"}
</p>
<p className="text-[11px] text-muted-foreground">Chunk #{chunkId}</p>
</div>
{data?.source && (
<a
href={data.source}
target="_blank"
rel="noopener noreferrer"
className="inline-flex shrink-0 items-center gap-1 rounded-md px-2 py-1 text-[11px] font-medium text-primary hover:bg-primary/10"
>
<ExternalLink className="size-3" />
Open
</a>
)}
</div>
<div className="max-h-72 overflow-auto px-3 py-2 text-sm">
{isLoading && (
<div className="flex items-center gap-2 py-4 text-muted-foreground">
<Spinner size="xs" />
<span className="text-xs">Loading</span>
</div>
)}
{error && (
<p className="py-4 text-xs text-destructive">
{error instanceof Error ? error.message : "Failed to load chunk"}
</p>
)}
{!isLoading && !error && citedChunk?.content && (
<MarkdownViewer content={citedChunk.content} maxLength={1500} />
)}
{!isLoading && !error && !citedChunk?.content && (
<p className="py-4 text-xs text-muted-foreground">No content available.</p>
)}
</div>
</PopoverContent>
</Popover>
);
};

View file

@ -1,5 +1,6 @@
"use client";
import { FindReplacePlugin } from "@platejs/find-replace";
import { useAtomValue, useSetAtom } from "jotai";
import {
Check,
@ -14,17 +15,21 @@ import {
import dynamic from "next/dynamic";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { pendingChunkHighlightAtom } from "@/atoms/document-viewer/pending-chunk-highlight.atom";
import { closeEditorPanelAtom, editorPanelAtom } from "@/atoms/editor/editor-panel.atom";
import { VersionHistoryButton } from "@/components/documents/version-history";
import type { PlateEditorInstance } from "@/components/editor/plate-editor";
import { SourceCodeEditor } from "@/components/editor/source-code-editor";
import { MarkdownViewer } from "@/components/markdown-viewer";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer";
import { CITATION_HIGHLIGHT_CLASS } from "@/components/ui/search-highlight-node";
import { Spinner } from "@/components/ui/spinner";
import { useMediaQuery } from "@/hooks/use-media-query";
import { useElectronAPI } from "@/hooks/use-platform";
import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth-utils";
import { buildCitationSearchCandidates } from "@/lib/citation-search";
import { inferMonacoLanguageFromPath } from "@/lib/editor-language";
const PlateEditor = dynamic(
@ -32,7 +37,10 @@ const PlateEditor = dynamic(
{ ssr: false, loading: () => <EditorPanelSkeleton /> }
);
type CitationHighlightStatus = "exact" | "miss";
const LARGE_DOCUMENT_THRESHOLD = 2 * 1024 * 1024; // 2MB
const CITATION_MAX_LENGTH = 16 * 1024 * 1024; // 16MB on-demand cap for citation jumps
interface EditorContent {
document_id: number;
@ -136,6 +144,61 @@ export function EditorPanelContent({
const [displayTitle, setDisplayTitle] = useState(title || "Untitled");
const isLocalFileMode = kind === "local_file";
const editorRenderMode: EditorRenderMode = isLocalFileMode ? "source_code" : "rich_markdown";
// --- Citation-jump highlight wiring ----------------------------------
// `EditorPanelContent` is the consumer of `pendingChunkHighlightAtom`: when
// a citation badge is clicked, the badge stages `{documentId, chunkId,
// chunkText}` and opens this panel. We drive Plate's `FindReplacePlugin`
// (registered in every preset) to highlight the cited text natively via
// Slate decorations — no DOM walking, no Range gymnastics. The state
// machine below escalates the document fetch from 2MB → 16MB once if no
// candidate snippet matched in the preview, and surfaces miss outcomes
// via an inline alert.
const pending = useAtomValue(pendingChunkHighlightAtom);
const setPendingHighlight = useSetAtom(pendingChunkHighlightAtom);
const [fetchKey, setFetchKey] = useState(0);
const [maxLengthOverride, setMaxLengthOverride] = useState<number | null>(null);
const [highlightResult, setHighlightResult] = useState<CitationHighlightStatus | null>(null);
const editorRef = useRef<PlateEditorInstance | null>(null);
const escalatedForRef = useRef<number | null>(null);
const lastAppliedChunkIdRef = useRef<number | null>(null);
// Tracks whether a citation highlight is currently decorated in the
// editor. We use a ref (not state) because the click-to-dismiss handler
// runs in a stable callback that would otherwise close over stale state.
const isHighlightActiveRef = useRef(false);
// Once a citation jump targets this doc we have to keep `PlateEditor`
// mounted for the *rest of the doc session* — even after the highlight
// effect clears `pendingChunkHighlightAtom` (which it does as soon as
// the decoration is applied, so a follow-up citation on the same chunk
// can re-trigger). Without this latch, non-editable docs would re-render
// back into `MarkdownViewer` the instant `pending` is released, tearing
// down the Plate decorations and dropping the highlight after a frame.
const [stickyPlateMode, setStickyPlateMode] = useState(false);
const clearCitationSearch = useCallback(() => {
isHighlightActiveRef.current = false;
const editor = editorRef.current;
if (!editor) return;
try {
editor.setOption(FindReplacePlugin, "search", "");
editor.api.redecorate();
} catch (err) {
console.warn("[EditorPanelContent] clearCitationSearch failed:", err);
}
}, []);
// Dismiss the highlight when the user interacts with the editor surface.
// `onPointerDown` fires before focus / selection changes so the click
// itself feels responsive — the highlight clears in the same event tick
// that places the cursor. No-op when nothing is highlighted, so we don't
// thrash `redecorate` on every click in normal editing.
const handleEditorPointerDown = useCallback(() => {
if (!isHighlightActiveRef.current) return;
clearCitationSearch();
setHighlightResult(null);
}, [clearCitationSearch]);
const isCitationTarget = !!pending && !isLocalFileMode && pending.documentId === documentId;
const resolveLocalVirtualPath = useCallback(
async (candidatePath: string): Promise<string> => {
if (!electronAPI?.getAgentFilesystemMounts) {
@ -155,6 +218,8 @@ export function EditorPanelContent({
const isLargeDocument = (editorDoc?.content_size_bytes ?? 0) > LARGE_DOCUMENT_THRESHOLD;
// `fetchKey` is an explicit re-fetch trigger (escalation bumps it to force
// a new request even when documentId/searchSpaceId haven't changed).
useEffect(() => {
const controller = new AbortController();
setIsLoading(true);
@ -166,6 +231,12 @@ export function EditorPanelContent({
setIsEditing(false);
initialLoadDone.current = false;
changeCountRef.current = 0;
// Clear any in-flight FindReplacePlugin search before the editor
// re-mounts on new content (a fresh editor key is generated below
// from documentId + isEditing, so the previous editor + its
// decorations are about to be discarded anyway, but we belt-and-
// brace here for the case where only `fetchKey` changed).
clearCitationSearch();
const doFetch = async () => {
try {
@ -210,7 +281,11 @@ export function EditorPanelContent({
const url = new URL(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/editor-content`
);
url.searchParams.set("max_length", String(LARGE_DOCUMENT_THRESHOLD));
url.searchParams.set("max_length", String(maxLengthOverride ?? LARGE_DOCUMENT_THRESHOLD));
// `fetchKey` participates here so biome's noUnusedVariables sees it
// as consumed; bumping it forces a fresh request even when the URL
// is otherwise identical.
if (fetchKey > 0) url.searchParams.set("_n", String(fetchKey));
const response = await authenticatedFetch(url.toString(), { method: "GET" });
@ -256,8 +331,259 @@ export function EditorPanelContent({
resolveLocalVirtualPath,
searchSpaceId,
title,
fetchKey,
maxLengthOverride,
clearCitationSearch,
]);
// Reset citation-jump bookkeeping whenever the panel switches to a different
// document (or local file). Body only writes setters — the deps are the
// real triggers we want to react to.
// biome-ignore lint/correctness/useExhaustiveDependencies: documentId/localFilePath are intentional triggers.
useEffect(() => {
clearCitationSearch();
escalatedForRef.current = null;
lastAppliedChunkIdRef.current = null;
setHighlightResult(null);
setMaxLengthOverride(null);
setFetchKey(0);
// Drop sticky Plate mode when the panel moves to a different doc
// — the next doc starts in its preferred render mode (Plate for
// editable, MarkdownViewer for everything else) until/unless a
// citation jump targets it.
setStickyPlateMode(false);
}, [documentId, localFilePath, clearCitationSearch]);
// Latch sticky Plate mode the first time a citation jump targets this
// doc. We keep it sticky for the remainder of this doc session so the
// highlight effect's `setPendingHighlight(null)` doesn't unmount the
// editor mid-flight (see comment on `stickyPlateMode` declaration).
useEffect(() => {
if (isCitationTarget) setStickyPlateMode(true);
}, [isCitationTarget]);
// `isEditorReady` is what `useEffect` actually depends on — `editorRef`
// is a ref so changes don't trigger re-runs. We flip this to `true` once
// `PlateEditor` calls back with its live editor instance (its
// `usePlateEditor` value-init runs synchronously, so by the time this
// flips true the markdown is already deserialized into the Slate tree).
const [isEditorReady, setIsEditorReady] = useState(false);
const handleEditorReady = useCallback((editor: PlateEditorInstance | null) => {
console.log("[citation:editor] handleEditorReady", { ready: !!editor });
editorRef.current = editor;
setIsEditorReady(!!editor);
}, []);
// --- Citation jump highlight effect -----------------------------------
// Drives Plate's FindReplacePlugin to highlight the cited chunk:
// 1. Build candidate snippets from the chunk text (first sentence,
// first 8 words, full chunk if short). Plate's decorate runs per-
// block and won't cross block boundaries, so the shorter
// candidates exist to give us something that fits in one
// paragraph / heading.
// 2. For each candidate: setOption('search', ...) → redecorate →
// wait two animation frames for React to flush → query the editor
// DOM for `.${CITATION_HIGHLIGHT_CLASS}`. First hit wins.
//
// Why a className and not a `data-*` attribute? Plate's
// `PlateLeaf` runs its props through `useNodeAttributes`, which
// only forwards `attributes`, `className`, `ref`, and `style` —
// arbitrary `data-*` attributes are silently dropped. `className`
// is the only escape hatch guaranteed to survive into the DOM.
// 3. On hit: smooth-scroll the first match into view, mark the
// highlight active (so a click inside the editor can dismiss it),
// release the pending atom.
// 4. On terminal miss: if the doc was truncated and we haven't
// escalated yet, bump the fetch's `max_length` to the citation
// cap and re-fetch — the post-refetch render will re-run this
// effect against the larger preview. Otherwise, release the
// atom and show the miss alert.
useEffect(() => {
console.log("[citation:effect] fired", {
isCitationTarget,
pendingDocId: pending?.documentId,
pendingChunkId: pending?.chunkId,
pendingChunkTextLen: pending?.chunkText?.length,
documentId,
isLocalFileMode,
isEditing,
hasMarkdown: !!editorDoc?.source_markdown,
markdownLen: editorDoc?.source_markdown?.length,
truncated: editorDoc?.truncated,
isEditorReady,
editorRefSet: !!editorRef.current,
maxLengthOverride,
});
if (!isCitationTarget || !pending) {
console.log("[citation:effect] guard ✗ no citation target / no pending");
return;
}
if (isLocalFileMode || isEditing) {
console.log("[citation:effect] guard ✗ localFileMode/editing");
return;
}
if (!editorDoc?.source_markdown) {
console.log("[citation:effect] guard ✗ source_markdown not ready");
return;
}
if (!isEditorReady) {
console.log("[citation:effect] guard ✗ editor not ready yet");
return;
}
const editor = editorRef.current;
if (!editor) {
console.log("[citation:effect] guard ✗ editorRef.current is null");
return;
}
if (lastAppliedChunkIdRef.current !== pending.chunkId) {
lastAppliedChunkIdRef.current = pending.chunkId;
}
let cancelled = false;
const finishMiss = () => {
console.log("[citation:effect] terminal miss — no candidate matched");
try {
editor.setOption(FindReplacePlugin, "search", "");
editor.api.redecorate();
} catch (err) {
console.warn("[EditorPanelContent] reset search after miss failed:", err);
}
const canEscalate =
editorDoc.truncated === true &&
(maxLengthOverride ?? LARGE_DOCUMENT_THRESHOLD) < CITATION_MAX_LENGTH &&
escalatedForRef.current !== pending.chunkId;
console.log("[citation:effect] miss decision", {
truncated: editorDoc.truncated,
currentMaxLength: maxLengthOverride ?? LARGE_DOCUMENT_THRESHOLD,
canEscalate,
});
if (canEscalate) {
escalatedForRef.current = pending.chunkId;
setMaxLengthOverride(CITATION_MAX_LENGTH);
setFetchKey((k) => k + 1);
// Keep the atom set so the post-refetch render re-runs.
return;
}
setHighlightResult("miss");
setPendingHighlight(null);
};
const tryCandidates = async () => {
const candidates = buildCitationSearchCandidates(pending.chunkText);
console.log("[citation:effect] candidates built", {
count: candidates.length,
previews: candidates.map((c) => c.slice(0, 60)),
});
if (candidates.length === 0) {
if (!cancelled) finishMiss();
return;
}
// Resolve the editor's rendered DOM root via Slate's stable
// `[data-slate-editor="true"]` attribute (set by slate-react's
// `<Editable>`). Scoping queries to this root prevents
// `<mark>` elements rendered elsewhere on the page (e.g. chat
// search-highlight leaves in another mounted PlateEditor) from
// being mistaken for citation hits.
const editorRoot = document.querySelector<HTMLElement>('[data-slate-editor="true"]');
console.log("[citation:effect] editor root", {
hasRoot: !!editorRoot,
});
const root: ParentNode = editorRoot ?? document;
for (let i = 0; i < candidates.length; i++) {
const candidate = candidates[i];
if (cancelled) return;
try {
editor.setOption(FindReplacePlugin, "search", candidate);
editor.api.redecorate();
console.log(`[citation:effect] try #${i} setOption + redecorate`, {
len: candidate.length,
preview: candidate.slice(0, 80),
});
} catch (err) {
console.warn("[EditorPanelContent] setOption/redecorate failed:", err);
continue;
}
// Two rAFs: first lets Slate flush its onChange, second lets
// React commit the decoration leaves into the DOM.
await new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
);
if (cancelled) return;
// Primary probe: by our stable class on the rendered <mark>.
let el = root.querySelector<HTMLElement>(`.${CITATION_HIGHLIGHT_CLASS}`);
const classMarkCount = root.querySelectorAll(`.${CITATION_HIGHLIGHT_CLASS}`).length;
// Diagnostic fallback: any <mark> inside the editor root.
// If we ever see allMarks > 0 but classMarkCount === 0,
// the className was stripped again and we need to revisit
// `useNodeAttributes` filtering.
const allMarkCount = root.querySelectorAll("mark").length;
if (!el && allMarkCount > 0) {
el = root.querySelector<HTMLElement>("mark");
}
console.log(`[citation:effect] try #${i} DOM probe`, {
foundEl: !!el,
classMarkCount,
allMarkCount,
usedFallback: !!el && classMarkCount === 0,
});
if (el) {
try {
el.scrollIntoView({ block: "center", behavior: "smooth" });
} catch {
el.scrollIntoView();
}
isHighlightActiveRef.current = true;
setHighlightResult("exact");
console.log(`[citation:effect] ✓ exact via candidate #${i} — atom released`);
// No auto-clear timer — the highlight is intentionally
// permanent until the user clicks inside the editor (see
// `handleEditorPointerDown`) or another dismissal trigger
// fires (doc switch, edit-mode toggle, panel unmount,
// next citation jump). Sticky Plate mode keeps the
// editor mounted after the atom clears.
setPendingHighlight(null);
return;
}
}
if (!cancelled) finishMiss();
};
void tryCandidates();
return () => {
cancelled = true;
};
}, [
isCitationTarget,
pending,
documentId,
editorDoc?.source_markdown,
editorDoc?.truncated,
isLocalFileMode,
isEditing,
isEditorReady,
maxLengthOverride,
clearCitationSearch,
setPendingHighlight,
]);
// Cleanup any active highlight on unmount.
useEffect(() => {
return () => clearCitationSearch();
}, [clearCitationSearch]);
// Toggling into edit mode swaps Plate out of readOnly. Clear the citation
// search so stale leaves don't linger in the editing surface.
useEffect(() => {
if (isEditing) {
clearCitationSearch();
setHighlightResult(null);
}
}, [isEditing, clearCitationSearch]);
useEffect(() => {
return () => {
if (copyResetTimeoutRef.current) {
@ -367,6 +693,15 @@ export function EditorPanelContent({
EDITABLE_DOCUMENT_TYPES.has(editorDoc.document_type ?? "")) &&
!isLargeDocument
: false;
// Use PlateEditor for any of:
// - Editable doc types (FILE/NOTE) — existing editing UX.
// - Active citation jump in flight (`isCitationTarget`) — covers the
// mount in the very first render where the atom is set but the
// sticky effect hasn't fired yet.
// - Sticky Plate mode latched on a previous citation jump — keeps
// the editor mounted (with its decorations) after the highlight
// effect clears the atom. Resets when the doc changes.
const renderInPlateEditor = isEditableType || isCitationTarget || stickyPlateMode;
const hasUnsavedChanges = editedMarkdown !== null;
const showDesktopHeader = !!onClose;
const showEditingActions = isEditableType && isEditing;
@ -381,6 +716,90 @@ export function EditorPanelContent({
setIsEditing(false);
}, [editorDoc?.source_markdown]);
const handleDownloadMarkdown = useCallback(async () => {
if (!searchSpaceId || !documentId) return;
setDownloading(true);
try {
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/download-markdown`,
{ method: "GET" }
);
if (!response.ok) throw new Error("Download failed");
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
const disposition = response.headers.get("content-disposition");
const match = disposition?.match(/filename="(.+)"/);
a.download = match?.[1] ?? `${editorDoc?.title || "document"}.md`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
toast.success("Download started");
} catch {
toast.error("Failed to download document");
} finally {
setDownloading(false);
}
}, [documentId, editorDoc?.title, searchSpaceId]);
// We no longer surface an "approximate" status — Plate's FindReplacePlugin
// either decorates an exact match or it doesn't, and the candidate snippet
// strategy (first sentence → first 8 words → full chunk) means we either
// land on the citation start or fall through to the miss alert.
const showMissAlert = isCitationTarget && highlightResult === "miss";
const citationAlerts = showMissAlert && (
<Alert variant="destructive" className="mb-4">
<FileQuestionMark className="size-4" />
<AlertDescription className="flex items-center justify-between gap-4">
<span>Cited section couldn&apos;t be located in this view.</span>
{editorDoc?.truncated && (
<Button
variant="outline"
size="sm"
className="relative shrink-0"
disabled={downloading}
onClick={handleDownloadMarkdown}
>
<span className={`flex items-center gap-1.5 ${downloading ? "opacity-0" : ""}`}>
<Download className="size-3.5" />
Download .md
</span>
{downloading && <Spinner size="sm" className="absolute" />}
</Button>
)}
</AlertDescription>
</Alert>
);
const largeDocAlert = isLargeDocument && !isLocalFileMode && editorDoc && (
<Alert className="mb-4">
<FileText className="size-4" />
<AlertDescription className="flex items-center justify-between gap-4">
<span>
This document is too large for the editor (
{Math.round((editorDoc.content_size_bytes ?? 0) / 1024 / 1024)}MB,{" "}
{editorDoc.chunk_count ?? 0} chunks). Showing a preview below.
</span>
<Button
variant="outline"
size="sm"
className="relative shrink-0"
disabled={downloading}
onClick={handleDownloadMarkdown}
>
<span className={`flex items-center gap-1.5 ${downloading ? "opacity-0" : ""}`}>
<Download className="size-3.5" />
Download .md
</span>
{downloading && <Spinner size="sm" className="absolute" />}
</Button>
</AlertDescription>
</Alert>
);
return (
<>
{showDesktopHeader ? (
@ -565,61 +984,6 @@ export function EditorPanelContent({
</p>
</div>
</div>
) : isLargeDocument && !isLocalFileMode ? (
<div className="h-full overflow-y-auto px-5 py-4">
<Alert className="mb-4">
<FileText className="size-4" />
<AlertDescription className="flex items-center justify-between gap-4">
<span>
This document is too large for the editor (
{Math.round((editorDoc.content_size_bytes ?? 0) / 1024 / 1024)}MB,{" "}
{editorDoc.chunk_count ?? 0} chunks). Showing a preview below.
</span>
<Button
variant="outline"
size="sm"
className="relative shrink-0"
disabled={downloading}
onClick={async () => {
setDownloading(true);
try {
if (!searchSpaceId || !documentId) {
throw new Error("Missing document context");
}
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/download-markdown`,
{ method: "GET" }
);
if (!response.ok) throw new Error("Download failed");
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
const disposition = response.headers.get("content-disposition");
const match = disposition?.match(/filename="(.+)"/);
a.download = match?.[1] ?? `${editorDoc.title || "document"}.md`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
toast.success("Download started");
} catch {
toast.error("Failed to download document");
} finally {
setDownloading(false);
}
}}
>
<span className={`flex items-center gap-1.5 ${downloading ? "opacity-0" : ""}`}>
<Download className="size-3.5" />
Download .md
</span>
{downloading && <Spinner size="sm" className="absolute" />}
</Button>
</AlertDescription>
</Alert>
<MarkdownViewer content={editorDoc.source_markdown} />
</div>
) : editorRenderMode === "source_code" ? (
<div className="h-full overflow-hidden">
<SourceCodeEditor
@ -638,20 +1002,46 @@ export function EditorPanelContent({
}}
/>
</div>
) : isEditableType ? (
<PlateEditor
key={`${isLocalFileMode ? (localFilePath ?? "local-file") : documentId}-${isEditing ? "editing" : "viewing"}`}
preset="full"
markdown={editorDoc.source_markdown}
onMarkdownChange={handleMarkdownChange}
readOnly={!isEditing}
placeholder="Start writing..."
editorVariant="default"
allowModeToggle={false}
reserveToolbarSpace
defaultEditing={isEditing}
className="[&_[role=toolbar]]:!bg-sidebar"
/>
) : isLargeDocument && !isLocalFileMode && !isCitationTarget ? (
// Large doc, no active citation — fast Streamdown preview
// + download CTA. We only fall back to MarkdownViewer here
// because Plate is heavy on multi-MB docs and the user
// isn't waiting on a specific citation to render.
<div className="h-full overflow-y-auto px-5 py-4">
{largeDocAlert}
<MarkdownViewer content={editorDoc.source_markdown} />
</div>
) : renderInPlateEditor ? (
// Editable doc (FILE/NOTE) OR active citation jump (any
// doc type). The citation path uses Plate's
// FindReplacePlugin for native, decoration-based
// highlighting — see the citation-jump highlight effect
// above for how `editorRef` and `handleEditorReady` are
// wired.
<div className="flex h-full min-h-0 flex-col">
{(citationAlerts || (isLargeDocument && isCitationTarget && !isLocalFileMode)) && (
<div className="shrink-0 px-5 pt-4">
{isLargeDocument && isCitationTarget && largeDocAlert}
{citationAlerts}
</div>
)}
<div className="flex-1 min-h-0 overflow-hidden" onPointerDown={handleEditorPointerDown}>
<PlateEditor
key={`${isLocalFileMode ? (localFilePath ?? "local-file") : documentId}-${isEditing ? "editing" : "viewing"}`}
preset="full"
markdown={editorDoc.source_markdown}
onMarkdownChange={handleMarkdownChange}
readOnly={!isEditing}
placeholder="Start writing..."
editorVariant="default"
allowModeToggle={false}
reserveToolbarSpace
defaultEditing={isEditing}
className="[&_[role=toolbar]]:!bg-sidebar"
onEditorReady={handleEditorReady}
/>
</div>
</div>
) : (
<div className="h-full overflow-y-auto px-5 py-4">
<MarkdownViewer content={editorDoc.source_markdown} />

View file

@ -12,6 +12,12 @@ import { type EditorPreset, presetMap } from "@/components/editor/presets";
import { escapeMdxExpressions } from "@/components/editor/utils/escape-mdx";
import { Editor, EditorContainer } from "@/components/ui/editor";
/** Live editor instance returned by `usePlateEditor`. Exposed via the
* `onEditorReady` prop so callers (e.g. `EditorPanelContent`) can drive
* plugin options imperatively most notably setting
* `FindReplacePlugin`'s `search` option for citation-jump highlights. */
export type PlateEditorInstance = ReturnType<typeof usePlateEditor>;
export interface PlateEditorProps {
/** Markdown string to load as initial content */
markdown?: string;
@ -62,6 +68,15 @@ export interface PlateEditorProps {
* without modifying the core editor component.
*/
extraPlugins?: AnyPluginConfig[];
/**
* Called whenever the live editor instance (re)mounts, with `null` on
* unmount. Used by callers that need to drive plugin options imperatively
* e.g. `EditorPanelContent` setting `FindReplacePlugin`'s `search`
* option for citation-jump highlights. The callback is invoked exactly
* once per editor lifetime (the parent's `key` prop forces a fresh
* editor when needed, e.g. on edit-mode toggle).
*/
onEditorReady?: (editor: PlateEditorInstance | null) => void;
}
function PlateEditorContent({
@ -100,6 +115,7 @@ export function PlateEditor({
defaultEditing = false,
preset = "full",
extraPlugins = [],
onEditorReady,
}: PlateEditorProps) {
const lastMarkdownRef = useRef(markdown);
const lastHtmlRef = useRef(html);
@ -156,6 +172,21 @@ export function PlateEditor({
: undefined,
});
// Expose the live editor instance to imperative callers (e.g. citation
// jump highlights). We deliberately don't depend on `onEditorReady`
// itself in the cleanup closure — callers commonly pass an arrow that
// closes over a stable ref setter, but if they pass a freshly-bound
// callback per render, the `onEditorReady?.(editor)` re-fires which is
// idempotent for ref-style setters.
const onEditorReadyRef = useRef(onEditorReady);
useEffect(() => {
onEditorReadyRef.current = onEditorReady;
}, [onEditorReady]);
useEffect(() => {
onEditorReadyRef.current?.(editor);
return () => onEditorReadyRef.current?.(null);
}, [editor]);
// Update editor content when html prop changes externally
useEffect(() => {
if (html !== undefined && html !== lastHtmlRef.current) {

View file

@ -1,5 +1,6 @@
"use client";
import { FindReplacePlugin } from "@platejs/find-replace";
import type { AnyPluginConfig } from "platejs";
import { TrailingBlockPlugin } from "platejs";
@ -17,6 +18,30 @@ import { SelectionKit } from "@/components/editor/plugins/selection-kit";
import { SlashCommandKit } from "@/components/editor/plugins/slash-command-kit";
import { TableKit } from "@/components/editor/plugins/table-kit";
import { ToggleKit } from "@/components/editor/plugins/toggle-kit";
import { SearchHighlightLeaf } from "@/components/ui/search-highlight-node";
/**
* Citation-jump highlighter. Re-uses Plate's built-in `FindReplacePlugin`
* (decorate-only, no editing surface) to drive the "scroll-to-cited-text"
* UX in `EditorPanelContent`. We register it in every preset because:
* - Decorate is a no-op when `search` is empty (single getOptions() check
* per block), so cost is effectively zero for non-citation viewers.
* - Keeping it preset-agnostic means citations work whether the doc is
* opened in editable (`full`) or pure-viewer (`readonly`) modes.
*
* The parent component drives `setOption(FindReplacePlugin, 'search', ...)`
* + `editor.api.redecorate()` to trigger highlights, then queries the
* editor DOM for `.citation-highlight-leaf` to scroll the first match
* into view. (We can't use a `data-*` attribute here — Plate's
* `PlateLeaf` runs props through `useNodeAttributes`, which only forwards
* `attributes`, `className`, `ref`, `style`; arbitrary `data-*` props are
* silently dropped.) See `components/ui/search-highlight-node.tsx` for
* the leaf component and `CITATION_HIGHLIGHT_CLASS` constant.
*/
const CitationFindReplacePlugin = FindReplacePlugin.configure({
options: { search: "" },
render: { node: SearchHighlightLeaf },
});
/**
* Full preset every plugin kit enabled.
@ -38,6 +63,7 @@ export const fullPreset: AnyPluginConfig[] = [
...AutoformatKit,
...DndKit,
TrailingBlockPlugin,
CitationFindReplacePlugin,
];
/**
@ -52,6 +78,7 @@ export const minimalPreset: AnyPluginConfig[] = [
...LinkKit,
...AutoformatKit,
TrailingBlockPlugin,
CitationFindReplacePlugin,
];
/**
@ -68,6 +95,7 @@ export const readonlyPreset: AnyPluginConfig[] = [
...CalloutKit,
...ToggleKit,
...MathKit,
CitationFindReplacePlugin,
];
/** All available preset names */

View file

@ -1,719 +0,0 @@
"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)}
</>
);
}

View file

@ -67,9 +67,6 @@ const DesktopShortcutsContent = dynamic(
import(
"@/app/dashboard/[search_space_id]/user-settings/components/DesktopShortcutsContent"
).then((m) => ({ default: m.DesktopShortcutsContent })),
import(
"@/app/dashboard/[search_space_id]/user-settings/components/DesktopShortcutsContent"
).then((m) => ({ default: m.DesktopShortcutsContent })),
{ ssr: false }
);
const MemoryContent = dynamic(

View file

@ -0,0 +1,45 @@
"use client";
import type { PlateLeafProps } from "platejs/react";
import { PlateLeaf } from "platejs/react";
/**
* Stable class name used to identify Plate-rendered citation highlight
* leaves in the DOM. We can't use a `data-*` attribute here — Plate's
* `PlateLeaf` runs its props through `useNodeAttributes`, which only
* forwards `attributes`, `className`, `ref`, and `style` to the rendered
* element; arbitrary `data-*` props are silently dropped (verified
* against `@platejs/core/dist/react/index.js` v52). So `className` is
* the only escape hatch that's guaranteed to survive into the DOM.
*/
export const CITATION_HIGHLIGHT_CLASS = "citation-highlight-leaf";
/**
* Leaf rendered for ranges decorated by `@platejs/find-replace`'s
* `FindReplacePlugin`. We re-purpose that plugin to drive the citation-jump
* highlight: when a citation is staged, the parent sets the plugin's `search`
* option to a snippet of the chunk text and Plate decorates every match with
* `searchHighlight: true`. This component renders those decorations as a
* `<mark>` tagged with `CITATION_HIGHLIGHT_CLASS` so the parent can:
* 1. Query the first match in DOM order to scroll it into view.
* 2. Detect the active-highlight state without a separate React ref.
*
* The highlight is **persistent** it does not auto-fade. The parent in
* `EditorPanelContent` clears it by setting the plugin's `search` option
* back to "" when one of: (a) the user clicks anywhere inside the editor,
* (b) the panel switches to a different document, (c) the user toggles
* into edit mode, (d) another citation jump is staged, (e) the panel
* unmounts. We use a brief entrance pulse (`citation-flash-in`, see
* `globals.css`) purely to draw the eye after `scrollIntoView` lands.
*/
export function SearchHighlightLeaf(props: PlateLeafProps) {
return (
<PlateLeaf
{...props}
as="mark"
className={`${CITATION_HIGHLIGHT_CLASS} bg-primary/15 ring-1 ring-primary/40 rounded-sm px-0.5 text-inherit animate-[citation-flash-in_400ms_ease-out]`}
>
{props.children}
</PlateLeaf>
);
}