feat: harden support, localize knowledge and add operator availability [skip ci]

Source-only publication of reviewed commit 64e96a7f423673dc29ad56485be9fc79bce8af1c.

Passed in isolation: focused frontend/backend tests, race checks, TypeScript typecheck, lint, Go compilation, Flowgram build, and deterministic SDK build/tests.

Not accepted as a production release: Next.js export/native assembly remains blocked by environment memory limits; browser interaction and MySQL staging acceptance remain unverified. No production server, database/schema, runtime configuration, deployment, release tag, or workflow execution was performed.

Expected source tree: 0a4a7528e9.
This commit is contained in:
2026-10-16 15:21:42 +00:00
parent 299ea50fb5
commit 2f7a491666
176 changed files with 11869 additions and 2716 deletions

View File

@@ -1,6 +1,8 @@
// 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, useRef, useState, type ChangeEvent } from "react"
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"
@@ -32,10 +34,14 @@ import {
MessageImageExtension,
removeEditorImageByTitle,
revokeEditorObjectUrl,
revokeEditorObjectUrls,
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"
@@ -60,6 +66,9 @@ export type SharedMessageEditorAICopilot = {
export type SharedMessageEditorProps = {
variant: SharedMessageEditorVariant
conversationId?: number | null
draftScope?: string
draftStore?: MessageEditorDraftStore
sending?: boolean
disabled?: boolean
uploadingAsset?: boolean
manageLocalUploading?: boolean
@@ -79,6 +88,9 @@ export type SharedMessageEditorProps = {
export function SharedMessageEditor({
variant,
conversationId,
draftScope = "default",
draftStore,
sending = false,
disabled = false,
uploadingAsset = false,
manageLocalUploading = false,
@@ -91,6 +103,18 @@ export function SharedMessageEditor({
}: 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<string | null>(null)
const [isGeneratingSuggestion, setIsGeneratingSuggestion] = useState(false)
@@ -105,11 +129,10 @@ export function SharedMessageEditor({
const onSendAttachmentRef = useRef(onSendAttachment)
const onTypingRef = useRef(onTyping)
const shouldRestoreFocusRef = useRef(false)
const objectUrlsRef = useRef<Set<string>>(new Set())
const uploadedImagesRef = useRef(new Map<string, UploadedMessageEditorImage>())
const placeholderRef = useRef(t("conversation.editorPlaceholder"))
const isCustomer = variant === "customer"
const isUploading = uploadingAsset || (manageLocalUploading && localUploading)
const isSending = sending || draftSending
placeholderRef.current = t("conversation.editorPlaceholder")
@@ -118,31 +141,21 @@ export function SharedMessageEditor({
}, [quickReplies])
useEffect(() => {
const objectUrls = objectUrlsRef.current
mountedRef.current = true
return () => {
revokeEditorObjectUrls(objectUrls)
mountedRef.current = false
suggestionRequestRef.current += 1
// A supplied store belongs to the parent and survives editor unmounts.
if (!draftStore) localDraftStore.clear()
}
}, [])
}, [draftStore, localDraftStore])
useEffect(() => {
useLayoutEffect(() => {
onSendRef.current = onSend
}, [onSend])
useEffect(() => {
onUploadImageRef.current = onUploadImage
}, [onUploadImage])
useEffect(() => {
onSendAttachmentRef.current = onSendAttachment
}, [onSendAttachment])
useEffect(() => {
onTypingRef.current = onTyping
}, [onTyping])
useEffect(() => {
setSuggestedReply(null)
}, [conversationId])
}, [onSend, onUploadImage, onSendAttachment, onTyping])
const editor = useEditor({
immediatelyRender: false,
@@ -162,6 +175,8 @@ export function SharedMessageEditor({
],
content: "",
onUpdate: ({ editor }) => {
const displayed = displayedDraftRef.current
if (displayed) drafts.update(displayed.draft, editor.getHTML())
if (!editor.isEmpty && isMeaningfulHTML(editor.getHTML())) {
onTypingRef.current?.()
}
@@ -227,7 +242,7 @@ export function SharedMessageEditor({
return false
},
handlePaste: (_view, event) => {
if (disabled || isUploading) {
if (disabled || isUploading || isSending) {
return false
}
const imageFile = getClipboardImageFile(event.clipboardData)
@@ -241,6 +256,49 @@ export function SharedMessageEditor({
},
})
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
@@ -252,42 +310,45 @@ export function SharedMessageEditor({
if (!editor || disabled || isUploading || !shouldRestoreFocusRef.current) {
return
}
requestAnimationFrame(() => {
editor.commands.focus()
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() {
if (!editor || disabled || isUploading) {
return
}
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, uploadedImagesRef.current)) {
return
}
const html = buildSendableEditorHTML(rawHTML, uploadedImagesRef.current)
if (!isMeaningfulHTML(html)) {
return
}
await onSendRef.current(html)
editor.commands.clearContent(true)
revokeEditorObjectUrls(objectUrlsRef.current)
uploadedImagesRef.current.clear()
setQuickReplySearch("")
isSlashTriggeredRef.current = false
quickRepliesRef.current?.onOpenChange(false)
setSuggestedReply(null)
if (!isCustomer) {
requestAnimationFrame(() => {
editor.commands.focus("end")
})
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<HTMLInputElement>) {
const file = event.target.files?.[0]
event.target.value = ""
if (!file || !editor || disabled || isUploading) {
if (!file || !editor || disabled || isUploading || isSending) {
restoreFocusIfNeeded()
return
}
@@ -295,68 +356,86 @@ export function SharedMessageEditor({
}
async function insertUploadedImage(file: File) {
if (!editor || disabled || isUploading) {
return
}
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)
objectUrlsRef.current.add(objectUrl)
draft.objectUrls.add(objectUrl)
const placeholderId = `uploading-${generateUUID()}`
editor
.chain()
.focus()
.setImage({
src: objectUrl,
alt: file.name || "uploading-image",
title: placeholderId,
})
.run()
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 onUploadImageRef.current(file)
const uploaded = await upload(file)
if (!drafts.contains(key, draft)) return
if (!uploaded?.assetId || !uploaded.provider || !uploaded.storageKey) {
removeEditorImageByTitle(editor, placeholderId)
revokeEditorObjectUrl(objectUrlsRef.current, objectUrl)
removePlaceholder()
return
}
markEditorImageUploadedByTitle(
editor,
placeholderId,
uploaded,
uploadedImagesRef.current
)
draft.uploadedImages.set(placeholderId, uploaded)
drafts.notify(draft)
} catch {
removePlaceholder()
} finally {
setLocalUploading(false)
requestAnimationFrame(() => {
if (!disabled && shouldRestoreFocusRef.current) {
editor.commands.focus()
}
})
if (generation === mutationGenerationRef.current) {
uploadingRef.current = false
if (mountedRef.current) setLocalUploading(false)
}
if (isVisible()) restoreFocusIfNeeded()
}
}
async function handleSelectAttachment(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0]
event.target.value = ""
if (!file || disabled || isUploading) {
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 onSendAttachmentRef.current(file)
await sendAttachment(file)
} catch {
// The caller reports failed attachment sends; no text draft is discarded.
} finally {
setLocalUploading(false)
requestAnimationFrame(() => {
if (editor && !disabled && shouldRestoreFocusRef.current) {
editor.commands.focus()
}
})
if (generation === mutationGenerationRef.current) {
uploadingRef.current = false
if (mountedRef.current) setLocalUploading(false)
}
if (displayedDraftRef.current?.draft === draft) restoreFocusIfNeeded()
}
}
@@ -396,15 +475,23 @@ export function SharedMessageEditor({
quickRepliesRef.current?.onOpenChange(false)
setQuickReplySearch("")
isSlashTriggeredRef.current = false
focusEditorEnd()
}
function focusEditorEnd() {
const draft = displayedDraftRef.current?.draft
requestAnimationFrame(() => {
editor.commands.focus("end")
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(() => {
editor.commands.focus()
if (mountedRef.current && !editor.isDestroyed && displayedDraftRef.current?.draft === draft) editor.commands.focus()
})
}
}
@@ -412,17 +499,23 @@ export function SharedMessageEditor({
const isGenerating = isGeneratingSuggestion || Boolean(aiCopilot?.loading)
async function handleTriggerSuggest() {
if (!aiCopilot || disabled || isUploading || isGenerating) {
return
}
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 (text && text.trim()) {
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 {
setIsGeneratingSuggestion(false)
if (mountedRef.current && requestId === suggestionRequestRef.current) {
suggestionPendingRef.current = false
setIsGeneratingSuggestion(false)
}
}
}
@@ -432,9 +525,7 @@ export function SharedMessageEditor({
}
editor.chain().focus().insertContent(suggestedReply).run()
setSuggestedReply(null)
requestAnimationFrame(() => {
editor.commands.focus("end")
})
focusEditorEnd()
}
function handleDismissSuggestedReply() {
@@ -520,7 +611,7 @@ export function SharedMessageEditor({
shouldRestoreFocusRef.current = editor?.isFocused ?? true
imageInputRef.current?.click()
}}
disabled={disabled || isUploading}
disabled={disabled || isUploading || isSending}
aria-label={isUploading ? t("conversation.imageUploading") : t("conversation.sendImage")}
title={isUploading ? t("conversation.imageUploading") : t("conversation.sendImage")}
>
@@ -536,7 +627,7 @@ export function SharedMessageEditor({
shouldRestoreFocusRef.current = editor?.isFocused ?? true
attachmentInputRef.current?.click()
}}
disabled={disabled || isUploading}
disabled={disabled || isUploading || isSending}
aria-label={isUploading ? t("conversation.attachmentUploading") : t("conversation.sendAttachment")}
title={isUploading ? t("conversation.attachmentUploading") : t("conversation.sendAttachment")}
>
@@ -640,21 +731,23 @@ export function SharedMessageEditor({
type="button"
size="icon"
onClick={() => void handleSend()}
disabled={disabled || isUploading}
aria-busy={isSending}
disabled={disabled || isUploading || isSending}
aria-label={t("conversation.send")}
title={t("conversation.send")}
className="bg-primary text-white shadow-[0_10px_20px_color-mix(in_srgb,var(--primary)_24%,transparent)] hover:bg-primary hover:brightness-105"
>
<SendHorizonalIcon />
{isSending ? <Loader2Icon className="animate-spin" /> : <SendHorizonalIcon />}
</Button>
) : (
<Button
type="button"
size="sm"
onClick={() => void handleSend()}
disabled={disabled || isUploading}
aria-busy={isSending}
disabled={disabled || isUploading || isSending}
>
<SendIcon className="mr-1 size-4" />
{isSending ? <Loader2Icon className="mr-1 size-4 animate-spin" /> : <SendIcon className="mr-1 size-4" />}
{isUploading ? t("conversation.uploading") : t("conversation.send")}
</Button>
)}