Skip to content

Commit 6807284

Browse files
authored
Merge pull request #4028 from Agenta-AI/AGE-3713-/-playground-editor-becomes-unresponsive-when-pasting-large-input
(enhancement)[AGE-3713]: Playground editor becomes unresponsive when pasting large input
2 parents a11143b + f9cfce4 commit 6807284

File tree

16 files changed

+1269
-137
lines changed

16 files changed

+1269
-137
lines changed

web/packages/agenta-ui/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
"@lexical/code": ">=0.38.0",
3737
"@lexical/code-shiki": ">=0.38.0",
3838
"@lexical/hashtag": ">=0.38.0",
39+
"@lexical/html": ">=0.38.0",
3940
"@lexical/link": ">=0.38.0",
4041
"@lexical/list": ">=0.38.0",
4142
"@lexical/mark": ">=0.38.0",
@@ -61,6 +62,7 @@
6162
"@tanstack/react-query": "^5.90.21",
6263
"@tanstack/react-virtual": "^3.12.3",
6364
"clsx": "^2.1.1",
65+
"dompurify": "^3.3.3",
6466
"fast-deep-equal": "^3.1.3",
6567
"jotai": ">=2.0.0",
6668
"jotai-family": "^1.0.1",
@@ -70,6 +72,7 @@
7072
"json5": "^2.2.3",
7173
"jsonrepair": "^3.13.2",
7274
"lucide-react": "^0.479.0",
75+
"marked": "^17.0.4",
7376
"prismjs": ">=1.30.0",
7477
"react-resizable": "^3.0.5"
7578
},
@@ -78,6 +81,7 @@
7881
"@lexical/code": "^0.40.0",
7982
"@lexical/code-shiki": "^0.40.0",
8083
"@lexical/hashtag": "^0.40.0",
84+
"@lexical/html": "^0.40.0",
8185
"@lexical/link": "^0.40.0",
8286
"@lexical/list": "^0.40.0",
8387
"@lexical/mark": "^0.40.0",

web/packages/agenta-ui/src/Editor/Editor.tsx

Lines changed: 113 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,13 @@ import {
1111
} from "react"
1212

