Merge pull request #1470 from AnishSarkar22/fix/platejs-pagination

feat(editor): route large documents to Monaco viewer
This commit is contained in:
Rohan Verma 2026-06-05 16:06:38 -07:00 committed by GitHub
commit 952ebeea72
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 198 additions and 109 deletions

View file

@ -38,14 +38,13 @@ logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
EDITOR_PLATE_MAX_BYTES = 5 * 1024 * 1024
@router.get("/search-spaces/{search_space_id}/documents/{document_id}/editor-content") @router.get("/search-spaces/{search_space_id}/documents/{document_id}/editor-content")
async def get_editor_content( async def get_editor_content(
search_space_id: int, search_space_id: int,
document_id: int, document_id: int,
max_length: int | None = Query(
None, description="Truncate source_markdown to this many characters"
),
session: AsyncSession = Depends(get_async_session), session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user), user: User = Depends(current_active_user),
): ):
@ -84,19 +83,16 @@ async def get_editor_content(
def _build_response(md: str) -> dict: def _build_response(md: str) -> dict:
size_bytes = len(md.encode("utf-8")) size_bytes = len(md.encode("utf-8"))
truncated = False viewer_mode = "monaco" if size_bytes > EDITOR_PLATE_MAX_BYTES else "plate"
output_md = md
if max_length is not None and size_bytes > max_length:
output_md = md[:max_length]
truncated = True
return { return {
"document_id": document.id, "document_id": document.id,
"title": document.title, "title": document.title,
"document_type": document.document_type.value, "document_type": document.document_type.value,
"source_markdown": output_md, "source_markdown": md,
"content_size_bytes": size_bytes, "content_size_bytes": size_bytes,
"chunk_count": chunk_count, "chunk_count": chunk_count,
"truncated": truncated, "viewer_mode": viewer_mode,
"editor_plate_max_bytes": EDITOR_PLATE_MAX_BYTES,
"updated_at": document.updated_at.isoformat() "updated_at": document.updated_at.isoformat()
if document.updated_at if document.updated_at
else None, else None,

View file

@ -12,7 +12,7 @@ import {
XIcon, XIcon,
} from "lucide-react"; } from "lucide-react";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { closeEditorPanelAtom, editorPanelAtom } from "@/atoms/editor/editor-panel.atom"; import { closeEditorPanelAtom, editorPanelAtom } from "@/atoms/editor/editor-panel.atom";
import { DownloadOriginalButton } from "@/components/documents/download-original-button"; import { DownloadOriginalButton } from "@/components/documents/download-original-button";
@ -50,11 +50,13 @@ interface EditorContent {
source_markdown: string; source_markdown: string;
content_size_bytes?: number; content_size_bytes?: number;
chunk_count?: number; chunk_count?: number;
truncated?: boolean; viewer_mode?: ViewerMode;
editor_plate_max_bytes?: number;
} }
const EDITABLE_DOCUMENT_TYPES = new Set(["FILE", "NOTE"]); const EDITABLE_DOCUMENT_TYPES = new Set(["FILE", "NOTE"]);
type EditorRenderMode = "rich_markdown" | "source_code"; type EditorRenderMode = "rich_markdown" | "source_code";
type ViewerMode = "plate" | "monaco";
type AgentFilesystemMount = { type AgentFilesystemMount = {
mount: string; mount: string;
@ -112,6 +114,20 @@ function EditorPanelSkeleton() {
); );
} }
function getUtf8ByteSize(value: string): number {
return new TextEncoder().encode(value).byteLength;
}
function formatBytes(bytes: number): string {
if (bytes >= 1024 * 1024) {
return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
}
if (bytes >= 1024) {
return `${Math.round(bytes / 1024)}KB`;
}
return `${bytes}B`;
}
export function EditorPanelContent({ export function EditorPanelContent({
kind = "document", kind = "document",
documentId, documentId,
@ -167,7 +183,11 @@ export function EditorPanelContent({
[electronAPI, searchSpaceId] [electronAPI, searchSpaceId]
); );
const isLargeDocument = (editorDoc?.content_size_bytes ?? 0) > LARGE_DOCUMENT_THRESHOLD; const plateMaxBytes = editorDoc?.editor_plate_max_bytes ?? LARGE_DOCUMENT_THRESHOLD;
const isLargeDocument = (editorDoc?.content_size_bytes ?? 0) > plateMaxBytes;
const viewerMode: ViewerMode = isMemoryMode
? "plate"
: (editorDoc?.viewer_mode ?? (isLargeDocument ? "monaco" : "plate"));
useEffect(() => { useEffect(() => {
const controller = new AbortController(); const controller = new AbortController();
@ -243,8 +263,6 @@ export function EditorPanelContent({
const url = new URL( const url = new URL(
`${BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/editor-content` `${BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/editor-content`
); );
url.searchParams.set("max_length", String(LARGE_DOCUMENT_THRESHOLD));
const response = await authenticatedFetch(url.toString(), { method: "GET" }); const response = await authenticatedFetch(url.toString(), { method: "GET" });
if (controller.signal.aborted) return; if (controller.signal.aborted) return;
@ -402,7 +420,12 @@ export function EditorPanelContent({
setEditorDoc((prev) => (prev ? { ...prev, source_markdown: markdownRef.current } : prev)); setEditorDoc((prev) => (prev ? { ...prev, source_markdown: markdownRef.current } : prev));
setEditedMarkdown(null); setEditedMarkdown(null);
if (!options?.silent) { if (!options?.silent) {
toast.success("Document saved! Reindexing in background..."); const savedSizeBytes = getUtf8ByteSize(markdownRef.current);
if (savedSizeBytes > plateMaxBytes) {
toast.success("Document saved. It will reopen in raw markdown mode.");
} else {
toast.success("Document saved! Reindexing in background...");
}
} }
return true; return true;
} catch (err) { } catch (err) {
@ -423,6 +446,7 @@ export function EditorPanelContent({
localFilePath, localFilePath,
memoryLimits, memoryLimits,
memoryScope, memoryScope,
plateMaxBytes,
resolveLocalVirtualPath, resolveLocalVirtualPath,
searchSpaceId, searchSpaceId,
] ]
@ -432,18 +456,21 @@ export function EditorPanelContent({
? (isMemoryMode || ? (isMemoryMode ||
editorRenderMode === "source_code" || editorRenderMode === "source_code" ||
EDITABLE_DOCUMENT_TYPES.has(editorDoc.document_type ?? "")) && EDITABLE_DOCUMENT_TYPES.has(editorDoc.document_type ?? "")) &&
!isLargeDocument viewerMode === "plate"
: false; : false;
// Render through PlateEditor for editable doc types (FILE/NOTE). // Render through PlateEditor only when the backend says the rich editor is safe.
// Everything else (large docs, non-editable types) falls back to the // Monaco mode is a raw markdown safety path for large documents.
// lightweight `MarkdownViewer` — Plate is heavy on multi-MB docs and
// non-editable types don't benefit from its editing UX.
const renderInPlateEditor = isEditableType; const renderInPlateEditor = isEditableType;
const hasUnsavedChanges = editedMarkdown !== null; const hasUnsavedChanges = editedMarkdown !== null;
const showDesktopHeader = !!onClose; const showDesktopHeader = !!onClose;
const showEditingActions = isEditableType && isEditing; const showEditingActions = isEditableType && isEditing;
const localFileLanguage = inferMonacoLanguageFromPath(localFilePath); const localFileLanguage = inferMonacoLanguageFromPath(localFilePath);
const activeMarkdown = editedMarkdown ?? editorDoc?.source_markdown ?? ""; const activeMarkdown = editedMarkdown ?? editorDoc?.source_markdown ?? "";
const activeMarkdownSizeBytes = useMemo(() => getUtf8ByteSize(activeMarkdown), [activeMarkdown]);
const isNearPlateLimit = activeMarkdownSizeBytes >= plateMaxBytes * 0.9;
const isOverPlateLimit = activeMarkdownSizeBytes > plateMaxBytes;
const showPlateSizeWarning =
showEditingActions && !isMemoryMode && !isLocalFileMode && isNearPlateLimit;
const memoryLimitState = isMemoryMode const memoryLimitState = isMemoryMode
? getMemoryLimitState(activeMarkdown.length, memoryLimits) ? getMemoryLimitState(activeMarkdown.length, memoryLimits)
: null; : null;
@ -492,14 +519,14 @@ export function EditorPanelContent({
} }
}, [documentId, editorDoc?.title, searchSpaceId]); }, [documentId, editorDoc?.title, searchSpaceId]);
const largeDocAlert = isLargeDocument && !isLocalFileMode && editorDoc && ( const largeDocAlert = viewerMode === "monaco" && !isLocalFileMode && editorDoc && (
<Alert className="mb-4"> <Alert className="m-4 shrink-0">
<FileText className="size-4" /> <FileText className="size-4" />
<AlertDescription className="flex items-center justify-between gap-4"> <AlertDescription className="flex items-center justify-between gap-4">
<span> <span>
This document is too large for the editor ( This document is too large for the editor (
{Math.round((editorDoc.content_size_bytes ?? 0) / 1024 / 1024)}MB,{" "} {Math.round((editorDoc.content_size_bytes ?? 0) / 1024 / 1024)}MB,{" "}
{editorDoc.chunk_count ?? 0} chunks). Showing a preview below. {editorDoc.chunk_count ?? 0} chunks). Showing raw markdown below.
</span> </span>
<Button <Button
variant="outline" variant="outline"
@ -753,16 +780,33 @@ export function EditorPanelContent({
}} }}
/> />
</div> </div>
) : isLargeDocument && !isLocalFileMode ? ( ) : viewerMode === "monaco" && !isLocalFileMode ? (
// Large doc — fast Streamdown preview + download CTA. // Large doc — raw markdown in Monaco. Rich renderers are intentionally skipped.
// Plate is heavy on multi-MB docs. <div className="flex h-full min-h-0 flex-col">
<div className="h-full overflow-y-auto px-5 py-4">
{largeDocAlert} {largeDocAlert}
<MarkdownViewer content={editorDoc.source_markdown} enableCitations /> <div className="min-h-0 flex-1 overflow-hidden">
<SourceCodeEditor
path={`${editorDoc.title || "document"}.md`}
language="markdown"
value={editorDoc.source_markdown}
readOnly
onChange={() => {}}
/>
</div>
</div> </div>
) : renderInPlateEditor ? ( ) : renderInPlateEditor ? (
// Editable doc (FILE/NOTE) — Plate editing UX. // Editable doc (FILE/NOTE) — Plate editing UX.
<div className="flex h-full min-h-0 flex-col"> <div className="flex h-full min-h-0 flex-col">
{showPlateSizeWarning && (
<Alert className="m-4 mb-0 shrink-0">
<FileText className="size-4" />
<AlertDescription>
{isOverPlateLimit
? `This document is ${formatBytes(activeMarkdownSizeBytes)}, above the rich editor limit of ${formatBytes(plateMaxBytes)}. You can save, but it will reopen in raw markdown mode.`
: `This document is approaching the rich editor limit (${formatBytes(activeMarkdownSizeBytes)} of ${formatBytes(plateMaxBytes)}).`}
</AlertDescription>
</Alert>
)}
<div className="flex-1 min-h-0 overflow-hidden"> <div className="flex-1 min-h-0 overflow-hidden">
<PlateEditor <PlateEditor
key={`${ key={`${

View file

@ -2,9 +2,10 @@
import { Download, FileQuestionMark, FileText, Pencil, RefreshCw } from "lucide-react"; import { Download, FileQuestionMark, FileText, Pencil, RefreshCw } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { PlateEditor } from "@/components/editor/plate-editor"; import { PlateEditor } from "@/components/editor/plate-editor";
import { SourceCodeEditor } from "@/components/editor/source-code-editor";
import { MarkdownViewer } from "@/components/markdown-viewer"; import { MarkdownViewer } from "@/components/markdown-viewer";
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -21,7 +22,8 @@ interface DocumentContent {
source_markdown: string; source_markdown: string;
content_size_bytes?: number; content_size_bytes?: number;
chunk_count?: number; chunk_count?: number;
truncated?: boolean; viewer_mode?: ViewerMode;
editor_plate_max_bytes?: number;
} }
function DocumentSkeleton() { function DocumentSkeleton() {
@ -51,6 +53,21 @@ interface DocumentTabContentProps {
} }
const EDITABLE_DOCUMENT_TYPES = new Set(["FILE", "NOTE"]); const EDITABLE_DOCUMENT_TYPES = new Set(["FILE", "NOTE"]);
type ViewerMode = "plate" | "monaco";
function getUtf8ByteSize(value: string): number {
return new TextEncoder().encode(value).byteLength;
}
function formatBytes(bytes: number): string {
if (bytes >= 1024 * 1024) {
return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
}
if (bytes >= 1024) {
return `${Math.round(bytes / 1024)}KB`;
}
return `${bytes}B`;
}
export function DocumentTabContent({ documentId, searchSpaceId, title }: DocumentTabContentProps) { export function DocumentTabContent({ documentId, searchSpaceId, title }: DocumentTabContentProps) {
const [doc, setDoc] = useState<DocumentContent | null>(null); const [doc, setDoc] = useState<DocumentContent | null>(null);
@ -65,7 +82,13 @@ export function DocumentTabContent({ documentId, searchSpaceId, title }: Documen
const changeCountRef = useRef(0); const changeCountRef = useRef(0);
const router = useRouter(); const router = useRouter();
const isLargeDocument = (doc?.content_size_bytes ?? 0) > LARGE_DOCUMENT_THRESHOLD; const plateMaxBytes = doc?.editor_plate_max_bytes ?? LARGE_DOCUMENT_THRESHOLD;
const isLargeDocument = (doc?.content_size_bytes ?? 0) > plateMaxBytes;
const viewerMode: ViewerMode = doc?.viewer_mode ?? (isLargeDocument ? "monaco" : "plate");
const activeMarkdown = editedMarkdown ?? doc?.source_markdown ?? "";
const activeMarkdownSizeBytes = useMemo(() => getUtf8ByteSize(activeMarkdown), [activeMarkdown]);
const isNearPlateLimit = activeMarkdownSizeBytes >= plateMaxBytes * 0.9;
const isOverPlateLimit = activeMarkdownSizeBytes > plateMaxBytes;
useEffect(() => { useEffect(() => {
const controller = new AbortController(); const controller = new AbortController();
@ -88,8 +111,6 @@ export function DocumentTabContent({ documentId, searchSpaceId, title }: Documen
const url = new URL( const url = new URL(
`${BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/editor-content` `${BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/editor-content`
); );
url.searchParams.set("max_length", String(LARGE_DOCUMENT_THRESHOLD));
const response = await authenticatedFetch(url.toString(), { method: "GET" }); const response = await authenticatedFetch(url.toString(), { method: "GET" });
if (controller.signal.aborted) return; if (controller.signal.aborted) return;
@ -161,14 +182,19 @@ export function DocumentTabContent({ documentId, searchSpaceId, title }: Documen
setDoc((prev) => (prev ? { ...prev, source_markdown: markdownRef.current } : prev)); setDoc((prev) => (prev ? { ...prev, source_markdown: markdownRef.current } : prev));
setEditedMarkdown(null); setEditedMarkdown(null);
toast.success("Document saved! Reindexing in background..."); const savedSizeBytes = getUtf8ByteSize(markdownRef.current);
if (savedSizeBytes > plateMaxBytes) {
toast.success("Document saved. It will reopen in raw markdown mode.");
} else {
toast.success("Document saved! Reindexing in background...");
}
} catch (err) { } catch (err) {
console.error("Error saving document:", err); console.error("Error saving document:", err);
toast.error(err instanceof Error ? err.message : "Failed to save document"); toast.error(err instanceof Error ? err.message : "Failed to save document");
} finally { } finally {
setSaving(false); setSaving(false);
} }
}, [documentId, searchSpaceId]); }, [documentId, plateMaxBytes, searchSpaceId]);
if (isLoading) return <DocumentSkeleton />; if (isLoading) return <DocumentSkeleton />;
@ -204,9 +230,9 @@ export function DocumentTabContent({ documentId, searchSpaceId, title }: Documen
); );
} }
const isEditable = EDITABLE_DOCUMENT_TYPES.has(doc.document_type ?? "") && !isLargeDocument; const isEditable = viewerMode === "plate" && EDITABLE_DOCUMENT_TYPES.has(doc.document_type ?? "");
if (isEditing && !isLargeDocument) { if (isEditing && viewerMode === "plate") {
return ( return (
<div className="flex flex-col h-full overflow-hidden"> <div className="flex flex-col h-full overflow-hidden">
<div className="flex items-center justify-between px-6 py-3 border-b shrink-0"> <div className="flex items-center justify-between px-6 py-3 border-b shrink-0">
@ -228,20 +254,32 @@ export function DocumentTabContent({ documentId, searchSpaceId, title }: Documen
Done editing Done editing
</Button> </Button>
</div> </div>
<div className="flex-1 overflow-hidden"> <div className="flex-1 min-h-0 overflow-hidden flex flex-col">
<PlateEditor {isNearPlateLimit && (
key={`edit-${documentId}`} <Alert className="m-4 mb-0 shrink-0">
preset="full" <FileText className="size-4" />
markdown={doc.source_markdown} <AlertDescription>
onMarkdownChange={handleMarkdownChange} {isOverPlateLimit
readOnly={false} ? `This document is ${formatBytes(activeMarkdownSizeBytes)}, above the rich editor limit of ${formatBytes(plateMaxBytes)}. You can save, but it will reopen in raw markdown mode.`
placeholder="Start writing..." : `This document is approaching the rich editor limit (${formatBytes(activeMarkdownSizeBytes)} of ${formatBytes(plateMaxBytes)}).`}
editorVariant="default" </AlertDescription>
onSave={handleSave} </Alert>
hasUnsavedChanges={editedMarkdown !== null} )}
isSaving={saving} <div className="min-h-0 flex-1 overflow-hidden">
defaultEditing={true} <PlateEditor
/> key={`edit-${documentId}`}
preset="full"
markdown={doc.source_markdown}
onMarkdownChange={handleMarkdownChange}
readOnly={false}
placeholder="Start writing..."
editorVariant="default"
onSave={handleSave}
hasUnsavedChanges={editedMarkdown !== null}
isSaving={saving}
defaultEditing={true}
/>
</div>
</div> </div>
</div> </div>
); );
@ -265,64 +303,74 @@ export function DocumentTabContent({ documentId, searchSpaceId, title }: Documen
</Button> </Button>
)} )}
</div> </div>
<div className="flex-1 overflow-auto"> <div className="flex-1 min-h-0 overflow-hidden">
<div className="max-w-4xl mx-auto px-6 py-6"> {viewerMode === "monaco" ? (
{isLargeDocument ? ( <div className="flex h-full min-h-0 flex-col">
<> <Alert className="m-4 shrink-0">
<Alert className="mb-4"> <FileText className="size-4" />
<FileText className="size-4" /> <AlertDescription className="flex items-center justify-between gap-4">
<AlertDescription className="flex items-center justify-between gap-4"> <span>
<span> This document is too large for the editor (
This document is too large for the editor ( {Math.round((doc.content_size_bytes ?? 0) / 1024 / 1024)}MB,{" "}
{Math.round((doc.content_size_bytes ?? 0) / 1024 / 1024)}MB,{" "} {doc.chunk_count ?? 0} chunks). Showing raw markdown below.
{doc.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 {
const response = await authenticatedFetch(
`${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] ?? `${doc.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> </span>
<Button {downloading && <Spinner size="sm" className="absolute" />}
variant="outline" </Button>
size="sm" </AlertDescription>
className="relative shrink-0" </Alert>
disabled={downloading} <div className="min-h-0 flex-1 overflow-hidden">
onClick={async () => { <SourceCodeEditor
setDownloading(true); path={`${doc.title || "document"}.md`}
try { language="markdown"
const response = await authenticatedFetch( value={doc.source_markdown}
`${BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/download-markdown`, readOnly
{ method: "GET" } onChange={() => {}}
); />
if (!response.ok) throw new Error("Download failed"); </div>
const blob = await response.blob(); </div>
const url = URL.createObjectURL(blob); ) : (
const a = document.createElement("a"); <div className="h-full overflow-auto">
a.href = url; <div className="max-w-4xl mx-auto px-6 py-6">
const disposition = response.headers.get("content-disposition");
const match = disposition?.match(/filename="(.+)"/);
a.download = match?.[1] ?? `${doc.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={doc.source_markdown} enableCitations /> <MarkdownViewer content={doc.source_markdown} enableCitations />
</> </div>
) : ( </div>
<MarkdownViewer content={doc.source_markdown} enableCitations /> )}
)}
</div>
</div> </div>
</div> </div>
); );

View file

@ -43,7 +43,8 @@ export type EditorContent = {
source_markdown: string; source_markdown: string;
content_size_bytes: number; content_size_bytes: number;
chunk_count: number; chunk_count: number;
truncated: boolean; viewer_mode?: "plate" | "monaco";
editor_plate_max_bytes?: number;
}; };
// Same endpoint the UI hits when a user opens a document in the dashboard. // Same endpoint the UI hits when a user opens a document in the dashboard.