2026-03-17 16:55:31 +05:30
"use client" ;
import { useAtomValue , useSetAtom } from "jotai" ;
2026-04-23 19:25:59 +05:30
import {
Check ,
Copy ,
Download ,
FileQuestionMark ,
FileText ,
Pencil ,
RefreshCw ,
XIcon ,
} from "lucide-react" ;
2026-03-31 14:45:46 -07:00
import dynamic from "next/dynamic" ;
2026-06-05 14:57:52 +05:30
import { useCallback , useEffect , useMemo , useRef , useState } from "react" ;
2026-03-17 16:55:31 +05:30
import { toast } from "sonner" ;
import { closeEditorPanelAtom , editorPanelAtom } from "@/atoms/editor/editor-panel.atom" ;
2026-06-02 16:10:50 +02:00
import { DownloadOriginalButton } from "@/components/documents/download-original-button" ;
2026-04-02 22:21:01 +05:30
import { VersionHistoryButton } from "@/components/documents/version-history" ;
2026-05-20 12:55:10 +05:30
import { SourceCodeEditor } from "@/components/editor/source-code-editor" ;
2026-05-20 12:50:15 +05:30
import {
fetchMemoryEditorDocument ,
getMemoryLimitState ,
type MemoryLimits ,
saveMemoryMarkdown ,
} from "@/components/editor-panel/memory" ;
2026-03-30 01:50:41 +05:30
import { MarkdownViewer } from "@/components/markdown-viewer" ;
2026-04-02 19:39:10 -07:00
import { Alert , AlertDescription } from "@/components/ui/alert" ;
2026-03-17 16:55:31 +05:30
import { Button } from "@/components/ui/button" ;
import { Drawer , DrawerContent , DrawerHandle , DrawerTitle } from "@/components/ui/drawer" ;
2026-05-20 03:17:05 +05:30
import { Separator } from "@/components/ui/separator" ;
2026-04-23 20:03:18 +05:30
import { Spinner } from "@/components/ui/spinner" ;
2026-03-17 16:55:31 +05:30
import { useMediaQuery } from "@/hooks/use-media-query" ;
2026-04-23 17:23:38 +05:30
import { useElectronAPI } from "@/hooks/use-platform" ;
2026-03-17 16:55:31 +05:30
import { authenticatedFetch , getBearerToken , redirectToLogin } from "@/lib/auth-utils" ;
2026-04-23 18:00:51 +05:30
import { inferMonacoLanguageFromPath } from "@/lib/editor-language" ;
2026-05-19 01:29:31 +05:30
import { BACKEND_URL } from "@/lib/env-config" ;
2026-05-28 19:21:29 -07:00
2026-03-30 19:26:33 +02:00
const PlateEditor = dynamic (
( ) = > import ( "@/components/editor/plate-editor" ) . then ( ( m ) = > ( { default : m . PlateEditor } ) ) ,
2026-04-06 17:07:26 +05:30
{ ssr : false , loading : ( ) = > < EditorPanelSkeleton / > }
2026-03-30 19:26:33 +02:00
) ;
2026-04-02 19:39:10 -07:00
const LARGE_DOCUMENT_THRESHOLD = 2 * 1024 * 1024 ; // 2MB
2026-03-17 16:55:31 +05:30
interface EditorContent {
document_id : number ;
title : string ;
document_type? : string ;
source_markdown : string ;
2026-04-02 19:39:10 -07:00
content_size_bytes? : number ;
chunk_count? : number ;
2026-06-05 14:23:18 +05:30
viewer_mode? : ViewerMode ;
2026-06-05 14:57:52 +05:30
editor_plate_max_bytes? : number ;
2026-03-17 16:55:31 +05:30
}
2026-03-29 22:29:40 +05:30
const EDITABLE_DOCUMENT_TYPES = new Set ( [ "FILE" , "NOTE" ] ) ;
2026-04-23 18:21:50 +05:30
type EditorRenderMode = "rich_markdown" | "source_code" ;
2026-06-05 14:23:18 +05:30
type ViewerMode = "plate" | "monaco" ;
2026-03-29 22:29:40 +05:30
2026-04-27 23:08:32 +05:30
type AgentFilesystemMount = {
mount : string ;
rootPath : string ;
} ;
function normalizeLocalVirtualPathForEditor (
candidatePath : string ,
mounts : AgentFilesystemMount [ ]
) : string {
const normalizedCandidate = candidatePath . trim ( ) . replace ( /\\/g , "/" ) . replace ( /\/+/g , "/" ) ;
if ( ! normalizedCandidate ) return candidatePath ;
const defaultMount = mounts [ 0 ] ? . mount ;
if ( ! defaultMount ) {
return normalizedCandidate . startsWith ( "/" )
? normalizedCandidate
: ` / ${ normalizedCandidate . replace ( /^\/+/ , "" ) } ` ;
}
const mountNames = new Set ( mounts . map ( ( entry ) = > entry . mount ) ) ;
if ( normalizedCandidate . startsWith ( "/" ) ) {
const relative = normalizedCandidate . replace ( /^\/+/ , "" ) ;
const [ firstSegment ] = relative . split ( "/" , 1 ) ;
if ( mountNames . has ( firstSegment ) ) {
return ` / ${ relative } ` ;
}
return ` / ${ defaultMount } / ${ relative } ` ;
}
const relative = normalizedCandidate . replace ( /^\/+/ , "" ) ;
const [ firstSegment ] = relative . split ( "/" , 1 ) ;
if ( mountNames . has ( firstSegment ) ) {
return ` / ${ relative } ` ;
}
return ` / ${ defaultMount } / ${ relative } ` ;
}
2026-03-17 16:55:31 +05:30
function EditorPanelSkeleton() {
return (
< div className = "space-y-6 p-6" >
< div className = "h-6 w-3/4 rounded-md bg-muted/60 animate-pulse" / >
< div className = "space-y-2.5" >
< div className = "h-3 w-full rounded-md bg-muted/60 animate-pulse" / >
< div className = "h-3 w-[95%] rounded-md bg-muted/60 animate-pulse [animation-delay:100ms]" / >
< div className = "h-3 w-[88%] rounded-md bg-muted/60 animate-pulse [animation-delay:200ms]" / >
< div className = "h-3 w-[60%] rounded-md bg-muted/60 animate-pulse [animation-delay:300ms]" / >
< / div >
< div className = "h-5 w-2/5 rounded-md bg-muted/60 animate-pulse [animation-delay:400ms]" / >
< div className = "space-y-2.5" >
< div className = "h-3 w-full rounded-md bg-muted/60 animate-pulse [animation-delay:500ms]" / >
< div className = "h-3 w-[92%] rounded-md bg-muted/60 animate-pulse [animation-delay:600ms]" / >
< div className = "h-3 w-[75%] rounded-md bg-muted/60 animate-pulse [animation-delay:700ms]" / >
< / div >
< / div >
) ;
}
2026-06-05 14:57:52 +05:30
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 ` ;
}
2026-03-17 16:55:31 +05:30
export function EditorPanelContent ( {
2026-04-23 17:23:38 +05:30
kind = "document" ,
2026-03-17 16:55:31 +05:30
documentId ,
2026-04-23 17:23:38 +05:30
localFilePath ,
2026-05-20 02:02:59 +05:30
memoryScope ,
2026-03-17 16:55:31 +05:30
searchSpaceId ,
title ,
onClose ,
} : {
2026-05-20 02:02:59 +05:30
kind ? : "document" | "local_file" | "memory" ;
2026-04-23 17:23:38 +05:30
documentId? : number ;
localFilePath? : string ;
2026-05-20 02:02:59 +05:30
memoryScope ? : "user" | "team" ;
2026-04-23 17:23:38 +05:30
searchSpaceId? : number ;
2026-03-17 16:55:31 +05:30
title : string | null ;
onClose ? : ( ) = > void ;
} ) {
2026-04-23 17:23:38 +05:30
const electronAPI = useElectronAPI ( ) ;
2026-03-17 16:55:31 +05:30
const [ editorDoc , setEditorDoc ] = useState < EditorContent | null > ( null ) ;
const [ isLoading , setIsLoading ] = useState ( true ) ;
const [ error , setError ] = useState < string | null > ( null ) ;
const [ saving , setSaving ] = useState ( false ) ;
2026-04-02 19:39:10 -07:00
const [ downloading , setDownloading ] = useState ( false ) ;
2026-04-23 19:52:55 +05:30
const [ isEditing , setIsEditing ] = useState ( false ) ;
2026-05-20 03:17:05 +05:30
const [ memoryLimits , setMemoryLimits ] = useState < MemoryLimits | null > ( null ) ;
2026-03-17 16:55:31 +05:30
const [ editedMarkdown , setEditedMarkdown ] = useState < string | null > ( null ) ;
2026-04-23 18:00:51 +05:30
const [ localFileContent , setLocalFileContent ] = useState ( "" ) ;
2026-04-23 19:25:59 +05:30
const [ hasCopied , setHasCopied ] = useState ( false ) ;
2026-03-17 16:55:31 +05:30
const markdownRef = useRef < string > ( "" ) ;
2026-04-23 19:25:59 +05:30
const copyResetTimeoutRef = useRef < ReturnType < typeof setTimeout > | null > ( null ) ;
2026-03-17 16:55:31 +05:30
const initialLoadDone = useRef ( false ) ;
const changeCountRef = useRef ( 0 ) ;
const [ displayTitle , setDisplayTitle ] = useState ( title || "Untitled" ) ;
2026-04-23 17:23:38 +05:30
const isLocalFileMode = kind === "local_file" ;
2026-05-20 02:02:59 +05:30
const isMemoryMode = kind === "memory" ;
2026-04-23 18:21:50 +05:30
const editorRenderMode : EditorRenderMode = isLocalFileMode ? "source_code" : "rich_markdown" ;
2026-04-28 21:30:53 -07:00
2026-04-27 23:08:32 +05:30
const resolveLocalVirtualPath = useCallback (
async ( candidatePath : string ) : Promise < string > = > {
if ( ! electronAPI ? . getAgentFilesystemMounts ) {
return candidatePath ;
}
try {
const mounts = ( await electronAPI . getAgentFilesystemMounts (
searchSpaceId
) ) as AgentFilesystemMount [ ] ;
return normalizeLocalVirtualPathForEditor ( candidatePath , mounts ) ;
} catch {
return candidatePath ;
}
} ,
[ electronAPI , searchSpaceId ]
) ;
2026-03-17 16:55:31 +05:30
2026-06-05 14:57:52 +05:30
const plateMaxBytes = editorDoc ? . editor_plate_max_bytes ? ? LARGE_DOCUMENT_THRESHOLD ;
const isLargeDocument = ( editorDoc ? . content_size_bytes ? ? 0 ) > plateMaxBytes ;
2026-06-05 14:23:18 +05:30
const viewerMode : ViewerMode = isMemoryMode
? "plate"
: ( editorDoc ? . viewer_mode ? ? ( isLargeDocument ? "monaco" : "plate" ) ) ;
2026-04-02 19:39:10 -07:00
2026-03-17 16:55:31 +05:30
useEffect ( ( ) = > {
2026-04-03 14:29:41 +05:30
const controller = new AbortController ( ) ;
2026-03-17 16:55:31 +05:30
setIsLoading ( true ) ;
setError ( null ) ;
setEditorDoc ( null ) ;
setEditedMarkdown ( null ) ;
2026-04-23 18:00:51 +05:30
setLocalFileContent ( "" ) ;
2026-04-23 19:25:59 +05:30
setHasCopied ( false ) ;
2026-04-23 19:52:55 +05:30
setIsEditing ( false ) ;
2026-05-20 03:17:05 +05:30
setMemoryLimits ( null ) ;
2026-03-17 16:55:31 +05:30
initialLoadDone . current = false ;
changeCountRef . current = 0 ;
2026-04-03 14:29:41 +05:30
const doFetch = async ( ) = > {
2026-03-17 16:55:31 +05:30
try {
2026-04-23 17:23:38 +05:30
if ( isLocalFileMode ) {
if ( ! localFilePath ) {
throw new Error ( "Missing local file path" ) ;
}
if ( ! electronAPI ? . readAgentLocalFileText ) {
throw new Error ( "Local file editor is available only in desktop mode." ) ;
}
2026-04-27 23:08:32 +05:30
const resolvedLocalPath = await resolveLocalVirtualPath ( localFilePath ) ;
2026-04-27 21:00:40 +05:30
const readResult = await electronAPI . readAgentLocalFileText (
2026-04-27 23:08:32 +05:30
resolvedLocalPath ,
2026-04-27 21:00:40 +05:30
searchSpaceId
) ;
2026-04-23 17:23:38 +05:30
if ( ! readResult . ok ) {
throw new Error ( readResult . error || "Failed to read local file" ) ;
}
2026-04-27 23:08:32 +05:30
const inferredTitle = resolvedLocalPath . split ( "/" ) . pop ( ) || resolvedLocalPath ;
2026-04-23 17:23:38 +05:30
const content : EditorContent = {
document_id : - 1 ,
title : inferredTitle ,
document_type : "NOTE" ,
source_markdown : readResult.content ,
} ;
markdownRef . current = content . source_markdown ;
2026-04-23 18:00:51 +05:30
setLocalFileContent ( content . source_markdown ) ;
2026-04-23 17:23:38 +05:30
setDisplayTitle ( title || inferredTitle ) ;
setEditorDoc ( content ) ;
initialLoadDone . current = true ;
return ;
}
2026-05-20 02:02:59 +05:30
if ( isMemoryMode ) {
2026-05-20 12:50:15 +05:30
if ( ! memoryScope ) throw new Error ( "Missing memory context" ) ;
const { document , limits } = await fetchMemoryEditorDocument ( {
scope : memoryScope ,
searchSpaceId ,
title ,
signal : controller.signal ,
} ) ;
2026-05-20 02:02:59 +05:30
if ( controller . signal . aborted ) return ;
2026-05-20 12:50:15 +05:30
setMemoryLimits ( limits ) ;
const content : EditorContent = document ;
2026-05-20 02:02:59 +05:30
markdownRef . current = content . source_markdown ;
setDisplayTitle ( content . title ) ;
setEditorDoc ( content ) ;
initialLoadDone . current = true ;
return ;
}
2026-04-23 17:23:38 +05:30
if ( ! documentId || ! searchSpaceId ) {
throw new Error ( "Missing document context" ) ;
}
const token = getBearerToken ( ) ;
if ( ! token ) {
redirectToLogin ( ) ;
return ;
}
2026-04-02 19:39:10 -07:00
const url = new URL (
2026-05-19 01:29:31 +05:30
` ${ BACKEND_URL } /api/v1/search-spaces/ ${ searchSpaceId } /documents/ ${ documentId } /editor-content `
2026-03-17 16:55:31 +05:30
) ;
2026-04-02 19:39:10 -07:00
const response = await authenticatedFetch ( url . toString ( ) , { method : "GET" } ) ;
2026-03-17 16:55:31 +05:30
2026-04-03 14:29:41 +05:30
if ( controller . signal . aborted ) return ;
2026-03-17 16:55:31 +05:30
if ( ! response . ok ) {
const errorData = await response
. json ( )
. catch ( ( ) = > ( { detail : "Failed to fetch document" } ) ) ;
throw new Error ( errorData . detail || "Failed to fetch document" ) ;
}
const data = await response . json ( ) ;
if ( data . source_markdown === undefined || data . source_markdown === null ) {
setError (
"This document does not have editable content. Please re-upload to enable editing."
) ;
setIsLoading ( false ) ;
return ;
}
markdownRef . current = data . source_markdown ;
setDisplayTitle ( data . title || title || "Untitled" ) ;
setEditorDoc ( data ) ;
initialLoadDone . current = true ;
} catch ( err ) {
2026-04-03 14:29:41 +05:30
if ( controller . signal . aborted ) return ;
2026-03-17 16:55:31 +05:30
console . error ( "Error fetching document:" , err ) ;
setError ( err instanceof Error ? err . message : "Failed to fetch document" ) ;
} finally {
2026-04-03 14:29:41 +05:30
if ( ! controller . signal . aborted ) setIsLoading ( false ) ;
2026-03-17 16:55:31 +05:30
}
} ;
2026-04-03 14:29:41 +05:30
doFetch ( ) . catch ( ( ) = > { } ) ;
return ( ) = > controller . abort ( ) ;
2026-04-27 14:04:50 -07:00
} , [
documentId ,
electronAPI ,
isLocalFileMode ,
2026-05-20 02:02:59 +05:30
isMemoryMode ,
2026-04-27 14:04:50 -07:00
localFilePath ,
2026-05-20 02:02:59 +05:30
memoryScope ,
2026-04-27 14:04:50 -07:00
resolveLocalVirtualPath ,
searchSpaceId ,
title ,
] ) ;
2026-03-17 16:55:31 +05:30
2026-04-23 19:25:59 +05:30
useEffect ( ( ) = > {
return ( ) = > {
if ( copyResetTimeoutRef . current ) {
clearTimeout ( copyResetTimeoutRef . current ) ;
}
} ;
} , [ ] ) ;
2026-05-20 11:52:41 +05:30
const handleMarkdownChange = useCallback (
( md : string ) = > {
if ( ! isEditing ) return ;
markdownRef . current = md ;
if ( ! initialLoadDone . current ) return ;
changeCountRef . current += 1 ;
if ( changeCountRef . current <= 1 ) return ;
const savedContent = editorDoc ? . source_markdown ? ? "" ;
setEditedMarkdown ( md === savedContent ? null : md ) ;
} ,
[ editorDoc ? . source_markdown , isEditing ]
) ;
2026-03-17 16:55:31 +05:30
2026-04-23 19:25:59 +05:30
const handleCopy = useCallback ( async ( ) = > {
try {
const textToCopy = markdownRef . current ? ? editorDoc ? . source_markdown ? ? "" ;
await navigator . clipboard . writeText ( textToCopy ) ;
setHasCopied ( true ) ;
if ( copyResetTimeoutRef . current ) {
clearTimeout ( copyResetTimeoutRef . current ) ;
}
copyResetTimeoutRef . current = setTimeout ( ( ) = > {
setHasCopied ( false ) ;
} , 1400 ) ;
} catch ( err ) {
console . error ( "Error copying content:" , err ) ;
}
} , [ editorDoc ? . source_markdown ] ) ;
2026-04-27 14:04:50 -07:00
const handleSave = useCallback (
2026-04-28 23:25:26 -07:00
async ( options ? : { silent? : boolean } ) = > {
2026-04-27 14:04:50 -07:00
setSaving ( true ) ;
try {
if ( isLocalFileMode ) {
if ( ! localFilePath ) {
throw new Error ( "Missing local file path" ) ;
}
if ( ! electronAPI ? . writeAgentLocalFileText ) {
throw new Error ( "Local file editor is available only in desktop mode." ) ;
}
const resolvedLocalPath = await resolveLocalVirtualPath ( localFilePath ) ;
const contentToSave = markdownRef . current ;
const writeResult = await electronAPI . writeAgentLocalFileText (
resolvedLocalPath ,
contentToSave ,
searchSpaceId
) ;
if ( ! writeResult . ok ) {
throw new Error ( writeResult . error || "Failed to save local file" ) ;
}
setEditorDoc ( ( prev ) = > ( prev ? { . . . prev , source_markdown : contentToSave } : prev ) ) ;
setEditedMarkdown ( markdownRef . current === contentToSave ? null : markdownRef . current ) ;
return true ;
2026-04-23 17:23:38 +05:30
}
2026-05-20 02:02:59 +05:30
if ( isMemoryMode ) {
2026-05-20 12:50:15 +05:30
if ( ! memoryScope ) throw new Error ( "Missing memory context" ) ;
const { markdown : savedContent , limits } = await saveMemoryMarkdown ( {
scope : memoryScope ,
searchSpaceId ,
markdown : markdownRef.current ,
} ) ;
2026-05-20 02:02:59 +05:30
markdownRef . current = savedContent ;
2026-05-20 12:50:15 +05:30
setMemoryLimits ( limits ? ? memoryLimits ) ;
2026-05-20 02:02:59 +05:30
setEditorDoc ( ( prev ) = > ( prev ? { . . . prev , source_markdown : savedContent } : prev ) ) ;
setEditedMarkdown ( null ) ;
if ( ! options ? . silent ) {
toast . success ( "Memory saved" ) ;
}
return true ;
}
2026-04-27 14:04:50 -07:00
if ( ! searchSpaceId || ! documentId ) {
throw new Error ( "Missing document context" ) ;
2026-04-23 17:23:38 +05:30
}
2026-04-27 14:04:50 -07:00
const token = getBearerToken ( ) ;
if ( ! token ) {
toast . error ( "Please login to save" ) ;
redirectToLogin ( ) ;
return ;
2026-04-23 17:23:38 +05:30
}
2026-04-27 14:04:50 -07:00
const response = await authenticatedFetch (
2026-05-19 01:29:31 +05:30
` ${ BACKEND_URL } /api/v1/search-spaces/ ${ searchSpaceId } /documents/ ${ documentId } /save ` ,
2026-04-27 14:04:50 -07:00
{
method : "POST" ,
headers : { "Content-Type" : "application/json" } ,
body : JSON.stringify ( { source_markdown : markdownRef.current } ) ,
}
2026-04-23 17:23:38 +05:30
) ;
2026-04-27 14:04:50 -07:00
if ( ! response . ok ) {
const errorData = await response
. json ( )
. catch ( ( ) = > ( { detail : "Failed to save document" } ) ) ;
throw new Error ( errorData . detail || "Failed to save document" ) ;
2026-03-17 16:55:31 +05:30
}
2026-04-27 14:04:50 -07:00
setEditorDoc ( ( prev ) = > ( prev ? { . . . prev , source_markdown : markdownRef.current } : prev ) ) ;
setEditedMarkdown ( null ) ;
2026-04-28 23:25:26 -07:00
if ( ! options ? . silent ) {
2026-06-05 14:57:52 +05:30
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..." ) ;
}
2026-04-28 23:25:26 -07:00
}
2026-04-27 14:04:50 -07:00
return true ;
} catch ( err ) {
console . error ( "Error saving document:" , err ) ;
2026-04-28 23:25:26 -07:00
if ( ! options ? . silent ) {
toast . error ( err instanceof Error ? err . message : "Failed to save document" ) ;
}
2026-04-27 14:04:50 -07:00
return false ;
} finally {
setSaving ( false ) ;
2026-03-17 16:55:31 +05:30
}
2026-04-27 14:04:50 -07:00
} ,
[
documentId ,
electronAPI ,
isLocalFileMode ,
2026-05-20 02:02:59 +05:30
isMemoryMode ,
2026-04-27 14:04:50 -07:00
localFilePath ,
2026-05-20 03:17:05 +05:30
memoryLimits ,
2026-05-20 02:02:59 +05:30
memoryScope ,
2026-06-05 14:57:52 +05:30
plateMaxBytes ,
2026-04-27 14:04:50 -07:00
resolveLocalVirtualPath ,
searchSpaceId ,
]
) ;
2026-03-17 16:55:31 +05:30
2026-03-29 22:29:40 +05:30
const isEditableType = editorDoc
2026-05-20 02:02:59 +05:30
? ( isMemoryMode ||
editorRenderMode === "source_code" ||
2026-04-23 18:21:50 +05:30
EDITABLE_DOCUMENT_TYPES . has ( editorDoc . document_type ? ? "" ) ) &&
2026-06-05 14:23:18 +05:30
viewerMode === "plate"
2026-03-29 22:29:40 +05:30
: false ;
2026-06-05 14:23:18 +05:30
// Render through PlateEditor only when the backend says the rich editor is safe.
// Monaco mode is a raw markdown safety path for large documents.
2026-04-28 23:25:26 -07:00
const renderInPlateEditor = isEditableType ;
2026-04-23 19:25:59 +05:30
const hasUnsavedChanges = editedMarkdown !== null ;
const showDesktopHeader = ! ! onClose ;
2026-04-23 19:52:55 +05:30
const showEditingActions = isEditableType && isEditing ;
2026-04-23 18:00:51 +05:30
const localFileLanguage = inferMonacoLanguageFromPath ( localFilePath ) ;
2026-05-20 03:17:05 +05:30
const activeMarkdown = editedMarkdown ? ? editorDoc ? . source_markdown ? ? "" ;
2026-06-05 14:57:52 +05:30
const activeMarkdownSizeBytes = useMemo ( ( ) = > getUtf8ByteSize ( activeMarkdown ) , [ activeMarkdown ] ) ;
const isNearPlateLimit = activeMarkdownSizeBytes >= plateMaxBytes * 0.9 ;
const isOverPlateLimit = activeMarkdownSizeBytes > plateMaxBytes ;
const showPlateSizeWarning =
showEditingActions && ! isMemoryMode && ! isLocalFileMode && isNearPlateLimit ;
2026-05-20 03:17:05 +05:30
const memoryLimitState = isMemoryMode
? getMemoryLimitState ( activeMarkdown . length , memoryLimits )
: null ;
const memoryCounterClassName =
memoryLimitState ? . level === "error"
? "text-red-500"
: memoryLimitState ? . level === "warning"
? "text-orange-500"
: "text-muted-foreground" ;
const saveDisabled = saving || ! hasUnsavedChanges || ( memoryLimitState ? . isOverLimit ? ? false ) ;
2026-03-29 22:29:40 +05:30
2026-04-23 19:52:55 +05:30
const handleCancelEditing = useCallback ( ( ) = > {
const savedContent = editorDoc ? . source_markdown ? ? "" ;
markdownRef . current = savedContent ;
setLocalFileContent ( savedContent ) ;
setEditedMarkdown ( null ) ;
changeCountRef . current = 0 ;
setIsEditing ( false ) ;
} , [ editorDoc ? . source_markdown ] ) ;
2026-04-28 21:30:53 -07:00
const handleDownloadMarkdown = useCallback ( async ( ) = > {
if ( ! searchSpaceId || ! documentId ) return ;
setDownloading ( true ) ;
try {
const response = await authenticatedFetch (
2026-05-19 01:29:31 +05:30
` ${ BACKEND_URL } /api/v1/search-spaces/ ${ searchSpaceId } /documents/ ${ documentId } /download-markdown ` ,
2026-04-28 21:30:53 -07:00
{ 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 ] ) ;
2026-06-05 14:23:18 +05:30
const largeDocAlert = viewerMode === "monaco" && ! isLocalFileMode && editorDoc && (
< Alert className = "m-4 shrink-0" >
2026-04-28 21:30:53 -07:00
< 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 , { " " }
2026-06-05 14:23:18 +05:30
{ editorDoc . chunk_count ? ? 0 } chunks ) . Showing raw markdown below .
2026-04-28 21:30:53 -07:00
< / 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 >
) ;
2026-03-17 16:55:31 +05:30
return (
< >
2026-04-23 19:25:59 +05:30
{ showDesktopHeader ? (
2026-05-04 03:06:01 +05:30
< div className = "shrink-0" >
< div className = "shrink-0 flex h-12 items-center justify-between px-3 border-b" >
< h2 className = "select-none text-lg font-semibold" > File < / h2 >
2026-04-23 19:25:59 +05:30
< div className = "flex items-center gap-1 shrink-0" >
2026-05-04 03:06:01 +05:30
< Button
variant = "ghost"
size = "icon"
onClick = { onClose }
2026-05-13 23:53:09 +05:30
className = "h-8 w-8 rounded-full shrink-0 text-muted-foreground hover:text-accent-foreground"
2026-05-04 03:06:01 +05:30
>
2026-05-13 23:53:09 +05:30
< XIcon className = "h-4 w-4" / >
2026-04-23 19:25:59 +05:30
< span className = "sr-only" > Close editor panel < / span >
< / Button >
< / div >
< / div >
2026-05-15 04:13:58 +05:30
< div className = "grid h-10 grid-cols-[minmax(0,1fr)_auto] items-center gap-3 border-b px-4" >
2026-04-25 15:12:22 +05:30
< div className = "min-w-0 flex flex-1 items-center gap-2" >
2026-04-23 19:25:59 +05:30
< p className = "truncate text-sm text-muted-foreground" > { displayTitle } < / p >
2026-05-20 03:17:05 +05:30
{ memoryLimitState && (
< >
< Separator
orientation = "vertical"
className = "mx-1 bg-border data-[orientation=vertical]:h-4 data-[orientation=vertical]:w-px dark:bg-white/10"
/ >
< span className = { ` shrink-0 text-xs ${ memoryCounterClassName } ` } >
{ memoryLimitState . label }
< / span >
< / >
) }
2026-04-23 19:25:59 +05:30
< / div >
< div className = "flex items-center gap-1 shrink-0" >
{ showEditingActions ? (
< >
< Button
variant = "ghost"
size = "sm"
className = "h-6 px-2 text-xs"
2026-04-23 19:52:55 +05:30
onClick = { handleCancelEditing }
2026-04-23 19:25:59 +05:30
disabled = { saving }
>
Cancel
< / Button >
< Button
variant = "secondary"
size = "sm"
className = "relative h-6 w-[56px] px-0 text-xs"
onClick = { async ( ) = > {
const saveSucceeded = await handleSave ( { silent : true } ) ;
2026-04-23 19:52:55 +05:30
if ( saveSucceeded ) setIsEditing ( false ) ;
2026-04-23 19:25:59 +05:30
} }
2026-05-20 03:17:05 +05:30
disabled = { saveDisabled }
2026-04-23 19:25:59 +05:30
>
2026-04-23 20:03:18 +05:30
< span className = { saving ? "opacity-0" : "" } > Save < / span >
{ saving && < Spinner size = "xs" className = "absolute" / > }
2026-04-23 19:25:59 +05:30
< / Button >
< / >
) : (
< >
2026-05-20 02:02:59 +05:30
{ ! isLocalFileMode && ! isMemoryMode && editorDoc ? . document_type && documentId && (
2026-04-25 15:12:22 +05:30
< VersionHistoryButton
documentId = { documentId }
documentType = { editorDoc . document_type }
/ >
) }
2026-06-02 16:10:50 +02:00
{ ! isLocalFileMode && ! isMemoryMode && documentId && (
< DownloadOriginalButton documentId = { documentId } / >
) }
2026-04-23 19:25:59 +05:30
< Button
variant = "ghost"
size = "icon"
className = "size-6"
onClick = { ( ) = > {
void handleCopy ( ) ;
} }
disabled = { isLoading || ! editorDoc }
>
{ hasCopied ? < Check className = "size-3.5" / > : < Copy className = "size-3.5" / > }
< span className = "sr-only" >
{ hasCopied ? "Copied file contents" : "Copy file contents" }
< / span >
< / Button >
2026-04-23 19:52:55 +05:30
{ isEditableType && (
2026-04-23 19:25:59 +05:30
< Button
variant = "ghost"
size = "icon"
className = "size-6"
2026-04-23 19:52:55 +05:30
onClick = { ( ) = > {
changeCountRef . current = 0 ;
setEditedMarkdown ( null ) ;
setIsEditing ( true ) ;
} }
2026-04-23 19:25:59 +05:30
>
< Pencil className = "size-3.5" / >
2026-04-23 19:52:55 +05:30
< span className = "sr-only" > Edit document < / span >
2026-04-23 19:25:59 +05:30
< / Button >
) }
< / >
) }
< / div >
< / div >
2026-04-03 13:14:40 +05:30
< / div >
2026-04-23 19:25:59 +05:30
) : (
< div className = "flex h-14 items-center justify-between border-b px-4 shrink-0" >
2026-04-25 15:12:22 +05:30
< div className = "flex flex-1 min-w-0 items-center gap-2" >
2026-04-23 19:25:59 +05:30
< h2 className = "text-sm font-semibold truncate" > { displayTitle } < / h2 >
2026-05-20 03:17:05 +05:30
{ memoryLimitState && (
< >
< Separator
orientation = "vertical"
className = "mx-1 bg-border data-[orientation=vertical]:h-4 data-[orientation=vertical]:w-px dark:bg-white/10"
/ >
< span className = { ` shrink-0 text-xs ${ memoryCounterClassName } ` } >
{ memoryLimitState . label }
< / span >
< / >
) }
2026-04-23 19:25:59 +05:30
< / div >
< div className = "flex items-center gap-1 shrink-0" >
2026-04-23 19:52:55 +05:30
{ showEditingActions ? (
< >
< Button
variant = "ghost"
size = "sm"
className = "h-6 px-2 text-xs"
onClick = { handleCancelEditing }
disabled = { saving }
>
Cancel
< / Button >
< Button
variant = "secondary"
size = "sm"
className = "relative h-6 w-[56px] px-0 text-xs"
onClick = { async ( ) = > {
const saveSucceeded = await handleSave ( { silent : true } ) ;
if ( saveSucceeded ) setIsEditing ( false ) ;
} }
2026-05-20 03:17:05 +05:30
disabled = { saveDisabled }
2026-04-23 19:52:55 +05:30
>
2026-04-23 20:03:18 +05:30
< span className = { saving ? "opacity-0" : "" } > Save < / span >
{ saving && < Spinner size = "xs" className = "absolute" / > }
2026-04-23 19:52:55 +05:30
< / Button >
< / >
) : (
< >
2026-05-20 02:02:59 +05:30
{ ! isLocalFileMode && ! isMemoryMode && editorDoc ? . document_type && documentId && (
2026-04-25 15:12:22 +05:30
< VersionHistoryButton
documentId = { documentId }
documentType = { editorDoc . document_type }
/ >
) }
2026-06-02 16:10:50 +02:00
{ ! isLocalFileMode && ! isMemoryMode && documentId && (
< DownloadOriginalButton documentId = { documentId } / >
) }
2026-04-23 19:52:55 +05:30
< Button
variant = "ghost"
size = "icon"
className = "size-6"
onClick = { ( ) = > {
void handleCopy ( ) ;
} }
disabled = { isLoading || ! editorDoc }
>
{ hasCopied ? < Check className = "size-3.5" / > : < Copy className = "size-3.5" / > }
< span className = "sr-only" >
{ hasCopied ? "Copied file contents" : "Copy file contents" }
< / span >
< / Button >
{ isEditableType && (
< Button
variant = "ghost"
size = "icon"
className = "size-6"
onClick = { ( ) = > {
changeCountRef . current = 0 ;
setEditedMarkdown ( null ) ;
setIsEditing ( true ) ;
} }
>
< Pencil className = "size-3.5" / >
< span className = "sr-only" > Edit document < / span >
< / Button >
) }
< / >
2026-04-23 19:25:59 +05:30
) }
< / div >
2026-04-03 13:14:40 +05:30
< / div >
2026-04-23 19:25:59 +05:30
) }
2026-03-17 16:55:31 +05:30
< div className = "flex-1 overflow-hidden" >
{ isLoading ? (
< EditorPanelSkeleton / >
) : error || ! editorDoc ? (
< div className = "flex flex-1 flex-col items-center justify-center gap-3 p-6 text-center" >
2026-04-01 20:31:45 +05:30
{ error ? . toLowerCase ( ) . includes ( "still being processed" ) ? (
< div className = "rounded-full bg-muted/50 p-3" >
< RefreshCw className = "size-6 text-muted-foreground animate-spin" / >
< / div >
) : (
< div className = "rounded-full bg-muted/50 p-3" >
< FileQuestionMark className = "size-6 text-muted-foreground" / >
< / div >
) }
< div className = "space-y-1 max-w-xs" >
< p className = "font-medium text-foreground" >
{ error ? . toLowerCase ( ) . includes ( "still being processed" )
? "Document is processing"
: "Document unavailable" }
< / p >
2026-04-03 13:14:40 +05:30
< p className = "text-sm text-muted-foreground" >
{ error || "An unknown error occurred" }
< / p >
2026-03-17 16:55:31 +05:30
< / div >
< / div >
2026-04-23 18:21:50 +05:30
) : editorRenderMode === "source_code" ? (
2026-04-23 18:00:51 +05:30
< div className = "h-full overflow-hidden" >
2026-04-23 18:21:50 +05:30
< SourceCodeEditor
path = { localFilePath ? ? "local-file.txt" }
2026-04-23 18:00:51 +05:30
language = { localFileLanguage }
value = { localFileContent }
2026-04-23 19:25:59 +05:30
onSave = { ( ) = > {
void handleSave ( { silent : true } ) ;
} }
2026-04-23 19:52:55 +05:30
readOnly = { ! isEditing }
2026-04-23 18:00:51 +05:30
onChange = { ( next ) = > {
markdownRef . current = next ;
setLocalFileContent ( next ) ;
if ( ! initialLoadDone . current ) return ;
setEditedMarkdown ( next === ( editorDoc ? . source_markdown ? ? "" ) ? null : next ) ;
} }
/ >
< / div >
2026-06-05 14:23:18 +05:30
) : viewerMode === "monaco" && ! isLocalFileMode ? (
// Large doc — raw markdown in Monaco. Rich renderers are intentionally skipped.
< div className = "flex h-full min-h-0 flex-col" >
2026-04-28 21:30:53 -07:00
{ largeDocAlert }
2026-06-05 14:23:18 +05:30
< div className = "min-h-0 flex-1 overflow-hidden" >
< SourceCodeEditor
path = { ` ${ editorDoc . title || "document" } .md ` }
language = "markdown"
value = { editorDoc . source_markdown }
readOnly
onChange = { ( ) = > { } }
/ >
< / div >
2026-04-28 21:30:53 -07:00
< / div >
) : renderInPlateEditor ? (
2026-04-28 23:25:26 -07:00
// Editable doc (FILE/NOTE) — Plate editing UX.
2026-04-28 21:30:53 -07:00
< div className = "flex h-full min-h-0 flex-col" >
2026-06-05 14:57:52 +05:30
{ 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 >
) }
2026-04-28 23:25:26 -07:00
< div className = "flex-1 min-h-0 overflow-hidden" >
2026-04-28 21:30:53 -07:00
< PlateEditor
2026-05-20 02:02:59 +05:30
key = { ` ${
isMemoryMode
? ` memory- ${ memoryScope ? ? "user" } `
: isLocalFileMode
? ( localFilePath ? ? "local-file" )
: documentId
} - $ { isEditing ? "editing" : "viewing" } ` }
2026-04-28 21:30:53 -07:00
preset = "full"
markdown = { editorDoc . source_markdown }
onMarkdownChange = { handleMarkdownChange }
readOnly = { ! isEditing }
placeholder = "Start writing..."
editorVariant = "default"
allowModeToggle = { false }
2026-05-20 11:52:41 +05:30
reserveToolbarSpace
2026-04-28 21:30:53 -07:00
defaultEditing = { isEditing }
2026-04-28 23:25:26 -07:00
className = "**:[[role=toolbar]]:bg-sidebar!"
2026-04-30 18:40:55 -07:00
// Render `[citation:N]` badges in view mode only.
// Edit mode keeps raw text so the user can edit/delete
// tokens directly. `local_file` never reaches this branch
// (handled by the source_code editor above).
2026-05-20 02:02:59 +05:30
enableCitations = { ! isEditing && ! isLocalFileMode && ! isMemoryMode }
2026-04-28 21:30:53 -07:00
/ >
< / div >
< / div >
2026-03-29 22:29:40 +05:30
) : (
< div className = "h-full overflow-y-auto px-5 py-4" >
2026-04-30 18:40:55 -07:00
< MarkdownViewer content = { editorDoc . source_markdown } enableCitations / >
2026-03-29 22:29:40 +05:30
< / div >
2026-03-17 16:55:31 +05:30
) }
< / div >
< / >
) ;
}
function DesktopEditorPanel() {
const panelState = useAtomValue ( editorPanelAtom ) ;
const closePanel = useSetAtom ( closeEditorPanelAtom ) ;
useEffect ( ( ) = > {
const handleKeyDown = ( e : KeyboardEvent ) = > {
if ( e . key === "Escape" ) closePanel ( ) ;
} ;
document . addEventListener ( "keydown" , handleKeyDown ) ;
return ( ) = > document . removeEventListener ( "keydown" , handleKeyDown ) ;
} , [ closePanel ] ) ;
2026-04-23 17:23:38 +05:30
const hasTarget =
panelState . kind === "document"
? ! ! panelState . documentId && ! ! panelState . searchSpaceId
2026-05-20 02:02:59 +05:30
: panelState . kind === "local_file"
? ! ! panelState . localFilePath
: ! ! panelState . memoryScope ;
2026-04-23 17:23:38 +05:30
if ( ! panelState . isOpen || ! hasTarget ) return null ;
2026-03-17 16:55:31 +05:30
return (
< div className = "flex w-[50%] max-w-[700px] min-w-[380px] flex-col border-l bg-sidebar text-sidebar-foreground animate-in slide-in-from-right-4 duration-300 ease-out" >
< EditorPanelContent
2026-04-23 17:23:38 +05:30
kind = { panelState . kind }
documentId = { panelState . documentId ? ? undefined }
localFilePath = { panelState . localFilePath ? ? undefined }
2026-05-20 02:02:59 +05:30
memoryScope = { panelState . memoryScope ? ? undefined }
2026-04-23 17:23:38 +05:30
searchSpaceId = { panelState . searchSpaceId ? ? undefined }
2026-03-17 16:55:31 +05:30
title = { panelState . title }
onClose = { closePanel }
/ >
< / div >
) ;
}
function MobileEditorDrawer() {
const panelState = useAtomValue ( editorPanelAtom ) ;
const closePanel = useSetAtom ( closeEditorPanelAtom ) ;
2026-04-23 19:52:55 +05:30
if ( panelState . kind === "local_file" ) return null ;
2026-04-23 17:23:38 +05:30
const hasTarget =
panelState . kind === "document"
? ! ! panelState . documentId && ! ! panelState . searchSpaceId
2026-05-20 02:02:59 +05:30
: ! ! panelState . memoryScope ;
2026-04-23 17:23:38 +05:30
if ( ! hasTarget ) return null ;
2026-03-17 16:55:31 +05:30
return (
< Drawer
open = { panelState . isOpen }
onOpenChange = { ( open ) = > {
if ( ! open ) closePanel ( ) ;
} }
shouldScaleBackground = { false }
>
< DrawerContent
2026-03-29 04:20:22 +05:30
className = "h-[90vh] max-h-[90vh] z-80 bg-sidebar overflow-hidden"
2026-03-17 16:55:31 +05:30
overlayClassName = "z-80"
>
< DrawerHandle / >
< DrawerTitle className = "sr-only" > { panelState . title || "Editor" } < / DrawerTitle >
< div className = "min-h-0 flex-1 flex flex-col overflow-hidden" >
< EditorPanelContent
2026-04-23 17:23:38 +05:30
kind = { panelState . kind }
documentId = { panelState . documentId ? ? undefined }
localFilePath = { panelState . localFilePath ? ? undefined }
2026-05-20 02:02:59 +05:30
memoryScope = { panelState . memoryScope ? ? undefined }
2026-04-23 17:23:38 +05:30
searchSpaceId = { panelState . searchSpaceId ? ? undefined }
2026-03-17 16:55:31 +05:30
title = { panelState . title }
/ >
< / div >
< / DrawerContent >
< / Drawer >
) ;
}
export function EditorPanel() {
const panelState = useAtomValue ( editorPanelAtom ) ;
const isDesktop = useMediaQuery ( "(min-width: 1024px)" ) ;
2026-04-23 17:23:38 +05:30
const hasTarget =
panelState . kind === "document"
? ! ! panelState . documentId && ! ! panelState . searchSpaceId
2026-05-20 02:02:59 +05:30
: panelState . kind === "local_file"
? ! ! panelState . localFilePath
: ! ! panelState . memoryScope ;
2026-03-17 16:55:31 +05:30
2026-04-23 17:23:38 +05:30
if ( ! panelState . isOpen || ! hasTarget ) return null ;
2026-04-23 19:52:55 +05:30
if ( ! isDesktop && panelState . kind === "local_file" ) return null ;
2026-03-17 16:55:31 +05:30
if ( isDesktop ) {
return < DesktopEditorPanel / > ;
}
return < MobileEditorDrawer / > ;
}
export function MobileEditorPanel() {
const panelState = useAtomValue ( editorPanelAtom ) ;
const isDesktop = useMediaQuery ( "(min-width: 1024px)" ) ;
2026-04-23 17:23:38 +05:30
const hasTarget =
panelState . kind === "document"
? ! ! panelState . documentId && ! ! panelState . searchSpaceId
2026-05-20 02:02:59 +05:30
: panelState . kind === "local_file"
? ! ! panelState . localFilePath
: ! ! panelState . memoryScope ;
2026-03-17 16:55:31 +05:30
2026-04-27 14:04:50 -07:00
if ( isDesktop || ! panelState . isOpen || ! hasTarget || panelState . kind === "local_file" )
return null ;
2026-03-17 16:55:31 +05:30
return < MobileEditorDrawer / > ;
}