1313
import {createLogger} from "@agenta/shared/utils"
14-
import {$isCodeNode} from "@lexical/code"
14+
import {$createCodeNode, $isCodeNode} from "@lexical/code"
1515
import {$convertFromMarkdownString, TRANSFORMERS} from "@lexical/markdown"
1616
import {useLexicalComposerContext} from "@lexical/react/LexicalComposerContext"
1717
import {LexicalExtensionComposer} from "@lexical/react/LexicalExtensionComposer"
1818
import {mergeRegister} from "@lexical/utils"
1919
import clsx from "clsx"
20+
import {useSetAtom} from "jotai"
2021
import yaml from "js-yaml"
2122
import {
2223
COMMAND_PRIORITY_HIGH,
@@ -29,7 +30,7 @@ import {
2930
defineExtension,
3031
type InitialEditorStateType,
3132
} from "lexical"
32-
import {$getRoot} from "lexical"
33+
import {$createTextNode, $getRoot} from "lexical"
3334
import {v4 as uuidv4} from "uuid"
3435

3536
import FormView from "./form/FormView"
@@ -56,11 +57,18 @@ import {$getEditorCodeAsString} from "./plugins/code/utils/editorCodeUtils"
5657
import {$getLineCount} from "./plugins/code/utils/segmentUtils"
5758
import {$convertToMarkdownStringCustom} from "./plugins/markdown/assets/transformers"
5859
import {ON_CHANGE_COMMAND} from "./plugins/markdown/commands"
60+
import {stripBackslashEscapes} from "./plugins/markdown/utils/textCleanup"
5961
import {
6062
TokenBehaviorExtension,
6163
TokenBehaviorCoreExtension,
6264
} from "./plugins/token/extensions/tokenBehavior"
65+
import {markdownViewAtom} from "./state/assets/atoms"
6366
import type {EditorProps} from "./types"
67+
import {
68+
isEditorLargeDocument,
69+
isLargeRichTextDocument,
70+
setEditorLargeDocumentFlag,
71+
} from "./utils/largeDocument"
6472

6573
export const ON_HYDRATE_FROM_REMOTE_CONTENT = createCommand<{
6674
hydrateWithRemoteContent: string
@@ -92,6 +100,17 @@ function $getNativeCodeAsString(): string {
92100
return root.getTextContent()
93101
}
94102

103+
function $getRichTextAsMarkdownString(): string {
104+
const root = $getRoot()
105+
const firstChild = root.getFirstChild()
106+
107+
if ($isCodeNode(firstChild) && firstChild.getLanguage() === "markdown") {
108+
return firstChild.getTextContent()
109+
}
110+
111+
return $convertToMarkdownStringCustom(TRANSFORMERS, undefined, true)
112+
}
113+
95114
// Re-export the useLexicalComposerContext hook for easier access
96115
export {useLexicalComposerContext} from "@lexical/react/LexicalComposerContext"
97116

@@ -165,6 +184,12 @@ const EditorInner = forwardRef<HTMLDivElement, EditorProps>(
165184
})
166185

167186
const [editor] = useLexicalComposerContext()
187+
const effectiveValue = value !== undefined ? value : initialValue
188+
const isLargeRichTextDoc = useMemo(
189+
() => !codeOnly && isLargeRichTextDocument(effectiveValue || ""),
190+
[codeOnly, effectiveValue],
191+
)
192+
const setMarkdownView = useSetAtom(markdownViewAtom(id))
168193

169194
const handleUpdate = useCallback(
170195
(editorState: EditorState, _editor: LexicalEditor, tags?: ReadonlySet<string>) => {
@@ -250,56 +275,64 @@ const EditorInner = forwardRef<HTMLDivElement, EditorProps>(
250275
})
251276
}
252277
} else {
253-
const root = $getRoot()
254-
const firstChild = root.getFirstChild()
255-
let textContent: string
278+
const emitRichTextChange = () => {
279+
const textContent = $getRichTextAsMarkdownString()
280+
const tokens: unknown[] = [] // Extract tokens if needed
281+
const result = {
282+
value: "", // Omit this for now
283+
textContent: stripBackslashEscapes(textContent),
284+
tokens,
285+
}
256286

257-
if (
258-
$isCodeNode(firstChild) &&
259-
firstChild.getLanguage() === "markdown"
260-
) {
261-
textContent = firstChild.getTextContent()
262-
} else {
263-
textContent = $convertToMarkdownStringCustom(
264-
TRANSFORMERS,
265-
undefined,
266-
true,
287+
setEditorLargeDocumentFlag(
288+
_editor,
289+
isLargeRichTextDocument(result.textContent),
267290
)
291+
lastEmittedTextRef.current = result.textContent
292+
const callbackStartMs = getNow()
293+
onChange(result)
294+
const callbackMs = getNow() - callbackStartMs
295+
const totalMs = getNow() - onChangeStartMs
296+
if (
297+
(DEBUG_ENTER_ON_CHANGE_PROFILE && isEnterUpdate) ||
298+
totalMs >= SLOW_ON_CHANGE_THRESHOLD_MS
299+
) {
300+
onChangeLog("updateProfile", {
301+
editorId: id,
302+
isEnterUpdate,
303+
codeOnly: false,
304+
contentLength: result.textContent.length,
305+
callbackMs: Number(callbackMs.toFixed(2)),
306+
totalMs: Number(totalMs.toFixed(2)),
307+
})
308+
}
268309
}
269310

270-
const tokens: unknown[] = [] // Extract tokens if needed
311+
const shouldDebounceRichTextChange =
312+
isLargeRichTextDoc || isEditorLargeDocument(_editor)
271313

272-
const result = {
273-
value: "", // Omit this for now
274-
textContent: textContent.replaceAll(/\\(.)/g, "$1"),
275-
tokens,
314+
if (shouldDebounceRichTextChange) {
315+
if (onChangeDebounceRef.current != null) {
316+
clearTimeout(onChangeDebounceRef.current)
317+
}
318+
onChangeDebounceRef.current = setTimeout(() => {
319+
onChangeDebounceRef.current = null
320+
editor.getEditorState().read(() => {
321+
if (!editor.isEditable()) return
322+
emitRichTextChange()
323+
})
324+
}, LARGE_DOC_ON_CHANGE_DEBOUNCE_MS)
325+
return true
276326
}
277327

278-
lastEmittedTextRef.current = result.textContent
279-
const callbackStartMs = getNow()
280-
onChange(result)
281-
const callbackMs = getNow() - callbackStartMs
282-
const totalMs = getNow() - onChangeStartMs
283-
if (
284-
(DEBUG_ENTER_ON_CHANGE_PROFILE && isEnterUpdate) ||
285-
totalMs >= SLOW_ON_CHANGE_THRESHOLD_MS
286-
) {
287-
onChangeLog("updateProfile", {
288-
editorId: id,
289-
isEnterUpdate,
290-
codeOnly: false,
291-
contentLength: result.textContent.length,
292-
callbackMs: Number(callbackMs.toFixed(2)),
293-
totalMs: Number(totalMs.toFixed(2)),
294-
})
295-
}
328+
emitRichTextChange()
296329
}
297330
})
298331
return true
299332
},
300333
COMMAND_PRIORITY_HIGH,
301334
)
302-
}, [codeOnly, editor, onChange])
335+
}, [codeOnly, editor, id, isLargeRichTextDoc, onChange, useNativeCodeNodes])
303336

