supermemory/apps/web/components/document-modal/index.tsx
ved015 4e607f9fd7 fix: Add plugin document rendering and MCP preview support (#938)
<h3>Implement comprehensive plugin document rendering support including MCP previews and plugin specific content handling.</h3>
<br>
<br>

<img width="1680" height="471" alt="Screenshot 2026-05-12 at 8 24 49 PM" src="https://github.com/user-attachments/assets/f1294bc2-2841-4833-9f01-ac47b8c52c01" />

<br>
<br>

<img width="1680" height="963" alt="Screenshot 2026-05-12 at 8 28 25 PM" src="https://github.com/user-attachments/assets/9436c7ab-3b9b-4366-86fd-1465407ff0f9" />
2026-05-15 18:26:37 +00:00

381 lines
12 KiB
TypeScript

"use client"
import { Dialog, DialogContent, DialogTitle } from "@repo/ui/components/dialog"
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import {
ArrowUpRightIcon,
XIcon,
Loader2,
Trash2Icon,
CheckIcon,
CopyIcon,
} from "lucide-react"
import type { z } from "zod"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { cn } from "@lib/utils"
import { Title } from "./title"
import { Summary as DocumentSummary } from "./summary"
import { dmSansClassName } from "@/lib/fonts"
import { GraphListMemories, type MemoryEntry } from "./graph-list-memories"
import { DocumentContent } from "./content"
import { useState, useEffect, useCallback, useMemo } from "react"
import { motion, AnimatePresence } from "motion/react"
import { useDocumentMutations } from "@/hooks/use-document-mutations"
import type { UseMutationResult } from "@tanstack/react-query"
import { toast } from "sonner"
import { useIsMobile } from "@hooks/use-mobile"
import { parsePluginDocument } from "@/lib/plugin-document"
import { PluginDetails } from "./plugin-details"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
function getDocumentSourceUrl(document: DocumentWithMemories): string {
const url = document.url ?? ""
const googleDocTypes: Record<string, string> = {
google_doc: "https://docs.google.com/document/d/",
google_sheet: "https://docs.google.com/spreadsheets/d/",
google_slide: "https://docs.google.com/presentation/d/",
}
const prefix = document.type ? googleDocTypes[document.type] : null
if (!prefix) return url
if (document.customId) {
return `${prefix}${document.customId}/edit`
}
// Extract ID from API URL like docs.googleapis.com/v1/documents/{id}
const apiMatch = url.match(
/docs\.googleapis\.com\/v1\/documents\/([a-zA-Z0-9_-]+)/,
)
if (apiMatch?.[1]) {
return `${prefix}${apiMatch[1]}/edit`
}
return url
}
interface DocumentModalProps {
document: DocumentWithMemories | null
isOpen: boolean
onClose: () => void
}
interface DeleteButtonProps {
documentId: string | null | undefined
customId: string | null | undefined
deleteMutation: UseMutationResult<
unknown,
Error,
{ documentId: string },
unknown
>
}
function isTemporaryId(id: string | null | undefined): boolean {
if (!id) return false
return id.startsWith("temp-") || id.startsWith("temp-file-")
}
function DeleteButton({
documentId,
customId,
deleteMutation,
}: DeleteButtonProps) {
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
const handleDelete = useCallback(() => {
const id = documentId ?? customId
if (!id) return
// Check both IDs to ensure we catch temporary documents regardless of which ID is used
if (isTemporaryId(documentId) || isTemporaryId(customId)) {
// this is when user added document immediately and trying to delete
toast.error("Cannot delete document", {
description: "This document is still being processed. Please wait.",
})
return
}
deleteMutation.mutate({ documentId: id as string })
}, [documentId, customId, deleteMutation])
return (
<AnimatePresence mode="wait">
{!deleteConfirmOpen ? (
<motion.button
key="trash"
type="button"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.15 }}
onClick={() => setDeleteConfirmOpen(true)}
tabIndex={-1}
className="bg-[#0D121A] size-7 flex items-center justify-center rounded-full transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-offset-2 focus:outline-none cursor-pointer shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)]"
disabled={deleteMutation.isPending}
>
<Trash2Icon className="size-4 text-red-500" />
<span className="sr-only">Delete document</span>
</motion.button>
) : (
<motion.div
key="confirm"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.15 }}
className="flex items-center gap-1 px-1 bg-[#0D121A] rounded-full shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)]"
>
<button
type="button"
onClick={() => setDeleteConfirmOpen(false)}
disabled={deleteMutation.isPending}
className="size-6 flex items-center justify-center rounded-full transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-offset-2 focus:outline-none cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
>
<XIcon className="size-4 text-[#737373]" />
<span className="sr-only">Cancel delete</span>
</button>
<button
type="button"
onClick={handleDelete}
disabled={deleteMutation.isPending}
className="size-6 flex items-center justify-center rounded-full transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-offset-2 focus:outline-none cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
>
{deleteMutation.isPending ? (
<Loader2 className="size-4 text-green-500 animate-spin" />
) : (
<CheckIcon className="size-4 text-green-500" />
)}
<span className="sr-only">Confirm delete</span>
</button>
</motion.div>
)}
</AnimatePresence>
)
}
function CopySessionIdButton({ sessionId }: { sessionId: string }) {
const [copied, setCopied] = useState(false)
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(sessionId)
setCopied(true)
toast.success("Copy session id")
setTimeout(() => setCopied(false), 1500)
} catch {
toast.error("Failed to copy session id")
}
}, [sessionId])
return (
<button
type="button"
onClick={handleCopy}
tabIndex={-1}
title="Copy session id"
aria-label="Copy session id"
className="bg-[#0D121A] w-7 h-7 flex items-center justify-center rounded-full transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-offset-2 focus:outline-none cursor-pointer shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)]"
>
{copied ? (
<CheckIcon className="w-4 h-4 text-green-500" />
) : (
<CopyIcon className="w-4 h-4 text-[#737373]" />
)}
<span className="sr-only">Copy session id</span>
</button>
)
}
export function DocumentModal({
document: _document,
isOpen,
onClose,
}: DocumentModalProps) {
const isMobile = useIsMobile()
const { updateMutation, deleteMutation } = useDocumentMutations({ onClose })
const { initialEditorContent, initialEditorString } = useMemo(() => {
const content = _document?.content as string | null | undefined
return {
initialEditorContent: content ?? undefined,
initialEditorString: content ?? "",
}
}, [_document?.content])
const pluginDocument = useMemo(
() => parsePluginDocument(_document),
[_document],
)
const [draftContentString, setDraftContentString] =
useState(initialEditorString)
const [editorResetNonce, setEditorResetNonce] = useState(0)
const [lastSavedContent, setLastSavedContent] = useState<string | null>(null)
const resetEditor = useCallback(() => {
setDraftContentString(initialEditorString)
setEditorResetNonce((n) => n + 1)
setLastSavedContent(null)
}, [initialEditorString])
useEffect(() => {
setDraftContentString(initialEditorString)
setEditorResetNonce((n) => n + 1)
setLastSavedContent(null)
}, [initialEditorString])
useEffect(() => {
if (!isOpen) {
resetEditor()
}
}, [isOpen, resetEditor])
const hasUnsavedChanges =
draftContentString !== initialEditorString &&
draftContentString !== lastSavedContent
const handleSave = useCallback(() => {
if (!_document?.id) return
updateMutation.mutate(
{ documentId: _document.id, content: draftContentString },
{
onSuccess: (_data, variables) => setLastSavedContent(variables.content),
},
)
}, [_document?.id, draftContentString, updateMutation])
const textEditorProps = useMemo(
() => ({
documentId: _document?.id ?? "",
editorResetNonce,
initialEditorContent,
hasUnsavedChanges,
isSaving: updateMutation.isPending,
onContentChange: setDraftContentString,
onSave: handleSave,
onReset: resetEditor,
}),
[
_document?.id,
editorResetNonce,
initialEditorContent,
hasUnsavedChanges,
updateMutation.isPending,
handleSave,
resetEditor,
],
)
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent
className={cn(
"p-0 border-none bg-[#1B1F24] flex flex-col px-3 md:px-4 pt-3 pb-4 gap-3",
isMobile
? "w-[calc(100vw-1rem)]! h-[calc(100dvh-1rem)]! max-w-none! max-h-none! rounded-xl"
: "w-[80%]! max-w-[1158px]! h-[86%]! max-h-[684px]! rounded-[22px]",
dmSansClassName(),
)}
style={{
boxShadow:
"0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset",
}}
showCloseButton={false}
>
<DialogTitle className="sr-only">
{_document?.title} - Document
</DialogTitle>
<div className="flex items-center justify-between h-fit gap-2 md:gap-4">
<div className="flex-1 min-w-0">
<Title
title={_document?.title}
documentType={_document?.type ?? "text"}
url={_document?.url}
pluginIconSrc={pluginDocument?.pluginIconSrc}
/>
</div>
<div className="flex items-center gap-1.5 md:gap-2 shrink-0">
{pluginDocument?.kind === "claude-code-doc" &&
_document?.customId && (
<CopySessionIdButton sessionId={_document.customId} />
)}
<DeleteButton
documentId={_document?.id}
customId={_document?.customId}
deleteMutation={deleteMutation}
/>
{_document?.url && (
<a
href={getDocumentSourceUrl(_document)}
target="_blank"
rel="noopener noreferrer"
className={cn(
"flex items-center gap-1 bg-[#0D121A] rounded-full shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)]",
isMobile ? "size-7 justify-center" : "px-3 py-2",
)}
>
{!isMobile && (
<span className="line-clamp-1">Visit source</span>
)}
<ArrowUpRightIcon className="size-4 text-[#737373]" />
</a>
)}
<DialogPrimitive.Close
className="bg-[#0D121A] size-7 flex items-center justify-center rounded-full transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus:outline-none disabled:pointer-events-none cursor-pointer [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)]"
data-slot="dialog-close"
type="button"
tabIndex={-1}
>
<XIcon stroke="#737373" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</div>
</div>
<div className="flex-1 grid grid-cols-1 md:grid-cols-[2fr_1fr] gap-3 overflow-hidden min-h-0">
<div
id="document-preview"
className={cn(
"bg-[#14161A] rounded-[14px] overflow-hidden flex flex-col shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)] relative",
)}
>
<DocumentContent
document={_document}
textEditorProps={textEditorProps}
pluginDocument={pluginDocument}
/>
</div>
<div
id="document-memories-summary"
className={cn(
"gap-3 flex flex-col overflow-hidden",
dmSansClassName(),
)}
>
{pluginDocument &&
pluginDocument.kind !== "claude-code-doc" &&
pluginDocument.kind !== "openclaw-session" && (
<PluginDetails parsed={pluginDocument} />
)}
{_document && (_document.summary || pluginDocument?.summary) && (
<DocumentSummary
memoryEntries={_document.memoryEntries}
summary={
(pluginDocument?.summary ?? _document.summary) as string
}
createdAt={_document.createdAt}
/>
)}
{_document?.memoryEntries && _document.memoryEntries.length > 0 && (
<GraphListMemories
memoryEntries={_document.memoryEntries as MemoryEntry[]}
documentId={_document.id}
/>
)}
</div>
</div>
</DialogContent>
</Dialog>
)
}