// Modified in the independent hardening fork; see docs/hardening/README.md. // Modified for AgentDeskFixes: preserve scoped drafts and guard asynchronous editor operations. "use client" import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore, type ChangeEvent } from "react" import Placeholder from "@tiptap/extension-placeholder" import { EditorContent, useEditor } from "@tiptap/react" import StarterKit from "@tiptap/starter-kit" import { ImageIcon, Loader2Icon, MessageSquareTextIcon, PaperclipIcon, SendHorizonalIcon, SendIcon, SparklesIcon, XIcon, } from "lucide-react" import { Button } from "@/components/ui/button" import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from "@/components/ui/command" import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" import { buildSendableEditorHTML, hasUploadingEditorImages, markEditorImageUploadedByTitle, MessageImageExtension, removeEditorImageByTitle, revokeEditorObjectUrl, setEditorImageUploadingByTitle, type UploadedEditorImage, } from "@/lib/im-editor-image" import { createMessageEditorDraftStore, type MessageEditorDraft, type MessageEditorDraftStore, } from "@/lib/message-editor-drafts" import { generateUUID } from "@/lib/utils" import { useI18n } from "@/i18n/provider" export type UploadedMessageEditorImage = UploadedEditorImage & { url: string } export type MessageEditorQuickReply = { id: number groupName?: string title: string content: string } type SharedMessageEditorVariant = "customer" | "agent" export type SharedMessageEditorAICopilot = { onSuggest: () => Promise loading?: boolean } export type SharedMessageEditorProps = { variant: SharedMessageEditorVariant conversationId?: number | null draftScope?: string draftStore?: MessageEditorDraftStore sending?: boolean disabled?: boolean uploadingAsset?: boolean manageLocalUploading?: boolean quickReplies?: { open: boolean loading: boolean items: MessageEditorQuickReply[] onOpenChange: (open: boolean) => void } aiCopilot?: SharedMessageEditorAICopilot onSend: (html: string) => Promise onUploadImage: (file: File) => Promise onSendAttachment: (file: File) => Promise onTyping?: () => void } export function SharedMessageEditor({ variant, conversationId, draftScope = "default", draftStore, sending = false, disabled = false, uploadingAsset = false, manageLocalUploading = false, quickReplies, aiCopilot, onSend, onUploadImage, onSendAttachment, onTyping, }: SharedMessageEditorProps) { const t = useI18n() const [localUploading, setLocalUploading] = useState(false) const [localDraftStore] = useState(createMessageEditorDraftStore) const drafts = draftStore ?? localDraftStore const draftSending = useSyncExternalStore(drafts.subscribeSending, drafts.isSending, () => false) const draftKey = String(conversationId ?? 0) const displayedDraftRef = useRef<{ key: string; draft: MessageEditorDraft } | null>(null) const contextRef = useRef({ draftKey, draftScope }) contextRef.current = { draftKey, draftScope } const mountedRef = useRef(false) const suggestionRequestRef = useRef(0) const suggestionPendingRef = useRef(false) const uploadingRef = useRef(false) const mutationGenerationRef = useRef(0) const [quickReplySearch, setQuickReplySearch] = useState("") const [suggestedReply, setSuggestedReply] = useState(null) const [isGeneratingSuggestion, setIsGeneratingSuggestion] = useState(false) const quickReplySearchRef = useRef(quickReplySearch) quickReplySearchRef.current = quickReplySearch const isSlashTriggeredRef = useRef(false) const quickRepliesRef = useRef(quickReplies) const imageInputRef = useRef(null) const attachmentInputRef = useRef(null) const onSendRef = useRef(onSend) const onUploadImageRef = useRef(onUploadImage) const onSendAttachmentRef = useRef(onSendAttachment) const onTypingRef = useRef(onTyping) const shouldRestoreFocusRef = useRef(false) const placeholderRef = useRef(t("conversation.editorPlaceholder")) const isCustomer = variant === "customer" const isUploading = uploadingAsset || (manageLocalUploading && localUploading) const isSending = sending || draftSending placeholderRef.current = t("conversation.editorPlaceholder") useEffect(() => { quickRepliesRef.current = quickReplies }, [quickReplies]) useEffect(() => { mountedRef.current = true return () => { mountedRef.current = false suggestionRequestRef.current += 1 // A supplied store belongs to the parent and survives editor unmounts. if (!draftStore) localDraftStore.clear() } }, [draftStore, localDraftStore]) useLayoutEffect(() => { onSendRef.current = onSend onUploadImageRef.current = onUploadImage onSendAttachmentRef.current = onSendAttachment onTypingRef.current = onTyping }, [onSend, onUploadImage, onSendAttachment, onTyping]) const editor = useEditor({ immediatelyRender: false, extensions: [ StarterKit.configure({ heading: false, blockquote: false, codeBlock: false, bulletList: false, orderedList: false, horizontalRule: false, }), MessageImageExtension, Placeholder.configure({ placeholder: () => placeholderRef.current, }), ], content: "", onUpdate: ({ editor }) => { const displayed = displayedDraftRef.current if (displayed) drafts.update(displayed.draft, editor.getHTML()) if (!editor.isEmpty && isMeaningfulHTML(editor.getHTML())) { onTypingRef.current?.() } if (!quickRepliesRef.current || quickRepliesRef.current.loading) { return } const { $from } = editor.state.selection const blockText = $from.parent.textContent const trimmed = blockText.trimStart() const match = trimmed.match(/^\/([^\s]*)$/) if (match) { isSlashTriggeredRef.current = true const query = match[1] setQuickReplySearch(query) if (!quickRepliesRef.current.open) { quickRepliesRef.current.onOpenChange(true) } } else if (isSlashTriggeredRef.current && quickRepliesRef.current.open) { isSlashTriggeredRef.current = false setQuickReplySearch("") quickRepliesRef.current.onOpenChange(false) } }, editorProps: { attributes: { class: getEditorClassName(variant), }, handleKeyDown: (_view, event) => { if (event.key === "Escape" && quickRepliesRef.current?.open) { event.preventDefault() quickRepliesRef.current.onOpenChange(false) setQuickReplySearch("") isSlashTriggeredRef.current = false return true } if ( (event.key === "Enter" || event.key === "Tab") && !event.shiftKey && quickRepliesRef.current?.open ) { const query = quickReplySearchRef.current.toLowerCase().trim() const items = quickRepliesRef.current.items const matching = items.filter((item) => { if (!query) return true const target = `${item.groupName ?? ""} ${item.title} ${item.content}`.toLowerCase() return target.includes(query) }) if (matching.length > 0) { event.preventDefault() handleInsertQuickReply(matching[0]) return true } if (event.key === "Enter") { event.preventDefault() return true } } if (event.key === "Enter" && !event.shiftKey) { event.preventDefault() void handleSend() return true } return false }, handlePaste: (_view, event) => { if (disabled || isUploading || isSending) { return false } const imageFile = getClipboardImageFile(event.clipboardData) if (!imageFile) { return false } event.preventDefault() void insertUploadedImage(imageFile) return true }, }, }) useLayoutEffect(() => { mutationGenerationRef.current += 1 uploadingRef.current = false setLocalUploading(false) drafts.setScope(draftScope) }, [drafts, draftScope]) useLayoutEffect(() => { if (!editor) return const draft = drafts.get(draftKey) displayedDraftRef.current = { key: draftKey, draft } editor.commands.setContent(draft.html, { emitUpdate: false }) // Image metadata is retained separately while a placeholder upload settles. for (const [title, uploaded] of draft.uploadedImages) { markEditorImageUploadedByTitle(editor, title, uploaded, draft.uploadedImages) } suggestionRequestRef.current += 1 suggestionPendingRef.current = false setSuggestedReply(null) setIsGeneratingSuggestion(false) setQuickReplySearch("") isSlashTriggeredRef.current = false quickRepliesRef.current?.onOpenChange(false) }, [draftKey, draftScope, drafts, editor]) useLayoutEffect(() => { if (!editor) return // The parent-owned draft may finish sending after this editor was remounted. return drafts.subscribe((draft) => { if (editor.isDestroyed || displayedDraftRef.current?.draft !== draft) return if (editor.getHTML() !== draft.html) editor.commands.setContent(draft.html, { emitUpdate: false }) for (const [title, uploaded] of draft.uploadedImages) { markEditorImageUploadedByTitle(editor, title, uploaded, draft.uploadedImages) } if (!draft.html) { setSuggestedReply(null) setQuickReplySearch("") isSlashTriggeredRef.current = false quickRepliesRef.current?.onOpenChange(false) } }) }, [drafts, editor]) useEffect(() => { if (!editor) { return } editor.setEditable(!disabled && !isUploading) }, [disabled, editor, isUploading]) useEffect(() => { if (!editor || disabled || isUploading || !shouldRestoreFocusRef.current) { return } const draft = displayedDraftRef.current?.draft const frame = requestAnimationFrame(() => { if (!editor.isDestroyed && displayedDraftRef.current?.draft === draft) editor.commands.focus() }) return () => cancelAnimationFrame(frame) }, [disabled, editor, isUploading]) async function handleSend() { const displayed = displayedDraftRef.current if (!editor || !displayed || disabled || isUploading || isSending || drafts.isSending() || uploadingRef.current) return if (displayed.key !== contextRef.current.draftKey) return const rawHTML = editor.getHTML() if (hasUploadingEditorImages(rawHTML, displayed.draft.uploadedImages)) return const html = buildSendableEditorHTML(rawHTML, displayed.draft.uploadedImages) if (!isMeaningfulHTML(html)) return const sent = drafts.beginSend(displayed.key) if (!sent) return const send = onSendRef.current try { await send(html) const cleared = drafts.finishSend(sent, true) if (!cleared || !mountedRef.current || editor.isDestroyed || displayedDraftRef.current?.draft !== sent.draft) return editor.commands.setContent("", { emitUpdate: false }) setQuickReplySearch("") isSlashTriggeredRef.current = false quickRepliesRef.current?.onOpenChange(false) setSuggestedReply(null) if (!isCustomer) focusEditorEnd() } catch { // The caller displays the error. Keep the entire draft, including images, for retry. drafts.finishSend(sent, false) } } async function handleSelectImage(event: ChangeEvent) { const file = event.target.files?.[0] event.target.value = "" if (!file || !editor || disabled || isUploading || isSending) { restoreFocusIfNeeded() return } await insertUploadedImage(file) } async function insertUploadedImage(file: File) { const displayed = displayedDraftRef.current if (!editor || !displayed || disabled || isUploading || isSending || drafts.isSending() || uploadingRef.current) return const { key, draft } = displayed const upload = onUploadImageRef.current const generation = mutationGenerationRef.current const isVisible = () => mountedRef.current && !editor.isDestroyed && displayedDraftRef.current?.draft === draft shouldRestoreFocusRef.current = true uploadingRef.current = true const objectUrl = URL.createObjectURL(file) draft.objectUrls.add(objectUrl) const placeholderId = `uploading-${generateUUID()}` editor.chain().focus().setImage({ src: objectUrl, alt: file.name || "uploading-image", title: placeholderId, }).run() setEditorImageUploadingByTitle(editor, placeholderId) const removePlaceholder = () => { if (!drafts.contains(key, draft)) return if (isVisible()) { removeEditorImageByTitle(editor, placeholderId) } else { const template = document.createElement("template") template.innerHTML = draft.html for (const image of template.content.querySelectorAll("img")) { if (image.getAttribute("title") === placeholderId) image.remove() } drafts.update(draft, template.innerHTML) drafts.notify(draft) } revokeEditorObjectUrl(draft.objectUrls, objectUrl) } try { setLocalUploading(true) const uploaded = await upload(file) if (!drafts.contains(key, draft)) return if (!uploaded?.assetId || !uploaded.provider || !uploaded.storageKey) { removePlaceholder() return } draft.uploadedImages.set(placeholderId, uploaded) drafts.notify(draft) } catch { removePlaceholder() } finally { if (generation === mutationGenerationRef.current) { uploadingRef.current = false if (mountedRef.current) setLocalUploading(false) } if (isVisible()) restoreFocusIfNeeded() } } async function handleSelectAttachment(event: ChangeEvent) { const file = event.target.files?.[0] event.target.value = "" if (!file || disabled || isUploading || isSending || drafts.isSending() || uploadingRef.current) { restoreFocusIfNeeded() return } const draft = displayedDraftRef.current?.draft const sendAttachment = onSendAttachmentRef.current const generation = mutationGenerationRef.current shouldRestoreFocusRef.current = editor?.isFocused ?? true uploadingRef.current = true try { setLocalUploading(true) await sendAttachment(file) } catch { // The caller reports failed attachment sends; no text draft is discarded. } finally { if (generation === mutationGenerationRef.current) { uploadingRef.current = false if (mountedRef.current) setLocalUploading(false) } if (displayedDraftRef.current?.draft === draft) restoreFocusIfNeeded() } } function handleInsertQuickReply(item: MessageEditorQuickReply) { if (!editor || disabled || isUploading) { return } if (!item.content.trim()) { return } const { $from } = editor.state.selection const blockText = $from.parent.textContent const trimmed = blockText.trimStart() const match = trimmed.match(/^\/([^\s]*)$/) if (match) { const slashIndex = blockText.indexOf("/") const blockStart = $from.start() + slashIndex const matchLen = match[0].length editor .chain() .focus() .deleteRange({ from: blockStart, to: blockStart + matchLen }) .insertContent(item.content) .run() } else { const fullText = editor.getText().trim() const fullMatch = fullText.match(/^\/([^\s]*)$/) if (fullMatch) { editor.chain().focus().setContent(item.content).run() } else { editor.chain().focus().insertContent(item.content).run() } } quickRepliesRef.current?.onOpenChange(false) setQuickReplySearch("") isSlashTriggeredRef.current = false focusEditorEnd() } function focusEditorEnd() { const draft = displayedDraftRef.current?.draft requestAnimationFrame(() => { if (editor && mountedRef.current && !editor.isDestroyed && displayedDraftRef.current?.draft === draft) { editor.commands.focus("end") } }) } function restoreFocusIfNeeded() { if (editor && shouldRestoreFocusRef.current) { const draft = displayedDraftRef.current?.draft requestAnimationFrame(() => { if (mountedRef.current && !editor.isDestroyed && displayedDraftRef.current?.draft === draft) editor.commands.focus() }) } } const isGenerating = isGeneratingSuggestion || Boolean(aiCopilot?.loading) async function handleTriggerSuggest() { if (!aiCopilot || disabled || isUploading || isGenerating || suggestionPendingRef.current) return const requestId = ++suggestionRequestRef.current suggestionPendingRef.current = true const context = contextRef.current setIsGeneratingSuggestion(true) try { const text = await aiCopilot.onSuggest() if (mountedRef.current && requestId === suggestionRequestRef.current && context.draftKey === contextRef.current.draftKey && context.draftScope === contextRef.current.draftScope && text?.trim()) { setSuggestedReply(text.trim()) } } catch { // The caller displays the suggestion error. Ignore any late failure here. } finally { if (mountedRef.current && requestId === suggestionRequestRef.current) { suggestionPendingRef.current = false setIsGeneratingSuggestion(false) } } } function handleInsertSuggestedReply() { if (!editor || !suggestedReply) { return } editor.chain().focus().insertContent(suggestedReply).run() setSuggestedReply(null) focusEditorEnd() } function handleDismissSuggestedReply() { setSuggestedReply(null) } const editorContent = ( <> {!isCustomer && suggestedReply ? (
{t("aiCopilot.suggestReply")}
{suggestedReply}
) : null} {isCustomer ? (
) : (
)}
{quickReplies ? ( { if (!nextOpen) { setQuickReplySearch("") isSlashTriggeredRef.current = false } quickReplies.onOpenChange(nextOpen) }} > event.preventDefault()} title={t("conversation.quickReplySlashHint")} aria-label={t("conversation.quickReplySlashHint")} /> } > {t("conversation.emptyQuickReplies")} {quickReplies.items.map((item) => ( handleInsertQuickReply(item)} >
{item.groupName ? `${item.groupName} / ${item.title}` : item.title} {item.content}
))}
) : null} {!isCustomer && aiCopilot ? ( ) : null} {!isCustomer && quickReplies ? ( {t("conversation.quickReplySlashHint")} ) : null}

