Browse Source

fix(app): large text pasted into prompt-input causes main thread lock

Adam 6 months ago
parent
commit
7e681b0bc0

+ 33 - 13
packages/app/src/components/prompt-input.tsx

@@ -89,6 +89,8 @@ const EXAMPLES = [
   "prompt.example.25",
 ] as const
 
+const NON_EMPTY_TEXT = /[^\s\u200B]/
+
 export const PromptInput: Component<PromptInputProps> = (props) => {
   const sdk = useSDK()
   const sync = useSync()
@@ -636,7 +638,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     let buffer = ""
 
     const flushText = () => {
-      const content = buffer.replace(/\r\n?/g, "\n").replace(/\u200B/g, "")
+      let content = buffer
+      if (content.includes("\r")) content = content.replace(/\r\n?/g, "\n")
+      if (content.includes("\u200B")) content = content.replace(/\u200B/g, "")
       buffer = ""
       if (!content) return
       parts.push({ type: "text", content, start: position, end: position + content.length })
@@ -714,10 +718,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     const rawParts = parseFromDOM()
     const images = imageAttachments()
     const cursorPosition = getCursorPosition(editorRef)
-    const rawText = rawParts.map((p) => ("content" in p ? p.content : "")).join("")
-    const trimmed = rawText.replace(/\u200B/g, "").trim()
+    const rawText =
+      rawParts.length === 1 && rawParts[0]?.type === "text"
+        ? rawParts[0].content
+        : rawParts.map((p) => ("content" in p ? p.content : "")).join("")
     const hasNonText = rawParts.some((part) => part.type !== "text")
-    const shouldReset = trimmed.length === 0 && !hasNonText && images.length === 0
+    const shouldReset = !NON_EMPTY_TEXT.test(rawText) && !hasNonText && images.length === 0
 
     if (shouldReset) {
       closePopover()
@@ -757,19 +763,31 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   }
 
   const addPart = (part: ContentPart) => {
+    if (part.type === "image") return false
+
     const selection = window.getSelection()
-    if (!selection || selection.rangeCount === 0) return
+    if (!selection) return false
 
-    const cursorPosition = getCursorPosition(editorRef)
-    const currentPrompt = prompt.current()
-    const rawText = currentPrompt.map((p) => ("content" in p ? p.content : "")).join("")
-    const textBeforeCursor = rawText.substring(0, cursorPosition)
-    const atMatch = textBeforeCursor.match(/@(\S*)$/)
+    if (selection.rangeCount === 0 || !editorRef.contains(selection.anchorNode)) {
+      editorRef.focus()
+      const cursor = prompt.cursor() ?? promptLength(prompt.current())
+      setCursorPosition(editorRef, cursor)
+    }
+
+    if (selection.rangeCount === 0) return false
+    const range = selection.getRangeAt(0)
+    if (!editorRef.contains(range.startContainer)) return false
 
     if (part.type === "file" || part.type === "agent") {
+      const cursorPosition = getCursorPosition(editorRef)
+      const rawText = prompt
+        .current()
+        .map((p) => ("content" in p ? p.content : ""))
+        .join("")
+      const textBeforeCursor = rawText.substring(0, cursorPosition)
+      const atMatch = textBeforeCursor.match(/@(\S*)$/)
       const pill = createPill(part)
       const gap = document.createTextNode(" ")
-      const range = selection.getRangeAt(0)
 
       if (atMatch) {
         const start = atMatch.index ?? cursorPosition - atMatch[0].length
@@ -784,8 +802,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       range.collapse(true)
       selection.removeAllRanges()
       selection.addRange(range)
-    } else if (part.type === "text") {
-      const range = selection.getRangeAt(0)
+    }
+
+    if (part.type === "text") {
       const fragment = createTextFragment(part.content)
       const last = fragment.lastChild
       range.deleteContents()
@@ -821,6 +840,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
 
     handleInput()
     closePopover()
+    return true
   }
 
   const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {

+ 21 - 1
packages/app/src/components/prompt-input/attachments.ts

@@ -7,6 +7,19 @@ import { getCursorPosition } from "./editor-dom"
 
 export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
 export const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"]
+const LARGE_PASTE_CHARS = 8000
+const LARGE_PASTE_BREAKS = 120
+
+function largePaste(text: string) {
+  if (text.length >= LARGE_PASTE_CHARS) return true
+  let breaks = 0
+  for (const char of text) {
+    if (char !== "\n") continue
+    breaks += 1
+    if (breaks >= LARGE_PASTE_BREAKS) return true
+  }
+  return false
+}
 
 type PromptAttachmentsInput = {
   editor: () => HTMLDivElement | undefined
@@ -14,7 +27,7 @@ type PromptAttachmentsInput = {
   isDialogActive: () => boolean
   setDraggingType: (type: "image" | "@mention" | null) => void
   focusEditor: () => void
-  addPart: (part: ContentPart) => void
+  addPart: (part: ContentPart) => boolean
   readClipboardImage?: () => Promise<File | null>
 }
 
@@ -89,6 +102,13 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
     }
 
     if (!plainText) return
+
+    if (largePaste(plainText)) {
+      if (input.addPart({ type: "text", content: plainText, start: 0, end: 0 })) return
+      input.focusEditor()
+      if (input.addPart({ type: "text", content: plainText, start: 0, end: 0 })) return
+    }
+
     const inserted = typeof document.execCommand === "function" && document.execCommand("insertText", false, plainText)
     if (inserted) return
 

+ 22 - 0
packages/app/src/components/prompt-input/editor-dom.test.ts

@@ -24,6 +24,28 @@ describe("prompt-input editor dom", () => {
     expect((container.childNodes[1] as HTMLElement).tagName).toBe("BR")
   })
 
+  test("createTextFragment avoids break-node explosion for large multiline content", () => {
+    const content = Array.from({ length: 220 }, () => "line").join("\n")
+    const fragment = createTextFragment(content)
+    const container = document.createElement("div")
+    container.appendChild(fragment)
+
+    expect(container.childNodes.length).toBe(1)
+    expect(container.childNodes[0]?.nodeType).toBe(Node.TEXT_NODE)
+    expect(container.textContent).toBe(content)
+  })
+
+  test("createTextFragment keeps terminal break in large multiline fallback", () => {
+    const content = `${Array.from({ length: 220 }, () => "line").join("\n")}\n`
+    const fragment = createTextFragment(content)
+    const container = document.createElement("div")
+    container.appendChild(fragment)
+
+    expect(container.childNodes.length).toBe(2)
+    expect(container.childNodes[0]?.textContent).toBe(content.slice(0, -1))
+    expect((container.childNodes[1] as HTMLElement).tagName).toBe("BR")
+  })
+
   test("length helpers treat breaks as one char and ignore zero-width chars", () => {
     const container = document.createElement("div")
     container.appendChild(document.createTextNode("ab\u200B"))

+ 15 - 0
packages/app/src/components/prompt-input/editor-dom.ts

@@ -1,5 +1,20 @@
+const MAX_BREAKS = 200
+
 export function createTextFragment(content: string): DocumentFragment {
   const fragment = document.createDocumentFragment()
+  let breaks = 0
+  for (const char of content) {
+    if (char !== "\n") continue
+    breaks += 1
+    if (breaks > MAX_BREAKS) {
+      const tail = content.endsWith("\n")
+      const text = tail ? content.slice(0, -1) : content
+      if (text) fragment.appendChild(document.createTextNode(text))
+      if (tail) fragment.appendChild(document.createElement("br"))
+      return fragment
+    }
+  }
+
   const segments = content.split("\n")
   segments.forEach((segment, index) => {
     if (segment) {