304337
const [view, setView] = useState<"code" | "form">("code")
305338
const [jsonValue, setJsonValue] = useState<Record<string, unknown>>({})
@@ -433,26 +466,48 @@ const EditorInner = forwardRef<HTMLDivElement, EditorProps>(
433466
}
434467
isInitRef.current = true
435468
if (hydrateWithRemoteContent) {
436-
$convertFromMarkdownString(
437-
hydrateWithRemoteContent,
438-
TRANSFORMERS,
439-
undefined,
440-
true,
441-
)
469+
if (!codeOnly && isLargeRichTextDocument(hydrateWithRemoteContent)) {
470+
setEditorLargeDocumentFlag(editor, true)
471+
472+
const root = $getRoot()
473+
const codeNode = $createCodeNode("markdown")
474+
codeNode.append($createTextNode(hydrateWithRemoteContent))
475+
root.clear().append(codeNode)
476+
setMarkdownView(true)
477+
} else {
478+
$convertFromMarkdownString(
479+
hydrateWithRemoteContent,
480+
TRANSFORMERS,
481+
undefined,
482+
true,
483+
)
484+
}
442485
}
443486
return false
444487
},
445488
COMMAND_PRIORITY_LOW,
446489
),
447490
)
448-
}, [editor])
491+
}, [codeOnly, editor])
449492

450493
const lastHydratedRef = useRef<string>("")
451494
const lastEmittedTextRef = useRef<string>("")
452495
const onChangeDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
453496

