Skip to content

Commit 7e681b0

Browse files
committed
fix(app): large text pasted into prompt-input causes main thread lock
1 parent 4e9ef3e commit 7e681b0

4 files changed

Lines changed: 91 additions & 14 deletions

File tree

packages/app/src/components/prompt-input.tsx

Lines changed: 33 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,8 @@ const EXAMPLES = [
8989
"prompt.example.25",
9090
] as const
9191

92+
const NON_EMPTY_TEXT = /[^\s\u200B]/
93+
9294
export const PromptInput: Component<PromptInputProps> = (props) => {
9395
const sdk = useSDK()
9496
const sync = useSync()
@@ -636,7 +638,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
636638
let buffer = ""
637639

638640
const flushText = () => {
639-
const content = buffer.replace(/\r\n?/g, "\n").replace(/\u200B/g, "")
641+
let content = buffer
642+
if (content.includes("\r")) content = content.replace(/\r\n?/g, "\n")
643+
if (content.includes("\u200B")) content = content.replace(/\u200B/g, "")
640644
buffer = ""
641645
if (!content) return
642646
parts.push({ type: "text", content, start: position, end: position + content.length })
@@ -714,10 +718,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
714718
const rawParts = parseFromDOM()
715719
const images = imageAttachments()
716720
const cursorPosition = getCursorPosition(editorRef)
717-
const rawText = rawParts.map((p) => ("content" in p ? p.content : "")).join("")
718-
const trimmed = rawText.replace(/\u200B/g, "").trim()
721+
const rawText =
722+
rawParts.length === 1 && rawParts[0]?.type === "text"
723+
? rawParts[0].content
724+
: rawParts.map((p) => ("content" in p ? p.content : "")).join("")
719725
const hasNonText = rawParts.some((part) => part.type !== "text")
720-
const shouldReset = trimmed.length === 0 && !hasNonText && images.length === 0
726+
const shouldReset = !NON_EMPTY_TEXT.test(rawText) && !hasNonText && images.length === 0
721727

722728
if (shouldReset) {
723729
closePopover()
@@ -757,19 +763,31 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
757763
}
758764

759765
const addPart = (part: ContentPart) => {
766+
if (part.type === "image") return false
767+
760768
const selection = window.getSelection()
761-
if (!selection || selection.rangeCount === 0) return
769+
if (!selection) return false
762770

763-
const cursorPosition = getCursorPosition(editorRef)
764-
const currentPrompt = prompt.current()
765-
const rawText = currentPrompt.map((p) => ("content" in p ? p.content : "")).join("")
766-
const textBeforeCursor = rawText.substring(0, cursorPosition)
767-
const atMatch = textBeforeCursor.match(/@(\S*)$/)
771+
if (selection.rangeCount === 0 || !editorRef.contains(selection.anchorNode)) {
772+
editorRef.focus()
773+
const cursor = prompt.cursor() ?? promptLength(prompt.current())
774+
setCursorPosition(editorRef, cursor)
775+
}
776+
777+
if (selection.rangeCount === 0) return false
778+
const range = selection.getRangeAt(0)
779+
if (!editorRef.contains(range.startContainer)) return false
768780

769781
if (part.type === "file" || part.type === "agent") {
782+
const cursorPosition = getCursorPosition(editorRef)
783+
const rawText = prompt
784+
.current()
785+
.map((p) => ("content" in p ? p.content : ""))
786+
.join("")
787+
const textBeforeCursor = rawText.substring(0, cursorPosition)
788+
const atMatch = textBeforeCursor.match(/@(\S*)$/)
770789
const pill = createPill(part)
771790
const gap = document.createTextNode(" ")
772-
const range = selection.getRangeAt(0)
773791

774792
if (atMatch) {
775793
const start = atMatch.index ?? cursorPosition - atMatch[0].length
@@ -784,8 +802,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
784802
range.collapse(true)
785803
selection.removeAllRanges()
786804
selection.addRange(range)
787-
} else if (part.type === "text") {
788-
const range = selection.getRangeAt(0)
805+
}
806+
807+
if (part.type === "text") {
789808
const fragment = createTextFragment(part.content)
790809
const last = fragment.lastChild
791810
range.deleteContents()
@@ -821,6 +840,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
821840

822841
handleInput()
823842
closePopover()
843+
return true
824844
}
825845

826846
const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {

packages/app/src/components/prompt-input/attachments.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,27 @@ import { getCursorPosition } from "./editor-dom"
77

88
export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
99
export const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"]
10+
const LARGE_PASTE_CHARS = 8000
11+
const LARGE_PASTE_BREAKS = 120
12+
13+
function largePaste(text: string) {
14+
if (text.length >= LARGE_PASTE_CHARS) return true
15+
let breaks = 0
16+
for (const char of text) {
17+
if (char !== "\n") continue
18+
breaks += 1
19+
if (breaks >= LARGE_PASTE_BREAKS) return true
20+
}
21+
return false
22+
}
1023

1124
type PromptAttachmentsInput = {
1225
editor: () => HTMLDivElement | undefined
1326
isFocused: () => boolean
1427
isDialogActive: () => boolean
1528
setDraggingType: (type: "image" | "@mention" | null) => void
1629
focusEditor: () => void
17-
addPart: (part: ContentPart) => void
30+
addPart: (part: ContentPart) => boolean
1831
readClipboardImage?: () => Promise<File | null>
1932
}
2033

@@ -89,6 +102,13 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
89102
}
90103

91104
if (!plainText) return
105+
106+
if (largePaste(plainText)) {
107+
if (input.addPart({ type: "text", content: plainText, start: 0, end: 0 })) return
108+
input.focusEditor()
109+
if (input.addPart({ type: "text", content: plainText, start: 0, end: 0 })) return
110+
}
111+
92112
const inserted = typeof document.execCommand === "function" && document.execCommand("insertText", false, plainText)
93113
if (inserted) return
94114

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,28 @@ describe("prompt-input editor dom", () => {
2424
expect((container.childNodes[1] as HTMLElement).tagName).toBe("BR")
2525
})
2626

27+
test("createTextFragment avoids break-node explosion for large multiline content", () => {
28+
const content = Array.from({ length: 220 }, () => "line").join("\n")
29+
const fragment = createTextFragment(content)
30+
const container = document.createElement("div")
31+
container.appendChild(fragment)
32+
33+
expect(container.childNodes.length).toBe(1)
34+
expect(container.childNodes[0]?.nodeType).toBe(Node.TEXT_NODE)
35+
expect(container.textContent).toBe(content)
36+
})
37+
38+
test("createTextFragment keeps terminal break in large multiline fallback", () => {
39+
const content = `${Array.from({ length: 220 }, () => "line").join("\n")}\n`
40+
const fragment = createTextFragment(content)
41+
const container = document.createElement("div")
42+
container.appendChild(fragment)
43+
44+
expect(container.childNodes.length).toBe(2)
45+
expect(container.childNodes[0]?.textContent).toBe(content.slice(0, -1))
46+
expect((container.childNodes[1] as HTMLElement).tagName).toBe("BR")
47+
})
48+
2749
test("length helpers treat breaks as one char and ignore zero-width chars", () => {
2850
const container = document.createElement("div")
2951
container.appendChild(document.createTextNode("ab\u200B"))

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,20 @@
1+
const MAX_BREAKS = 200
2+
13
export function createTextFragment(content: string): DocumentFragment {
24
const fragment = document.createDocumentFragment()
5+
let breaks = 0
6+
for (const char of content) {
7+
if (char !== "\n") continue
8+
breaks += 1
9+
if (breaks > MAX_BREAKS) {
10+
const tail = content.endsWith("\n")
11+
const text = tail ? content.slice(0, -1) : content
12+
if (text) fragment.appendChild(document.createTextNode(text))
13+
if (tail) fragment.appendChild(document.createElement("br"))
14+
return fragment
15+
}
16+
}
17+
318
const segments = content.split("\n")
419
segments.forEach((segment, index) => {
520
if (segment) {

0 commit comments

Comments
 (0)