{t("conversation.enterToSend")}

{isCustomer ? ( ) : ( )}
) if (isCustomer) { return (
{editorContent}
) } return (
{editorContent}
) } function getEditorClassName(variant: SharedMessageEditorVariant) { if (variant === "customer") { return "agent-desk-scrollbar min-h-12 max-h-40 overflow-y-auto px-1.5 py-1 text-sm leading-6 text-foreground outline-none [&_p]:m-0 [&_p+*]:mt-2 [&_.agent-desk-editor-image-wrap]:my-2 [&_.agent-desk-editor-image]:max-h-64 [&_.agent-desk-editor-image]:max-w-full [&_.agent-desk-editor-image]:rounded-lg [&_.agent-desk-editor-image]:object-contain [&_.agent-desk-editor-image-wrap-uploading_.agent-desk-editor-image]:opacity-55" } return "h-full min-h-12 max-h-[20vh] overflow-y-auto px-1.5 py-1 text-sm leading-6 text-foreground outline-none sm:max-h-none [&_.ProseMirror-focused]:outline-none [&_p]:m-0 [&_p+.agent-desk-editor-image-wrap]:mt-2 [&_.agent-desk-editor-image-wrap]:my-2 [&_.agent-desk-editor-image]:max-h-64 [&_.agent-desk-editor-image]:max-w-full [&_.agent-desk-editor-image]:rounded-md [&_.agent-desk-editor-image]:object-contain [&_.agent-desk-editor-image-wrap-uploading_.agent-desk-editor-image]:opacity-55 [&_p.is-editor-empty:first-child]:before:text-muted-foreground" } function getToolbarClassName(variant: SharedMessageEditorVariant) { if (variant === "customer") { return "mt-2 flex items-center justify-between" } return "flex items-center justify-between rounded-b-sm border-t border-border bg-card px-2 pt-1 pb-2" } function getIconButtonClassName(variant: SharedMessageEditorVariant) { if (variant === "customer") { return "text-muted-foreground hover:bg-muted hover:text-foreground" } return "size-8" } function isMeaningfulHTML(html: string) { const normalized = html .replace(/

<\/p>/g, "") .replace(/


<\/p>/g, "") .replace(/\s+/g, "") if (//i.test(normalized)) { return true } const plainText = normalized.replace(/<[^>]+>/g, "").trim() return plainText !== "" } function getClipboardImageFile(data: DataTransfer | null) { if (!data) { return null } for (const item of Array.from(data.items)) { if (item.kind === "file" && item.type.startsWith("image/")) { return item.getAsFile() } } return null }