454-
// Use controlled value if provided, otherwise fall back to initialValue
455-
const effectiveValue = value !== undefined ? value : initialValue
497+
useEffect(
498+
() => () => {
499+
if (onChangeDebounceRef.current != null) {
500+
clearTimeout(onChangeDebounceRef.current)
501+
onChangeDebounceRef.current = null
502+
}
503+
},
504+
[],
505+
)
506+
507+
useEffect(() => {
508+
setEditorLargeDocumentFlag(editor, isLargeRichTextDoc)
509+
}, [editor, isLargeRichTextDoc])
510+
456511
const shouldDisableLineNumbersForLargeDoc = useMemo(() => {
457512
if (!codeOnly || !showLineNumbers) {
458513
return false
@@ -563,9 +618,13 @@ const EditorInner = forwardRef<HTMLDivElement, EditorProps>(
563618
style.height = dimensions.height
564619
}
565620

566-
// Virtualization needs a bounded scroll container.
567-
// If caller did not provide a fixed height, apply a large-doc default.
568-
if (codeOnly && shouldEnableLargeDocOptimizations && !hasExplicitHeight) {
621+
// Large documents benefit from a bounded scroll container so the
622+
// page does not reflow around extremely tall editor content.
623+
if (
624+
((codeOnly && shouldEnableLargeDocOptimizations) ||
625+
(!codeOnly && isLargeRichTextDoc)) &&
626+
!hasExplicitHeight
627+
) {
569628
style.maxHeight = "70vh"
570629
style.overflow = "auto"
571630
}
@@ -576,6 +635,7 @@ const EditorInner = forwardRef<HTMLDivElement, EditorProps>(
576635
dimensions?.height,
577636
dimensions?.width,
578637
hasExplicitHeight,
638+
isLargeRichTextDoc,
579639
shouldEnableLargeDocOptimizations,
580640
])
581641

@@ -617,13 +677,7 @@ const EditorInner = forwardRef<HTMLDivElement, EditorProps>(
617677
// code fences, etc.) causing false mismatches after markdown paste/conversion.
618678
let currentContent = ""
619679
editor.getEditorState().read(() => {
620-
const root = $getRoot()
621-
const firstChild = root.getFirstChild()
622-
if ($isCodeNode(firstChild) && firstChild.getLanguage() === "markdown") {
623-
currentContent = firstChild.getTextContent()
624-
} else {
625-
currentContent = $convertToMarkdownStringCustom(TRANSFORMERS, undefined, true)
626-
}
680+
currentContent = $getRichTextAsMarkdownString()
627681
})
628682
// Skip if content already matches (no change needed)
629683
if (currentContent === next) {
@@ -656,6 +710,7 @@ const EditorInner = forwardRef<HTMLDivElement, EditorProps>(
656710
showMarkdownToggleButton={showMarkdownToggleButton}
657711
singleLine={singleLine}
658712
codeOnly={codeOnly}
713+
largeDocumentMode={isLargeRichTextDoc}
659714
debug={debug}
660715
language={language}
661716
placeholder={placeholder}

web/packages/agenta-ui/src/Editor/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,11 @@ export {
6969
$convertToMarkdownStringCustom,
7070
PLAYGROUND_TRANSFORMERS,
7171
} from "./plugins/markdown/assets/transformers"
72+
export {
73+
isLargeRichTextDocument,
74+
LARGE_RICH_TEXT_CHAR_THRESHOLD,
75+
LARGE_RICH_TEXT_LINE_THRESHOLD,
76+
} from "./utils/largeDocument"
7277

7378
// Form view types
7479
export type {CustomRenderFn} from "./form/nodes/NodeTypes"

web/packages/agenta-ui/src/Editor/plugins/code/utils/loadingOverlay.ts

Lines changed: 17 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -16,27 +16,21 @@ export function showEditorLoadingOverlay(
1616
const rootElement = editor.getRootElement()
1717
if (!rootElement) return null
1818

19-
// Find a host container that represents the visible area the user sees.
20-
// Priority: modal body (stable size) > editor wrapper > scrollable ancestor.
19+
// Find a host container that represents the visible editor surface.
20+
// Prefer the editor's own shell so loading states stay scoped to the
21+
// active input instead of blanketing the surrounding panel/section.
2122
let host: HTMLElement | null = null
2223

23-
// 1. Prefer Ant Design modal body — it has a known, stable size even
24-
// before the paste inflates the editor content.
25-
host = rootElement.closest(".ant-modal-body") as HTMLElement | null
24+
// 1. Shared editor shell — includes header, controls, and content area.
25+
host = rootElement.closest(".agenta-shared-editor") as HTMLElement | null
2626

27-
// 2. Editor wrapper — always present, already has position:relative,
28-
// and scoping the overlay to the editor is better UX than covering
29-
// the entire page when the editor is inside a scrollable ancestor.
30-
// However, after a bulk-clear the wrapper may be tiny (single empty line),
31-
// so fall through to a scrollable ancestor when the wrapper is too short.
27+
// 2. Editor wrapper inside the shell.
3228
if (!host) {
33-
const wrapper = rootElement.closest(".agenta-editor-wrapper") as HTMLElement | null
34-
if (wrapper && wrapper.offsetHeight >= 100) {
35-
host = wrapper
36-
}
29+
host = rootElement.closest(".agenta-editor-wrapper") as HTMLElement | null
3730
}
3831

39-
// 3. Try the nearest ancestor with overflow-y scrolling
32+
// 3. Prefer the nearest scrollable editor ancestor before escalating
33+
// to larger containers.
4034
if (!host) {
4135
let current = rootElement.parentElement
4236
while (current) {
@@ -49,11 +43,15 @@ export function showEditorLoadingOverlay(
4943
}
5044
}
5145

52-
// 4. Final fallback to editor wrapper or parent element
46+
// 4. If the editor lives inside a modal and no local shell exists,
47+
// fall back to the modal body.
48+
if (!host) {
49+
host = rootElement.closest(".ant-modal-body") as HTMLElement | null
50+
}
51+
52+
// 5. Final fallback to the immediate parent.
5353
if (!host) {
54-
host =
55-
(rootElement.closest(".agenta-editor-wrapper") as HTMLElement) ||
56-
rootElement.parentElement
54+
host = rootElement.parentElement
5755
}
5856

5957
if (!host) return null

0 commit comments

Comments
 (0)