refactor: citation viewer

This commit is contained in:
DESKTOP-RTLN3BA\$punk 2026-04-28 23:25:26 -07:00
parent ca9bbee06d
commit f23be16b35
14 changed files with 362 additions and 755 deletions

View file

@ -1,13 +1,11 @@
"use client";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { useSetAtom } from "jotai";
import { ExternalLink, FileText } from "lucide-react";
import type { FC } 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 { openCitationPanelAtom } from "@/atoms/citation/citation-panel.atom";
import { useCitationMetadata } from "@/components/assistant-ui/citation-metadata-context";
import { MarkdownViewer } from "@/components/markdown-viewer";
import { Citation } from "@/components/tool-ui/citation";
@ -29,11 +27,11 @@ const POPOVER_HOVER_CLOSE_DELAY_MS = 150;
* 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.
* Numeric KB chunks: clicking opens the citation panel in the right
* sidebar (alongside the chat does not replace it). The panel shows
* the cited chunk surrounded by adjacent chunks (via the API's
* `chunk_window`), with the cited one highlighted and an option to
* expand the window or jump into the full document via the editor panel.
*
* Surfsense docs chunks: rendered as a hover-controlled shadcn Popover that
* lazily fetches and previews the cited chunk inline, since those docs aren't
@ -65,71 +63,17 @@ export const InlineCitation: FC<InlineCitationProps> = ({ chunkId, isDocsChunk =
};
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]);
const openCitationPanel = useSetAtom(openCitationPanelAtom);
return (
<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"
onClick={() => openCitationPanel({ chunkId })}
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}`}
aria-label={`Jump to cited chunk ${chunkId}`}
aria-label={`View cited chunk ${chunkId}`}
>
{resolving ? <Spinner size="xs" /> : chunkId}
{chunkId}
</button>
);
};

View file

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

View file

@ -1,6 +1,5 @@
"use client";
import { FindReplacePlugin } from "@platejs/find-replace";
import { useAtomValue, useSetAtom } from "jotai";
import {
Check,
@ -15,21 +14,17 @@ 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(
@ -37,10 +32,7 @@ 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;
@ -145,60 +137,6 @@ export function EditorPanelContent({
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) {
@ -218,8 +156,6 @@ 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);
@ -231,12 +167,6 @@ 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 {
@ -281,11 +211,7 @@ 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(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));
url.searchParams.set("max_length", String(LARGE_DOCUMENT_THRESHOLD));
const response = await authenticatedFetch(url.toString(), { method: "GET" });
@ -331,259 +257,8 @@ 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) {
@ -617,7 +292,7 @@ export function EditorPanelContent({
}, [editorDoc?.source_markdown]);
const handleSave = useCallback(
async (_options?: { silent?: boolean }) => {
async (options?: { silent?: boolean }) => {
setSaving(true);
try {
if (isLocalFileMode) {
@ -668,11 +343,15 @@ export function EditorPanelContent({
setEditorDoc((prev) => (prev ? { ...prev, source_markdown: markdownRef.current } : prev));
setEditedMarkdown(null);
toast.success("Document saved! Reindexing in background...");
if (!options?.silent) {
toast.success("Document saved! Reindexing in background...");
}
return true;
} catch (err) {
console.error("Error saving document:", err);
toast.error(err instanceof Error ? err.message : "Failed to save document");
if (!options?.silent) {
toast.error(err instanceof Error ? err.message : "Failed to save document");
}
return false;
} finally {
setSaving(false);
@ -693,15 +372,11 @@ 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;
// Render through PlateEditor for editable doc types (FILE/NOTE).
// Everything else (large docs, non-editable types) falls back to the
// lightweight `MarkdownViewer` — Plate is heavy on multi-MB docs and
// non-editable types don't benefit from its editing UX.
const renderInPlateEditor = isEditableType;
const hasUnsavedChanges = editedMarkdown !== null;
const showDesktopHeader = !!onClose;
const showEditingActions = isEditableType && isEditing;
@ -744,36 +419,6 @@ export function EditorPanelContent({
}
}, [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" />
@ -1002,30 +647,17 @@ export function EditorPanelContent({
}}
/>
</div>
) : 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.
) : isLargeDocument && !isLocalFileMode ? (
// Large doc — fast Streamdown preview + download CTA.
// Plate is heavy on multi-MB docs.
<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.
// Editable doc (FILE/NOTE) — Plate editing UX.
<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}>
<div className="flex-1 min-h-0 overflow-hidden">
<PlateEditor
key={`${isLocalFileMode ? (localFilePath ?? "local-file") : documentId}-${isEditing ? "editing" : "viewing"}`}
preset="full"
@ -1037,8 +669,7 @@ export function EditorPanelContent({
allowModeToggle={false}
reserveToolbarSpace
defaultEditing={isEditing}
className="[&_[role=toolbar]]:!bg-sidebar"
onEditorReady={handleEditorReady}
className="**:[[role=toolbar]]:bg-sidebar!"
/>
</div>
</div>

View file

@ -12,10 +12,7 @@ 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. */
/** Live editor instance returned by `usePlateEditor`. */
export type PlateEditorInstance = ReturnType<typeof usePlateEditor>;
export interface PlateEditorProps {
@ -68,15 +65,6 @@ 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({
@ -115,7 +103,6 @@ export function PlateEditor({
defaultEditing = false,
preset = "full",
extraPlugins = [],
onEditorReady,
}: PlateEditorProps) {
const lastMarkdownRef = useRef(markdown);
const lastHtmlRef = useRef(html);
@ -172,21 +159,6 @@ 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,6 +1,5 @@
"use client";
import { FindReplacePlugin } from "@platejs/find-replace";
import type { AnyPluginConfig } from "platejs";
import { TrailingBlockPlugin } from "platejs";
@ -18,30 +17,6 @@ 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.
@ -63,7 +38,6 @@ export const fullPreset: AnyPluginConfig[] = [
...AutoformatKit,
...DndKit,
TrailingBlockPlugin,
CitationFindReplacePlugin,
];
/**
@ -78,7 +52,6 @@ export const minimalPreset: AnyPluginConfig[] = [
...LinkKit,
...AutoformatKit,
TrailingBlockPlugin,
CitationFindReplacePlugin,
];
/**
@ -95,7 +68,6 @@ export const readonlyPreset: AnyPluginConfig[] = [
...CalloutKit,
...ToggleKit,
...MathKit,
CitationFindReplacePlugin,
];
/** All available preset names */

View file

@ -6,6 +6,7 @@ import dynamic from "next/dynamic";
import { startTransition, useEffect } from "react";
import { closeHitlEditPanelAtom, hitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { closeReportPanelAtom, reportPanelAtom } from "@/atoms/chat/report-panel.atom";
import { citationPanelAtom, closeCitationPanelAtom } from "@/atoms/citation/citation-panel.atom";
import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms";
import { closeEditorPanelAtom, editorPanelAtom } from "@/atoms/editor/editor-panel.atom";
import { rightPanelCollapsedAtom, rightPanelTabAtom } from "@/atoms/layout/right-panel.atom";
@ -21,6 +22,14 @@ const EditorPanelContent = dynamic(
{ ssr: false, loading: () => null }
);
const CitationPanelContent = dynamic(
() =>
import("@/components/citation-panel/citation-panel").then((m) => ({
default: m.CitationPanelContent,
})),
{ ssr: false, loading: () => null }
);
const HitlEditPanelContent = dynamic(
() =>
import("@/components/hitl-edit-panel/hitl-edit-panel").then((m) => ({
@ -69,12 +78,14 @@ export function RightPanelExpandButton() {
const reportState = useAtomValue(reportPanelAtom);
const editorState = useAtomValue(editorPanelAtom);
const hitlEditState = useAtomValue(hitlEditPanelAtom);
const citationState = useAtomValue(citationPanelAtom);
const reportOpen = reportState.isOpen && !!reportState.reportId;
const editorOpen =
editorState.isOpen &&
(editorState.kind === "document" ? !!editorState.documentId : !!editorState.localFilePath);
const hitlEditOpen = hitlEditState.isOpen && !!hitlEditState.onSave;
const hasContent = documentsOpen || reportOpen || editorOpen || hitlEditOpen;
const citationOpen = citationState.isOpen && citationState.chunkId != null;
const hasContent = documentsOpen || reportOpen || editorOpen || hitlEditOpen || citationOpen;
if (!collapsed || !hasContent) return null;
@ -98,7 +109,13 @@ export function RightPanelExpandButton() {
);
}
const PANEL_WIDTHS = { sources: 420, report: 640, editor: 640, "hitl-edit": 640 } as const;
const PANEL_WIDTHS = {
sources: 420,
report: 640,
editor: 640,
"hitl-edit": 640,
citation: 560,
} as const;
export function RightPanel({ documentsPanel }: RightPanelProps) {
const [activeTab] = useAtom(rightPanelTabAtom);
@ -108,6 +125,8 @@ export function RightPanel({ documentsPanel }: RightPanelProps) {
const closeEditor = useSetAtom(closeEditorPanelAtom);
const hitlEditState = useAtomValue(hitlEditPanelAtom);
const closeHitlEdit = useSetAtom(closeHitlEditPanelAtom);
const citationState = useAtomValue(citationPanelAtom);
const closeCitation = useSetAtom(closeCitationPanelAtom);
const [collapsed, setCollapsed] = useAtom(rightPanelCollapsedAtom);
const documentsOpen = documentsPanel?.open ?? false;
@ -116,37 +135,59 @@ export function RightPanel({ documentsPanel }: RightPanelProps) {
editorState.isOpen &&
(editorState.kind === "document" ? !!editorState.documentId : !!editorState.localFilePath);
const hitlEditOpen = hitlEditState.isOpen && !!hitlEditState.onSave;
const citationOpen = citationState.isOpen && citationState.chunkId != null;
useEffect(() => {
if (!reportOpen && !editorOpen && !hitlEditOpen) return;
if (!reportOpen && !editorOpen && !hitlEditOpen && !citationOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
if (hitlEditOpen) closeHitlEdit();
else if (citationOpen) closeCitation();
else if (editorOpen) closeEditor();
else if (reportOpen) closeReport();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [reportOpen, editorOpen, hitlEditOpen, closeReport, closeEditor, closeHitlEdit]);
}, [
reportOpen,
editorOpen,
hitlEditOpen,
citationOpen,
closeReport,
closeEditor,
closeHitlEdit,
closeCitation,
]);
const isVisible = (documentsOpen || reportOpen || editorOpen || hitlEditOpen) && !collapsed;
const isVisible =
(documentsOpen || reportOpen || editorOpen || hitlEditOpen || citationOpen) && !collapsed;
let effectiveTab = activeTab;
if (effectiveTab === "hitl-edit" && !hitlEditOpen) {
effectiveTab = editorOpen ? "editor" : reportOpen ? "report" : "sources";
} else if (effectiveTab === "editor" && !editorOpen) {
effectiveTab = reportOpen ? "report" : "sources";
} else if (effectiveTab === "report" && !reportOpen) {
effectiveTab = editorOpen ? "editor" : "sources";
} else if (effectiveTab === "sources" && !documentsOpen) {
effectiveTab = hitlEditOpen
? "hitl-edit"
effectiveTab = citationOpen
? "citation"
: editorOpen
? "editor"
: reportOpen
? "report"
: "sources";
} else if (effectiveTab === "citation" && !citationOpen) {
effectiveTab = editorOpen ? "editor" : reportOpen ? "report" : "sources";
} else if (effectiveTab === "editor" && !editorOpen) {
effectiveTab = citationOpen ? "citation" : reportOpen ? "report" : "sources";
} else if (effectiveTab === "report" && !reportOpen) {
effectiveTab = citationOpen ? "citation" : editorOpen ? "editor" : "sources";
} else if (effectiveTab === "sources" && !documentsOpen) {
effectiveTab = hitlEditOpen
? "hitl-edit"
: citationOpen
? "citation"
: editorOpen
? "editor"
: reportOpen
? "report"
: "sources";
}
const targetWidth = PANEL_WIDTHS[effectiveTab];
@ -205,6 +246,11 @@ export function RightPanel({ documentsPanel }: RightPanelProps) {
/>
</div>
)}
{effectiveTab === "citation" && citationOpen && citationState.chunkId != null && (
<div className="h-full flex flex-col">
<CitationPanelContent chunkId={citationState.chunkId} onClose={closeCitation} />
</div>
)}
</div>
</aside>
);

View file

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