From 0c4b70b13d9cd92a1e11121aa8311bd697f04735 Mon Sep 17 00:00:00 2001 From: jp-agenta <174311389+jp-agenta@users.noreply.github.com> Date: Thu, 12 Mar 2026 16:19:46 +0000 Subject: [PATCH 1/8] v0.94.3 --- api/pyproject.toml | 2 +- sdk/pyproject.toml | 2 +- services/pyproject.toml | 2 +- web/ee/package.json | 2 +- web/oss/package.json | 2 +- web/package.json | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/api/pyproject.toml b/api/pyproject.toml index 4cde40d384..b61cadadba 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "api" -version = "0.94.2" +version = "0.94.3" description = "Agenta API" authors = [ { name = "Mahmoud Mabrouk", email = "mahmoud@agenta.ai" }, diff --git a/sdk/pyproject.toml b/sdk/pyproject.toml index 47c1c968a0..7d03dc7a24 100644 --- a/sdk/pyproject.toml +++ b/sdk/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "agenta" -version = "0.94.2" +version = "0.94.3" description = "The SDK for agenta is an open-source LLMOps platform." readme = "README.md" authors = [ diff --git a/services/pyproject.toml b/services/pyproject.toml index 9cfa1fcf04..7bf8117e63 100644 --- a/services/pyproject.toml +++ b/services/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "services" -version = "0.94.2" +version = "0.94.3" description = "Agenta Services (Chat & Completion)" authors = [ "Mahmoud Mabrouk ", diff --git a/web/ee/package.json b/web/ee/package.json index 432ed3d278..6cc13be2f7 100644 --- a/web/ee/package.json +++ b/web/ee/package.json @@ -1,6 +1,6 @@ { "name": "@agenta/ee", - "version": "0.94.2", + "version": "0.94.3", "private": true, "engines": { "node": ">=18" diff --git a/web/oss/package.json b/web/oss/package.json index 8b7d3a28bf..7134d80013 100644 --- a/web/oss/package.json +++ b/web/oss/package.json @@ -1,6 +1,6 @@ { "name": "@agenta/oss", - "version": "0.94.2", + "version": "0.94.3", "private": true, "engines": { "node": ">=18" diff --git a/web/package.json b/web/package.json index 8a52f9af0d..03afe5e306 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "agenta-web", - "version": "0.94.2", + "version": "0.94.3", "workspaces": [ "ee", "oss", From 0379f247888e09a68b66839ccb92ce226b7b35a4 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Thu, 12 Mar 2026 18:38:24 +0100 Subject: [PATCH 2/8] refactor(editor): fix diff highlight infinite loop and React Strict Mode issues - Add `computeOnMountOnly` prop to EntityCommitContent diff viewer - Set TextNode mode to "token" for all diff content nodes to prevent normalization - Track diff-built state per editor using WeakSet to survive Strict Mode double-mounts - Register no-op transforms for TextNode and CodeLineNode to absorb dirty nodes - Defer INITIAL_CONTENT_COMMAND dispatch to queueMicrotask to avoid nested updates - Mark diff as built before appending to root --- .../commit/components/EntityCommitContent.tsx | 1 + .../plugins/code/extensions/diffHighlight.tsx | 56 ++++++++++++++++--- 2 files changed, 50 insertions(+), 7 deletions(-) diff --git a/web/packages/agenta-entity-ui/src/modals/commit/components/EntityCommitContent.tsx b/web/packages/agenta-entity-ui/src/modals/commit/components/EntityCommitContent.tsx index b9e3cd79d4..6a24d0e697 100644 --- a/web/packages/agenta-entity-ui/src/modals/commit/components/EntityCommitContent.tsx +++ b/web/packages/agenta-entity-ui/src/modals/commit/components/EntityCommitContent.tsx @@ -285,6 +285,7 @@ export function EntityCommitContent({ className="h-full" showErrors enableFolding + computeOnMountOnly /> diff --git a/web/packages/agenta-ui/src/Editor/plugins/code/extensions/diffHighlight.tsx b/web/packages/agenta-ui/src/Editor/plugins/code/extensions/diffHighlight.tsx index 28d9d72c55..e65b0e6d2e 100644 --- a/web/packages/agenta-ui/src/Editor/plugins/code/extensions/diffHighlight.tsx +++ b/web/packages/agenta-ui/src/Editor/plugins/code/extensions/diffHighlight.tsx @@ -36,6 +36,7 @@ import { $hasUpdateTag, COMMAND_PRIORITY_CRITICAL, defineExtension, + TextNode, type LexicalEditor, } from "lexical" @@ -582,14 +583,14 @@ function $setLineContentWithInlineDiff( const truncatedSegs = $truncateDiffLineToSegments(fullContent) if (truncatedSegs) { truncatedSegs.forEach((seg) => { - const node = $createTextNode(seg.text) + const node = $createTextNode(seg.text).setMode("token") if (seg.segmentType === "truncated") { node.setStyle(DIFF_SEGMENT_STYLES.truncated) } lineNode.append(node) }) } else { - lineNode.append($createTextNode(fullContent)) + lineNode.append($createTextNode(fullContent).setMode("token")) } return } @@ -597,7 +598,7 @@ function $setLineContentWithInlineDiff( const truncatedSegments = $truncateInlineDiffSegments(segments) truncatedSegments.forEach((segment) => { - const node = $createTextNode(segment.text) + const node = $createTextNode(segment.text).setMode("token") if (segment.segmentType === "truncated") { node.setStyle(DIFF_SEGMENT_STYLES.truncated) @@ -644,6 +645,12 @@ function isDiffContent(blockText: string): boolean { return isDiff } +// ─── Diff-built tracking ───────────────────────────────────────────────────── +// Tracks which editors have completed their initial diff DOM build. +// Uses a WeakMap keyed on editor instance so the flag survives React Strict Mode +// double-mounts (where closures are discarded and recreated). +const diffBuiltEditors = new WeakSet() + // ─── Behavior registration ─────────────────────────────────────────────────── export function registerDiffHighlightBehavior( @@ -657,6 +664,9 @@ export function registerDiffHighlightBehavior( showFoldedLineCount = true, }: DiffHighlightPluginProps = {}, ): () => void { + // Reset the diff-built flag for this editor on (re-)registration. + diffBuiltEditors.delete(editor) + const removeCommandListener = editor.registerCommand( INITIAL_CONTENT_COMMAND, (payload: InitialContentPayload) => { @@ -664,6 +674,8 @@ export function registerDiffHighlightBehavior( payload.preventDefault() editor.update(() => { $addUpdateTag("diff-initial-content") + $addUpdateTag("agenta:initial-content") + try { let originalData: unknown, modifiedData: unknown @@ -772,13 +784,18 @@ export function registerDiffHighlightBehavior( parsed.diffType, ) } else { - lineNode.append($createTextNode(lineContent)) + lineNode.append($createTextNode(lineContent).setMode("token")) } lineNodes.push(lineNode) } }) + // Mark diff as built BEFORE appending to root — + // appending triggers CodeBlockNode transforms synchronously, + // so the flag must be set first to prevent the infinite loop. + diffBuiltEditors.add(editor) + // Wrap in segments for efficient virtualization $wrapLinesInSegments(lineNodes).forEach((node) => { codeBlock.append(node) @@ -786,7 +803,7 @@ export function registerDiffHighlightBehavior( root.append(codeBlock) } catch (parseError) { - // Silently fail - the editor will show empty content + console.error("DiffHighlight: error building diff content:", parseError) } }) @@ -802,6 +819,13 @@ export function registerDiffHighlightBehavior( COMMAND_PRIORITY_CRITICAL, ) + // No-op transforms for TextNode and CodeLineNode. + // These absorb dirty nodes during Lexical's internal $applyAllTransforms loop, + // preventing $normalizeTextNode from creating an infinite cycle when diff + // content contains backtick characters. + const removeTextTransform = editor.registerNodeTransform(TextNode, () => {}) + const removeLineTransform = editor.registerNodeTransform(CodeLineNode, () => {}) + const removeTransform = editor.registerNodeTransform( CodeBlockNode, (codeBlockNode: CodeBlockNode) => { @@ -817,6 +841,10 @@ export function registerDiffHighlightBehavior( return } + if (diffBuiltEditors.has(editor)) { + return + } + const codeLines = $getAllCodeLines(codeBlockNode) // Quick check: if lines already have diff properties set (from initial creation), @@ -928,7 +956,13 @@ export function registerDiffHighlightBehavior( // Check if root element is already available const existingRoot = editor.getRootElement() if (existingRoot) { - editor.dispatchCommand(INITIAL_CONTENT_COMMAND, payload) + // Defer dispatch to avoid nesting inside another Lexical update cycle + // (e.g. history-merge from Strict Mode double-mount). Nested updates + // cause the inner editor.update() to be batched, leading to two + // reconciliation passes that can hang Lexical's reconciler. + queueMicrotask(() => { + editor.dispatchCommand(INITIAL_CONTENT_COMMAND, payload) + }) } else { // Defer dispatch until the editor has a root DOM element. // The extension's register callback runs during editor creation @@ -939,15 +973,23 @@ export function registerDiffHighlightBehavior( removeRootListener = editor.registerRootListener((rootElement) => { if (rootElement && !dispatched) { dispatched = true - editor.dispatchCommand(INITIAL_CONTENT_COMMAND, payload) + // Defer to next microtask so the dispatch runs outside + // any active Lexical update cycle, ensuring editor.update() + // in the command handler executes as a top-level update. + queueMicrotask(() => { + editor.dispatchCommand(INITIAL_CONTENT_COMMAND, payload) + }) } }) } } return () => { + diffBuiltEditors.delete(editor) removeCommandListener() removeTransform() + removeTextTransform() + removeLineTransform() removeRootListener?.() } } From ba53cb9432a9cf6e21a295ff255d8744ca7fbd02 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Thu, 12 Mar 2026 22:19:09 +0100 Subject: [PATCH 3/8] fix(editor): prevent commit modal freeze on long prompt diffs Skip inline diff computation for lines exceeding the truncation threshold (200 chars). Long lines (e.g. serialized prompts ~4700 chars) produced massive Lexical TextNodes that froze the DOM reconciler. Also disable HistoryPlugin for read-only diff views. Co-Authored-By: Claude Opus 4.6 --- .../plugins/code/extensions/diffHighlight.tsx | 20 +++++++++++++++++-- .../agenta-ui/src/Editor/plugins/index.tsx | 2 +- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/web/packages/agenta-ui/src/Editor/plugins/code/extensions/diffHighlight.tsx b/web/packages/agenta-ui/src/Editor/plugins/code/extensions/diffHighlight.tsx index e65b0e6d2e..ecaf933876 100644 --- a/web/packages/agenta-ui/src/Editor/plugins/code/extensions/diffHighlight.tsx +++ b/web/packages/agenta-ui/src/Editor/plugins/code/extensions/diffHighlight.tsx @@ -713,8 +713,6 @@ export function registerDiffHighlightBehavior( const parsedLines = rawLines.map((line) => parseDiffLine(line)) // Pre-compute inline diff pairs for removed→added sequences. - // Stores unified segments (single-line view) keyed by the - // removed line index, and marks the added line index for skipping. const unifiedByRemovedIndex = new Map< number, { @@ -734,6 +732,16 @@ export function registerDiffHighlightBehavior( typeof current.content === "string" && typeof next.content === "string" ) { + // Skip inline diff for lines that exceed the truncation + // threshold — they'll be truncated to plain text anyway, + // and creating Lexical TextNodes with thousands of chars + // freezes the DOM reconciler. + if ( + current.content.length > DIFF_LINE_TRUNCATE_THRESHOLD || + next.content.length > DIFF_LINE_TRUNCATE_THRESHOLD + ) { + continue + } const inlinePair = buildInlineDiffPair( current.content, next.content, @@ -894,6 +902,14 @@ export function registerDiffHighlightBehavior( if (!isReplacementPair) continue + // Skip inline diff for lines exceeding the truncation threshold + if ( + current.content.length > DIFF_LINE_TRUNCATE_THRESHOLD || + next.content.length > DIFF_LINE_TRUNCATE_THRESHOLD + ) { + continue + } + const inlinePair = buildInlineDiffPair(current.content, next.content) if (!inlinePair) continue diff --git a/web/packages/agenta-ui/src/Editor/plugins/index.tsx b/web/packages/agenta-ui/src/Editor/plugins/index.tsx index 85fdad2d5a..d03c42c7df 100644 --- a/web/packages/agenta-ui/src/Editor/plugins/index.tsx +++ b/web/packages/agenta-ui/src/Editor/plugins/index.tsx @@ -142,7 +142,7 @@ const EditorPlugins = ({ } ErrorBoundary={LexicalErrorBoundary} /> - + {!isDiffView && } {autoFocus ? : null} {hasOnChange && } {showToolbar && !singleLine && !codeOnly && } From 32742bd19acdee02c9409370a2083f54dd4c1e2b Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Thu, 12 Mar 2026 23:47:02 +0100 Subject: [PATCH 4/8] refactor(editor): move diff building from command handler to direct registration Remove INITIAL_CONTENT_COMMAND dependency from diffHighlight extension. Build diff content synchronously during registerDiffHighlightBehavior() using { discrete: true } to force immediate commit. Truncate large changed segments (>600 chars) to prevent DOM freeze. Remove root listener and command payload fields (originalContent, modifiedContent, isDiffRequest). --- .../Editor/commands/InitialContentCommand.ts | 6 - .../plugins/code/extensions/diffHighlight.tsx | 369 ++++++++---------- .../agenta-ui/src/Editor/plugins/index.tsx | 3 - 3 files changed, 161 insertions(+), 217 deletions(-) diff --git a/web/packages/agenta-ui/src/Editor/commands/InitialContentCommand.ts b/web/packages/agenta-ui/src/Editor/commands/InitialContentCommand.ts index 8798908fcc..5b3adffac5 100644 --- a/web/packages/agenta-ui/src/Editor/commands/InitialContentCommand.ts +++ b/web/packages/agenta-ui/src/Editor/commands/InitialContentCommand.ts @@ -18,12 +18,6 @@ export interface InitialContentPayload { preventDefault: () => void /** Whether default handling has been prevented */ isDefaultPrevented: () => boolean - /** Optional: Original content for diff computation */ - originalContent?: string - /** Optional: Modified content for diff computation */ - modifiedContent?: string - /** Optional: Flag to indicate this is a diff request */ - isDiffRequest?: boolean /** Optional: Force update even if editor has focus (for undo/redo) */ forceUpdate?: boolean } diff --git a/web/packages/agenta-ui/src/Editor/plugins/code/extensions/diffHighlight.tsx b/web/packages/agenta-ui/src/Editor/plugins/code/extensions/diffHighlight.tsx index ecaf933876..8241972172 100644 --- a/web/packages/agenta-ui/src/Editor/plugins/code/extensions/diffHighlight.tsx +++ b/web/packages/agenta-ui/src/Editor/plugins/code/extensions/diffHighlight.tsx @@ -16,7 +16,7 @@ * - Long line truncation with character count indicators * * ## Architecture: - * - `registerDiffHighlightBehavior()` — registers the INITIAL_CONTENT_COMMAND handler + * - `registerDiffHighlightBehavior()` — registers diff building and transforms * and CodeBlockNode transform for diff annotation * - `DiffHighlightExtension` — Lexical extension wrapper (used by the extension system) * - `DiffHighlightPlugin` — Legacy React component wrapper (backward compatibility) @@ -34,16 +34,11 @@ import { $createTextNode, $getRoot, $hasUpdateTag, - COMMAND_PRIORITY_CRITICAL, defineExtension, TextNode, type LexicalEditor, } from "lexical" -import { - INITIAL_CONTENT_COMMAND, - InitialContentPayload, -} from "../../../commands/InitialContentCommand" import {computeDiff} from "../../../utils/diffUtils" import {$createCodeBlockNode} from "../nodes/CodeBlockNode" import {CodeBlockNode} from "../nodes/CodeBlockNode" @@ -516,12 +511,35 @@ function $truncateInlineDiffSegments(segments: InlineDiffSegment[]): InlineDiffS for (let i = 0; i < segments.length; i++) { const segment = segments[i] - // Never truncate changed segments or short segments - if (segment.changed || segment.text.length <= DIFF_CONTEXT_CHARS * 2) { + // Short segments never need truncation + if (segment.text.length <= DIFF_CONTEXT_CHARS * 2) { result.push(segment) continue } + // Truncate large changed segments — keep head + tail for context + if (segment.changed) { + const maxChanged = DIFF_CONTEXT_CHARS * 3 + if (segment.text.length > maxChanged) { + const keepEach = DIFF_CONTEXT_CHARS + const hiddenCount = segment.text.length - keepEach * 2 + result.push({ + text: segment.text.slice(0, keepEach), + changed: true, + segmentType: segment.segmentType, + }) + result.push($truncationSegment(hiddenCount)) + result.push({ + text: segment.text.slice(-keepEach), + changed: true, + segmentType: segment.segmentType, + }) + } else { + result.push(segment) + } + continue + } + // This is a long unchanged segment — split into content + truncation indicator const isFirst = i === 0 const isLast = i === segments.length - 1 @@ -667,165 +685,154 @@ export function registerDiffHighlightBehavior( // Reset the diff-built flag for this editor on (re-)registration. diffBuiltEditors.delete(editor) - const removeCommandListener = editor.registerCommand( - INITIAL_CONTENT_COMMAND, - (payload: InitialContentPayload) => { - if (payload.isDiffRequest && payload.originalContent && payload.modifiedContent) { - payload.preventDefault() - editor.update(() => { - $addUpdateTag("diff-initial-content") - $addUpdateTag("agenta:initial-content") - - try { - let originalData: unknown, modifiedData: unknown - - if (payload.language === "yaml") { - originalData = yaml.load(payload.originalContent!) - modifiedData = yaml.load(payload.modifiedContent!) - } else { - originalData = JSON5.parse(payload.originalContent!) - modifiedData = JSON5.parse(payload.modifiedContent!) - } + // Build the diff content tree inside a discrete editor.update(). + // Using { discrete: true } forces synchronous commit — bypassing + // Lexical's microtask-based update batching that would otherwise + // defer DOM reconciliation and freeze the browser. + const buildDiffContent = () => { + editor.update( + () => { + $addUpdateTag("diff-initial-content") + $addUpdateTag("agenta:initial-content") + + try { + let originalData: unknown, modifiedData: unknown + + if (language === "yaml") { + originalData = yaml.load(originalContent!) + modifiedData = yaml.load(modifiedContent!) + } else { + originalData = JSON5.parse(originalContent!) + modifiedData = JSON5.parse(modifiedContent!) + } - const diffContent = computeDiff(originalData, modifiedData, { - language: payload.language, - enableFolding, - foldThreshold, - showFoldedLineCount, - }) + const diffContent = computeDiff(originalData, modifiedData, { + language, + enableFolding, + foldThreshold, + showFoldedLineCount, + }) - const hasChanges = - diffContent.includes("|added|") || diffContent.includes("|removed|") - - if (!hasChanges && diffContent.trim()) { - const root = $getRoot() - root.clear() - return - } + const hasChanges = + diffContent.includes("|added|") || diffContent.includes("|removed|") + if (!hasChanges && diffContent.trim()) { const root = $getRoot() root.clear() + return + } - const codeBlock = $createCodeBlockNode(payload.language) - const rawLines = diffContent.split("\n") + const root = $getRoot() + root.clear() - // Pre-parse all lines to extract diff metadata - const parsedLines = rawLines.map((line) => parseDiffLine(line)) + const codeBlock = $createCodeBlockNode(language) + const rawLines = diffContent.split("\n") - // Pre-compute inline diff pairs for removed→added sequences. - const unifiedByRemovedIndex = new Map< - number, - { - unified: InlineDiffSegment[] - addedLineNumber?: number - } - >() - const skipIndices = new Set() - - for (let i = 0; i < parsedLines.length - 1; i++) { - const current = parsedLines[i] - const next = parsedLines[i + 1] - if (!current || !next) continue - if ( - current.diffType === "removed" && - next.diffType === "added" && - typeof current.content === "string" && - typeof next.content === "string" - ) { - // Skip inline diff for lines that exceed the truncation - // threshold — they'll be truncated to plain text anyway, - // and creating Lexical TextNodes with thousands of chars - // freezes the DOM reconciler. - if ( - current.content.length > DIFF_LINE_TRUNCATE_THRESHOLD || - next.content.length > DIFF_LINE_TRUNCATE_THRESHOLD - ) { - continue - } - const inlinePair = buildInlineDiffPair( - current.content, - next.content, - ) - if (inlinePair && inlinePair.unified.length > 0) { - unifiedByRemovedIndex.set(i, { - unified: inlinePair.unified, - addedLineNumber: next.newLineNumber, - }) - skipIndices.add(i + 1) // skip the added line - } + // Pre-parse all lines to extract diff metadata + const parsedLines = rawLines.map((line) => parseDiffLine(line)) + + // Pre-compute inline diff pairs for removed→added sequences. + const unifiedByRemovedIndex = new Map< + number, + { + unified: InlineDiffSegment[] + addedLineNumber?: number + } + >() + const skipIndices = new Set() + + for (let i = 0; i < parsedLines.length - 1; i++) { + const current = parsedLines[i] + const next = parsedLines[i + 1] + if (!current || !next) continue + if ( + current.diffType === "removed" && + next.diffType === "added" && + typeof current.content === "string" && + typeof next.content === "string" + ) { + const inlinePair = buildInlineDiffPair(current.content, next.content) + if (inlinePair && inlinePair.unified.length > 0) { + unifiedByRemovedIndex.set(i, { + unified: inlinePair.unified, + addedLineNumber: next.newLineNumber, + }) + skipIndices.add(i + 1) // skip the added line } } + } - // Create line nodes with all diff properties set upfront - // to avoid the node transform cascade - const lineNodes: CodeLineNode[] = [] - rawLines.forEach((lineContent, index) => { - if (lineContent.trim() || index < rawLines.length - 1) { - // Skip added lines that have been merged into a unified modified line - if (skipIndices.has(index)) return - - const parsed = parsedLines[index] - const lineNode = $createCodeLineNode() - - // Check if this removed line should become a unified modified line - const unifiedEntry = unifiedByRemovedIndex.get(index) - - if (unifiedEntry && parsed) { - // Create a single "modified" line with interleaved segments - lineNode.setDiffType("modified") - lineNode.setOldLineNumber(parsed.oldLineNumber) - lineNode.setNewLineNumber(unifiedEntry.addedLineNumber) - $setLineContentWithInlineDiff( - lineNode, - parsed.content, - "modified", - unifiedEntry.unified, - ) - } else if (parsed) { - // Regular diff line (context, standalone removed/added, etc.) - lineNode.setDiffType(parsed.diffType) - lineNode.setOldLineNumber(parsed.oldLineNumber) - lineNode.setNewLineNumber(parsed.newLineNumber) - $setLineContentWithInlineDiff( - lineNode, - parsed.content, - parsed.diffType, - ) - } else { - lineNode.append($createTextNode(lineContent).setMode("token")) - } - - lineNodes.push(lineNode) + // Create line nodes with all diff properties set upfront + // to avoid the node transform cascade + const lineNodes: CodeLineNode[] = [] + rawLines.forEach((lineContent, index) => { + if (lineContent.trim() || index < rawLines.length - 1) { + // Skip added lines that have been merged into a unified modified line + if (skipIndices.has(index)) return + + const parsed = parsedLines[index] + const lineNode = $createCodeLineNode() + + // Check if this removed line should become a unified modified line + const unifiedEntry = unifiedByRemovedIndex.get(index) + + if (unifiedEntry && parsed) { + // Create a single "modified" line with interleaved segments + lineNode.setDiffType("modified") + lineNode.setOldLineNumber(parsed.oldLineNumber) + lineNode.setNewLineNumber(unifiedEntry.addedLineNumber) + $setLineContentWithInlineDiff( + lineNode, + parsed.content, + "modified", + unifiedEntry.unified, + ) + } else if (parsed) { + // Regular diff line (context, standalone removed/added, etc.) + lineNode.setDiffType(parsed.diffType) + lineNode.setOldLineNumber(parsed.oldLineNumber) + lineNode.setNewLineNumber(parsed.newLineNumber) + $setLineContentWithInlineDiff( + lineNode, + parsed.content, + parsed.diffType, + ) + } else { + lineNode.append($createTextNode(lineContent).setMode("token")) } - }) - // Mark diff as built BEFORE appending to root — - // appending triggers CodeBlockNode transforms synchronously, - // so the flag must be set first to prevent the infinite loop. - diffBuiltEditors.add(editor) + lineNodes.push(lineNode) + } + }) - // Wrap in segments for efficient virtualization - $wrapLinesInSegments(lineNodes).forEach((node) => { - codeBlock.append(node) - }) + // Mark diff as built BEFORE appending to root — + // appending triggers CodeBlockNode transforms synchronously, + // so the flag must be set first to prevent the infinite loop. + diffBuiltEditors.add(editor) - root.append(codeBlock) - } catch (parseError) { - console.error("DiffHighlight: error building diff content:", parseError) - } - }) + // Wrap in segments for efficient virtualization + $wrapLinesInSegments(lineNodes).forEach((node) => { + codeBlock.append(node) + }) - return true - } - // In diff mode, block ALL initial-content commands to prevent - // other handlers from overwriting the diff-styled content. - if (originalContent && modifiedContent) { - return true - } - return false - }, - COMMAND_PRIORITY_CRITICAL, - ) + root.append(codeBlock) + } catch (parseError) { + console.error("DiffHighlight: error building diff content:", parseError) + } + }, + {discrete: true}, + ) + } + + // Build diff content immediately during extension registration. + // register() is called from LexicalBuilder.buildEditor() inside a + // useMemo — outside any Lexical update cycle — so { discrete: true } + // commits synchronously. The root DOM element doesn't exist yet, but + // Lexical builds the internal node tree regardless; DOM reconciliation + // happens automatically when the root element is attached later. + if (originalContent && modifiedContent) { + buildDiffContent() + } // No-op transforms for TextNode and CodeLineNode. // These absorb dirty nodes during Lexical's internal $applyAllTransforms loop, @@ -842,9 +849,9 @@ export function registerDiffHighlightBehavior( } // Skip re-processing during the diff initial content update. - // The INITIAL_CONTENT_COMMAND handler already set diff types - // on all line nodes; re-parsing here would strip them because - // the content is already cleaned (no pipe-delimited format). + // buildDiffContent() already set diff types on all line nodes; + // re-parsing here would strip them because the content is + // already cleaned (no pipe-delimited format). if ($hasUpdateTag("diff-initial-content")) { return } @@ -902,14 +909,6 @@ export function registerDiffHighlightBehavior( if (!isReplacementPair) continue - // Skip inline diff for lines exceeding the truncation threshold - if ( - current.content.length > DIFF_LINE_TRUNCATE_THRESHOLD || - next.content.length > DIFF_LINE_TRUNCATE_THRESHOLD - ) { - continue - } - const inlinePair = buildInlineDiffPair(current.content, next.content) if (!inlinePair) continue @@ -956,57 +955,11 @@ export function registerDiffHighlightBehavior( }, ) - let removeRootListener: (() => void) | null = null - - if (originalContent && modifiedContent) { - const payload: InitialContentPayload = { - content: "test", - language, - preventDefault: () => {}, - isDefaultPrevented: () => false, - originalContent, - modifiedContent, - isDiffRequest: true, - } - - // Check if root element is already available - const existingRoot = editor.getRootElement() - if (existingRoot) { - // Defer dispatch to avoid nesting inside another Lexical update cycle - // (e.g. history-merge from Strict Mode double-mount). Nested updates - // cause the inner editor.update() to be batched, leading to two - // reconciliation passes that can hang Lexical's reconciler. - queueMicrotask(() => { - editor.dispatchCommand(INITIAL_CONTENT_COMMAND, payload) - }) - } else { - // Defer dispatch until the editor has a root DOM element. - // The extension's register callback runs during editor creation - // (inside useMemo), before ContentEditable mounts. Without a root - // element, Lexical processes state changes but skips DOM reconciliation. - // By waiting for the root, we ensure createDOM() is called on diff nodes. - let dispatched = false - removeRootListener = editor.registerRootListener((rootElement) => { - if (rootElement && !dispatched) { - dispatched = true - // Defer to next microtask so the dispatch runs outside - // any active Lexical update cycle, ensuring editor.update() - // in the command handler executes as a top-level update. - queueMicrotask(() => { - editor.dispatchCommand(INITIAL_CONTENT_COMMAND, payload) - }) - } - }) - } - } - return () => { diffBuiltEditors.delete(editor) - removeCommandListener() removeTransform() removeTextTransform() removeLineTransform() - removeRootListener?.() } } diff --git a/web/packages/agenta-ui/src/Editor/plugins/index.tsx b/web/packages/agenta-ui/src/Editor/plugins/index.tsx index d03c42c7df..3fef2347b4 100644 --- a/web/packages/agenta-ui/src/Editor/plugins/index.tsx +++ b/web/packages/agenta-ui/src/Editor/plugins/index.tsx @@ -65,9 +65,6 @@ const DebugPlugin = lazy(importDebugPlugin) const SingleLinePlugin = lazy(importSingleLinePlugin) const CodeEditorPlugin = lazy(importCodeEditorPlugin) const NativeCodeOnlyPlugin = lazy(importNativeCodeOnlyPlugin) -// const TokenPlugin = lazy(importTokenPlugin) -// const AutoCloseTokenBracesPlugin = lazy(importAutoCloseTokenBracesPlugin) -// const TokenTypeaheadPlugin = lazy(importTokenTypeaheadPlugin) const EditorPlugins = ({ id, From 5e7eb7e404020d1582463cac0e60349ac26e4646 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Fri, 13 Mar 2026 10:35:01 +0100 Subject: [PATCH 5/8] refactor(editor): defer diff view mounting and simplify truncation logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defer DiffView rendering until after first paint using requestAnimationFrame to prevent Lexical editor creation from blocking the commit modal shell. Remove word-level inline diff computation—keep only character-level prefix/suffix matching for mostly-similar lines. Simplify truncation to in-place segment shortening instead of adding truncation indicator nodes, avoiding DOM reconciliation freeze. Increase modified line background --- .../commit/components/EntityCommitContent.tsx | 61 +- .../assets/DiffCodeBlock.module.css | 2 +- .../plugins/code/extensions/diffHighlight.tsx | 700 +++++------------- 3 files changed, 245 insertions(+), 518 deletions(-) diff --git a/web/packages/agenta-entity-ui/src/modals/commit/components/EntityCommitContent.tsx b/web/packages/agenta-entity-ui/src/modals/commit/components/EntityCommitContent.tsx index 6a24d0e697..34cbcf2bc7 100644 --- a/web/packages/agenta-entity-ui/src/modals/commit/components/EntityCommitContent.tsx +++ b/web/packages/agenta-entity-ui/src/modals/commit/components/EntityCommitContent.tsx @@ -5,7 +5,7 @@ * Supports version info, changes summary, and diff view via adapter. */ -import {lazy, Suspense} from "react" +import {lazy, Suspense, useState, useEffect} from "react" import {formatCount} from "@agenta/shared/utils" import {VersionBadge} from "@agenta/ui/components/presentational" @@ -74,6 +74,19 @@ export function EntityCommitContent({ const context = useAtomValue(commitModalContextAtom) const setMessage = useSetAtom(setCommitMessageAtom) + // Defer DiffView mounting until after the first paint so the modal + // shell and form appear immediately without being blocked by Lexical + // editor creation + DOM reconciliation. + const [diffReady, setDiffReady] = useState(false) + useEffect(() => { + if (!context?.diffData?.original || !context?.diffData?.modified) { + setDiffReady(false) + return + } + const id = requestAnimationFrame(() => setDiffReady(true)) + return () => cancelAnimationFrame(id) + }, [context?.diffData?.original, context?.diffData?.modified]) + // Build changes description from context const changesDescription: string[] = [] if (context?.changesSummary) { @@ -253,7 +266,7 @@ export function EntityCommitContent({ )} - {/* Diff view section (if diff data available) */} + {/* Diff view section — deferred until after first paint to avoid blocking the modal */} {hasDiffData && (
@@ -270,24 +283,32 @@ export function EntityCommitContent({
- - -
- } - > - - + {diffReady ? ( + + +
+ } + > + + + ) : ( +
+ +
+ )} )} diff --git a/web/packages/agenta-ui/src/Editor/plugins/code/components/assets/DiffCodeBlock.module.css b/web/packages/agenta-ui/src/Editor/plugins/code/components/assets/DiffCodeBlock.module.css index d04c7b3e38..8df76c4722 100644 --- a/web/packages/agenta-ui/src/Editor/plugins/code/components/assets/DiffCodeBlock.module.css +++ b/web/packages/agenta-ui/src/Editor/plugins/code/components/assets/DiffCodeBlock.module.css @@ -41,7 +41,7 @@ } &:global(.diff-modified) { - background-color: rgba(99, 102, 241, 0.06); + background-color: rgba(99, 102, 241, 0.12); border-left: 3px solid #6366f1; padding-left: 8px; position: relative; diff --git a/web/packages/agenta-ui/src/Editor/plugins/code/extensions/diffHighlight.tsx b/web/packages/agenta-ui/src/Editor/plugins/code/extensions/diffHighlight.tsx index 8241972172..32c7d3c2c6 100644 --- a/web/packages/agenta-ui/src/Editor/plugins/code/extensions/diffHighlight.tsx +++ b/web/packages/agenta-ui/src/Editor/plugins/code/extensions/diffHighlight.tsx @@ -12,7 +12,7 @@ * - Integration with existing syntax highlighting * - Line-by-line diff state management * - Real-time diff computation - * - Inline diff with word-level granularity + * - Inline diff with character-level prefix/suffix matching * - Long line truncation with character count indicators * * ## Architecture: @@ -51,8 +51,8 @@ import {$getAllCodeLines, $wrapLinesInSegments} from "../utils/segmentUtils" interface InlineDiffSegment { text: string changed: boolean - /** Fine-grained segment type for unified diff and truncation styling */ - segmentType?: "removed" | "added" | "truncated" + /** Fine-grained segment type for unified diff styling */ + segmentType?: "removed" | "added" } interface InlineDiffPair { @@ -174,11 +174,6 @@ function buildInlineDiffPair(removedLine: string, addedLine: string): InlineDiff // Only apply inline diff for mostly-similar lines; otherwise line-level diff is clearer. if (overlapRatio < 0.3) { - // For long strings with scattered changes, try word-level diff - // which can identify multiple separate change regions - if (removedLine.length > 100 || addedLine.length > 100) { - return buildWordLevelInlineDiff(removedLine, addedLine) - } return null } @@ -210,242 +205,10 @@ function buildInlineDiffPair(removedLine: string, addedLine: string): InlineDiff } } -// ─── Word-level inline diff ────────────────────────────────────────────────── - -/** Maximum DP cells for word-level inline diff (prevents freeze on huge strings) */ -const INLINE_DIFF_MAX_CELLS = 2_000_000 - -/** - * Tokenize a string into word/whitespace tokens for word-level diff. - * Lossless: `tokens.join('') === text`. - */ -function tokenizeForInlineDiff(text: string): string[] { - return text.match(/\S+|\s+/g) || [] -} - -/** - * Word-level inline diff for long strings with multiple scattered changes. - * Uses LCS on word tokens to find all matching/changed regions. - */ -function buildWordLevelInlineDiff(removedLine: string, addedLine: string): InlineDiffPair | null { - const removedTokens = tokenizeForInlineDiff(removedLine) - const addedTokens = tokenizeForInlineDiff(addedLine) - - const rLen = removedTokens.length - const aLen = addedTokens.length - - // Safety cap — too many tokens would freeze the browser - if (rLen * aLen > INLINE_DIFF_MAX_CELLS) return null - - // Skip matching prefix tokens - let tokenPrefix = 0 - const maxTokenPrefix = Math.min(rLen, aLen) - while ( - tokenPrefix < maxTokenPrefix && - removedTokens[tokenPrefix] === addedTokens[tokenPrefix] - ) { - tokenPrefix++ - } - - // Skip matching suffix tokens - let tokenSuffix = 0 - const maxTokenSuffix = Math.min(rLen - tokenPrefix, aLen - tokenPrefix) - while ( - tokenSuffix < maxTokenSuffix && - removedTokens[rLen - 1 - tokenSuffix] === addedTokens[aLen - 1 - tokenSuffix] - ) { - tokenSuffix++ - } - - const rMiddle = removedTokens.slice(tokenPrefix, rLen - tokenSuffix) - const aMiddle = addedTokens.slice(tokenPrefix, aLen - tokenSuffix) - - // If no middle difference, lines are identical (shouldn't happen but guard) - if (rMiddle.length === 0 && aMiddle.length === 0) return null - - // Check DP size for the middle portion only - if (rMiddle.length * aMiddle.length > INLINE_DIFF_MAX_CELLS) return null - - // Compute LCS on middle tokens using DP - const rMLen = rMiddle.length - const aMLen = aMiddle.length - const dp = new Uint16Array((rMLen + 1) * (aMLen + 1)) - const stride = aMLen + 1 - - for (let i = 1; i <= rMLen; i++) { - for (let j = 1; j <= aMLen; j++) { - if (rMiddle[i - 1] === aMiddle[j - 1]) { - dp[i * stride + j] = dp[(i - 1) * stride + (j - 1)] + 1 - } else { - dp[i * stride + j] = Math.max(dp[(i - 1) * stride + j], dp[i * stride + (j - 1)]) - } - } - } - - // Backtrack to find matched token indices in both sequences - const rMatched = new Uint8Array(rMLen) - const aMatched = new Uint8Array(aMLen) - let ri = rMLen, - ai = aMLen - while (ri > 0 && ai > 0) { - if (rMiddle[ri - 1] === aMiddle[ai - 1]) { - rMatched[ri - 1] = 1 - aMatched[ai - 1] = 1 - ri-- - ai-- - } else if (dp[(ri - 1) * stride + ai] >= dp[ri * stride + (ai - 1)]) { - ri-- - } else { - ai-- - } - } - - // Build segments for a token array given matched flags - const buildSegments = ( - prefixTokens: string[], - middleTokens: string[], - suffixTokens: string[], - matched: Uint8Array, - ): InlineDiffSegment[] => { - const segments: InlineDiffSegment[] = [] - let currentText = "" - let currentChanged = false - - // Prefix tokens are all unchanged - const prefixText = prefixTokens.join("") - if (prefixText) { - currentText = prefixText - currentChanged = false - } - - // Middle tokens — use matched flags - for (let i = 0; i < middleTokens.length; i++) { - const isChanged = !matched[i] - if (i === 0 && !currentText) { - currentText = middleTokens[i] - currentChanged = isChanged - } else if (isChanged === currentChanged) { - currentText += middleTokens[i] - } else { - if (currentText) segments.push({text: currentText, changed: currentChanged}) - currentText = middleTokens[i] - currentChanged = isChanged - } - } - - // Suffix tokens are all unchanged - const suffixText = suffixTokens.join("") - if (suffixText) { - if (!currentChanged && currentText) { - currentText += suffixText - } else { - if (currentText) segments.push({text: currentText, changed: currentChanged}) - currentText = suffixText - currentChanged = false - } - } - - if (currentText) segments.push({text: currentText, changed: currentChanged}) - return segments - } - - const prefixTokenArr = removedTokens.slice(0, tokenPrefix) - const rSuffixTokenArr = removedTokens.slice(rLen - tokenSuffix) - const aSuffixTokenArr = addedTokens.slice(aLen - tokenSuffix) - - const removedSegments = buildSegments(prefixTokenArr, rMiddle, rSuffixTokenArr, rMatched) - const addedSegments = buildSegments(prefixTokenArr, aMiddle, aSuffixTokenArr, aMatched) - - // Check overlap ratio from the word-level diff - const rUnchangedChars = removedSegments - .filter((s) => !s.changed) - .reduce((sum, s) => sum + s.text.length, 0) - const wordOverlapRatio = rUnchangedChars / Math.max(removedLine.length, addedLine.length) - if (wordOverlapRatio < 0.3) return null - - // Build unified segments by walking both token sequences with LCS alignment - const unified = buildUnifiedFromLCS( - prefixTokenArr, - rMiddle, - aMiddle, - rSuffixTokenArr, - rMatched, - aMatched, - ) - - return {removed: removedSegments, added: addedSegments, unified} -} - -/** - * Build unified (single-line) segments from LCS alignment of two token sequences. - * Interleaves removed (strikethrough) and added (highlight) tokens between unchanged regions. - */ -function buildUnifiedFromLCS( - prefixTokens: string[], - rMiddle: string[], - aMiddle: string[], - suffixTokens: string[], - rMatched: Uint8Array, - aMatched: Uint8Array, -): InlineDiffSegment[] { - const raw: InlineDiffSegment[] = [] - - // Prefix is unchanged - const prefixText = prefixTokens.join("") - if (prefixText) raw.push({text: prefixText, changed: false}) - - // Walk both middle sequences in sync using LCS matching - let ri = 0, - ai = 0 - while (ri < rMiddle.length || ai < aMiddle.length) { - // Collect unmatched removed tokens - let removedText = "" - while (ri < rMiddle.length && !rMatched[ri]) { - removedText += rMiddle[ri] - ri++ - } - if (removedText) raw.push({text: removedText, changed: true, segmentType: "removed"}) - - // Collect unmatched added tokens - let addedText = "" - while (ai < aMiddle.length && !aMatched[ai]) { - addedText += aMiddle[ai] - ai++ - } - if (addedText) raw.push({text: addedText, changed: true, segmentType: "added"}) - - // Emit matched token (same in both) - if (ri < rMiddle.length && rMatched[ri] && ai < aMiddle.length && aMatched[ai]) { - raw.push({text: rMiddle[ri], changed: false}) - ri++ - ai++ - } - } - - // Suffix is unchanged - const suffixText = suffixTokens.join("") - if (suffixText) raw.push({text: suffixText, changed: false}) - - // Merge consecutive segments of the same type - const merged: InlineDiffSegment[] = [] - for (const seg of raw) { - const prev = merged.length > 0 ? merged[merged.length - 1] : null - if (prev && prev.changed === seg.changed && prev.segmentType === seg.segmentType) { - prev.text += seg.text - } else { - merged.push({...seg}) - } - } - - return merged -} - // ─── Truncation utilities ──────────────────────────────────────────────────── -/** Maximum line length before truncation kicks in for diff views */ +/** Maximum visible characters for truncated diff lines */ const DIFF_LINE_TRUNCATE_THRESHOLD = 200 -/** How many characters of context to keep around a changed segment */ -const DIFF_CONTEXT_CHARS = 60 /** * Format a character count for display in truncation indicators. @@ -457,137 +220,97 @@ function formatTruncatedCount(count: number): string { return `${count}` } -/** - * Build a truncation indicator segment with distinct styling. - */ -function $truncationSegment(hiddenCount: number): InlineDiffSegment { - return { - text: ` … [${formatTruncatedCount(hiddenCount)} chars] … `, - changed: false, - segmentType: "truncated", - } -} +/** Inline style constants for diff segment types */ +const DIFF_SEGMENT_STYLES = { + removed: + "background-color: rgba(220, 38, 38, 0.3); text-decoration: line-through; text-decoration-color: rgba(220, 38, 38, 0.6); border-radius: 2px; padding: 0 1px;", + added: "background-color: rgba(22, 163, 74, 0.3); border-radius: 2px; padding: 0 1px;", +} as const /** - * Truncate a long plain-text line into segments with styled truncation indicators. - * Returns null if no truncation needed (caller should use plain text). + * Truncate a plain-text line (no inline diff segments). */ -function $truncateDiffLineToSegments(content: string): InlineDiffSegment[] | null { +function $truncatePlainLine(content: string): string | null { if (content.length <= DIFF_LINE_TRUNCATE_THRESHOLD) return null - // Find the JSON string value boundary (first quote after a colon) - // so we truncate the value, not the key - const colonQuoteMatch = content.match(/^(\s*"[^"]*"\s*:\s*")/) + const colonQuoteMatch = content.match(/^(\s*"[^"]*"\s*:\s*"?)/) if (colonQuoteMatch) { const keyPrefix = colonQuoteMatch[1] const valueContent = content.slice(keyPrefix.length) const keepChars = Math.max(40, DIFF_LINE_TRUNCATE_THRESHOLD - keyPrefix.length) if (valueContent.length > keepChars) { - return [ - {text: keyPrefix + valueContent.slice(0, keepChars), changed: false}, - $truncationSegment(valueContent.length - keepChars), - ] + return ( + keyPrefix + + valueContent.slice(0, keepChars) + + ` … [${formatTruncatedCount(valueContent.length - keepChars)} chars]` + ) } } - // Fallback: truncate from the end - return [ - {text: content.slice(0, DIFF_LINE_TRUNCATE_THRESHOLD), changed: false}, - $truncationSegment(content.length - DIFF_LINE_TRUNCATE_THRESHOLD), - ] + return ( + content.slice(0, DIFF_LINE_TRUNCATE_THRESHOLD) + + ` … [${formatTruncatedCount(content.length - DIFF_LINE_TRUNCATE_THRESHOLD)} chars]` + ) } /** - * Truncate long unchanged segments in inline diff. - * Produces separate styled truncation indicator segments. + * Truncate inline diff segments in-place without changing segment count. + * Each segment's text is shortened individually so the number of TextNodes + * stays the same — adding nodes triggers a Lexical DOM reconciliation freeze. + * + * Strategy: + * - Changed segments are kept fully visible (they're the point of the diff). + * - Unchanged segments are truncated to keep total line length reasonable, + * preserving JSON key prefixes when the segment starts with one. */ -function $truncateInlineDiffSegments(segments: InlineDiffSegment[]): InlineDiffSegment[] { - // Only truncate if total text length exceeds threshold +function $truncateSegmentsInPlace(segments: InlineDiffSegment[]): InlineDiffSegment[] { const totalLength = segments.reduce((sum, s) => sum + s.text.length, 0) if (totalLength <= DIFF_LINE_TRUNCATE_THRESHOLD) return segments - const result: InlineDiffSegment[] = [] + // Budget: total chars we can show for unchanged segments + const changedLength = segments.reduce((sum, s) => (s.changed ? sum + s.text.length : sum), 0) + const unchangedBudget = Math.max(80, DIFF_LINE_TRUNCATE_THRESHOLD - changedLength) + const unchangedSegments = segments.filter((s) => !s.changed) + const unchangedTotal = unchangedSegments.reduce((sum, s) => sum + s.text.length, 0) - for (let i = 0; i < segments.length; i++) { - const segment = segments[i] + if (unchangedTotal <= unchangedBudget) return segments - // Short segments never need truncation - if (segment.text.length <= DIFF_CONTEXT_CHARS * 2) { - result.push(segment) - continue - } + return segments.map((segment) => { + if (segment.changed) return segment - // Truncate large changed segments — keep head + tail for context - if (segment.changed) { - const maxChanged = DIFF_CONTEXT_CHARS * 3 - if (segment.text.length > maxChanged) { - const keepEach = DIFF_CONTEXT_CHARS - const hiddenCount = segment.text.length - keepEach * 2 - result.push({ - text: segment.text.slice(0, keepEach), - changed: true, - segmentType: segment.segmentType, - }) - result.push($truncationSegment(hiddenCount)) - result.push({ - text: segment.text.slice(-keepEach), - changed: true, - segmentType: segment.segmentType, - }) - } else { - result.push(segment) + // Proportional share of the budget for this unchanged segment + const share = Math.max( + 40, + Math.floor((segment.text.length / unchangedTotal) * unchangedBudget), + ) + if (segment.text.length <= share) return segment + + // Preserve JSON key prefix (e.g. ` "key": "`) in the first unchanged segment + const colonQuoteMatch = segment.text.match(/^(\s*"[^"]*"\s*:\s*"?)/) + if (colonQuoteMatch) { + const keyPrefix = colonQuoteMatch[1] + const valueContent = segment.text.slice(keyPrefix.length) + const keepChars = Math.max(20, share - keyPrefix.length) + if (valueContent.length > keepChars) { + return { + ...segment, + text: + keyPrefix + + valueContent.slice(0, keepChars) + + ` … [${formatTruncatedCount(valueContent.length - keepChars)} chars]`, + } } - continue } - // This is a long unchanged segment — split into content + truncation indicator - const isFirst = i === 0 - const isLast = i === segments.length - 1 - const hasChangedNeighborBefore = i > 0 && segments[i - 1].changed - const hasChangedNeighborAfter = i < segments.length - 1 && segments[i + 1].changed - - if (isFirst && !isLast) { - // Leading unchanged: keep small head + tail near the change - const hiddenCount = segment.text.length - DIFF_CONTEXT_CHARS - 20 - result.push({text: segment.text.slice(0, 20), changed: false}) - result.push($truncationSegment(hiddenCount)) - result.push({text: segment.text.slice(-DIFF_CONTEXT_CHARS), changed: false}) - } else if (isLast && !isFirst) { - // Trailing unchanged: keep head near the change - const hiddenCount = segment.text.length - DIFF_CONTEXT_CHARS - result.push({text: segment.text.slice(0, DIFF_CONTEXT_CHARS), changed: false}) - result.push($truncationSegment(hiddenCount)) - } else if (hasChangedNeighborBefore || hasChangedNeighborAfter) { - // Middle segment between two changes: keep both ends - const hiddenCount = segment.text.length - DIFF_CONTEXT_CHARS * 2 - if (hiddenCount > 20) { - result.push({text: segment.text.slice(0, DIFF_CONTEXT_CHARS), changed: false}) - result.push($truncationSegment(hiddenCount)) - result.push({text: segment.text.slice(-DIFF_CONTEXT_CHARS), changed: false}) - } else { - result.push(segment) - } - } else { - // Standalone long unchanged segment - const hiddenCount = segment.text.length - DIFF_CONTEXT_CHARS - result.push({text: segment.text.slice(0, DIFF_CONTEXT_CHARS), changed: false}) - result.push($truncationSegment(hiddenCount)) + return { + ...segment, + text: + segment.text.slice(0, share) + + ` … [${formatTruncatedCount(segment.text.length - share)} chars]`, } - } - - return result + }) } -// ─── Segment styling ───────────────────────────────────────────────────────── - -/** Inline style constants for diff segment types */ -const DIFF_SEGMENT_STYLES = { - removed: - "background-color: rgba(220, 38, 38, 0.3); text-decoration: line-through; text-decoration-color: rgba(220, 38, 38, 0.6); border-radius: 2px; padding: 0 1px;", - added: "background-color: rgba(22, 163, 74, 0.3); border-radius: 2px; padding: 0 1px;", - truncated: "opacity: 0.45; font-style: italic; color: #888; letter-spacing: 0.02em;", -} as const - function $setLineContentWithInlineDiff( lineNode: CodeLineNode, fullContent: string, @@ -596,43 +319,31 @@ function $setLineContentWithInlineDiff( ) { lineNode.clear() + // No segments — plain text with optional truncation (single TextNode) if (!segments || segments.length === 0) { - // Try to produce styled truncation segments for plain text - const truncatedSegs = $truncateDiffLineToSegments(fullContent) - if (truncatedSegs) { - truncatedSegs.forEach((seg) => { - const node = $createTextNode(seg.text).setMode("token") - if (seg.segmentType === "truncated") { - node.setStyle(DIFF_SEGMENT_STYLES.truncated) - } - lineNode.append(node) - }) - } else { - lineNode.append($createTextNode(fullContent).setMode("token")) - } + const displayText = $truncatePlainLine(fullContent) ?? fullContent + lineNode.append($createTextNode(displayText).setMode("token")) return } - const truncatedSegments = $truncateInlineDiffSegments(segments) + // Truncate unchanged segments in-place (same segment count, shorter text) + const displaySegments = $truncateSegmentsInPlace(segments) - truncatedSegments.forEach((segment) => { + for (const segment of displaySegments) { const node = $createTextNode(segment.text).setMode("token") - if (segment.segmentType === "truncated") { - node.setStyle(DIFF_SEGMENT_STYLES.truncated) - } else if (segment.segmentType === "removed") { + if (segment.segmentType === "removed") { node.setStyle(DIFF_SEGMENT_STYLES.removed) } else if (segment.segmentType === "added") { node.setStyle(DIFF_SEGMENT_STYLES.added) } else if (segment.changed) { - // Legacy path: use line-level diffType for color const changedBg = diffType === "added" ? DIFF_SEGMENT_STYLES.added : DIFF_SEGMENT_STYLES.removed node.setStyle(changedBg) } lineNode.append(node) - }) + } } // ─── Diff content detection ────────────────────────────────────────────────── @@ -685,10 +396,10 @@ export function registerDiffHighlightBehavior( // Reset the diff-built flag for this editor on (re-)registration. diffBuiltEditors.delete(editor) - // Build the diff content tree inside a discrete editor.update(). - // Using { discrete: true } forces synchronous commit — bypassing - // Lexical's microtask-based update batching that would otherwise - // defer DOM reconciliation and freeze the browser. + // Build the diff content tree inside an editor.update() with + // skipTransforms: true. Skipping transforms is critical — Lexical's + // $applyAllTransforms iterates all dirty nodes after each update, + // and with many appended nodes this causes the browser to freeze. const buildDiffContent = () => { editor.update( () => { @@ -805,12 +516,9 @@ export function registerDiffHighlightBehavior( } }) - // Mark diff as built BEFORE appending to root — - // appending triggers CodeBlockNode transforms synchronously, - // so the flag must be set first to prevent the infinite loop. diffBuiltEditors.add(editor) - // Wrap in segments for efficient virtualization + // Wrap lines in segments for virtualization, then append to tree $wrapLinesInSegments(lineNodes).forEach((node) => { codeBlock.append(node) }) @@ -820,140 +528,138 @@ export function registerDiffHighlightBehavior( console.error("DiffHighlight: error building diff content:", parseError) } }, - {discrete: true}, + {skipTransforms: true, discrete: true}, ) } - // Build diff content immediately during extension registration. - // register() is called from LexicalBuilder.buildEditor() inside a - // useMemo — outside any Lexical update cycle — so { discrete: true } - // commits synchronously. The root DOM element doesn't exist yet, but - // Lexical builds the internal node tree regardless; DOM reconciliation - // happens automatically when the root element is attached later. - if (originalContent && modifiedContent) { - buildDiffContent() - } + const isDiffMode = Boolean(originalContent && modifiedContent) - // No-op transforms for TextNode and CodeLineNode. - // These absorb dirty nodes during Lexical's internal $applyAllTransforms loop, - // preventing $normalizeTextNode from creating an infinite cycle when diff + // No-op transforms for TextNode and CodeLineNode prevent + // $normalizeTextNode from creating an infinite cycle when diff // content contains backtick characters. const removeTextTransform = editor.registerNodeTransform(TextNode, () => {}) const removeLineTransform = editor.registerNodeTransform(CodeLineNode, () => {}) - const removeTransform = editor.registerNodeTransform( - CodeBlockNode, - (codeBlockNode: CodeBlockNode) => { - if ($hasUpdateTag("agenta:bulk-clear")) { - return - } - - // Skip re-processing during the diff initial content update. - // buildDiffContent() already set diff types on all line nodes; - // re-parsing here would strip them because the content is - // already cleaned (no pipe-delimited format). - if ($hasUpdateTag("diff-initial-content")) { - return - } - - if (diffBuiltEditors.has(editor)) { - return - } - - const codeLines = $getAllCodeLines(codeBlockNode) - - // Quick check: if lines already have diff properties set (from initial creation), - // verify a small sample to see if they're already correct and skip the full scan. - // This avoids the expensive re-parse of all 5k+ lines on the initial transform pass. - if (codeLines.length > 100) { - let alreadyAnnotated = 0 - const sampleSize = Math.min(10, codeLines.length) - for (let i = 0; i < sampleSize; i++) { - if (codeLines[i].getDiffType() !== null) { - alreadyAnnotated++ - } - } - // If most sampled lines already have diff types, the initial creation - // already set everything — skip the full transform - if (alreadyAnnotated >= sampleSize * 0.8) { - return - } - } - - const blockText = codeBlockNode.getTextContent() - - if (!isDiffContent(blockText)) { - codeLines.forEach((line: CodeLineNode) => { - if (line.getDiffType() !== null) { - line.setDiffType(null) - } - }) - return - } - - const parsedLines = codeLines.map((lineNode) => - parseDiffLine(lineNode.getTextContent()), - ) - - const inlineDiffByIndex = new Map() - for (let i = 0; i < parsedLines.length - 1; i++) { - const current = parsedLines[i] - const next = parsedLines[i + 1] - if (!current || !next) continue - - const isReplacementPair = - current.diffType === "removed" && - next.diffType === "added" && - typeof current.content === "string" && - typeof next.content === "string" - - if (!isReplacementPair) continue - - const inlinePair = buildInlineDiffPair(current.content, next.content) - if (!inlinePair) continue - - if (inlinePair.removed.length > 0) { - inlineDiffByIndex.set(i, inlinePair.removed) - } - if (inlinePair.added.length > 0) { - inlineDiffByIndex.set(i + 1, inlinePair.added) - } - } - - codeLines.forEach((lineNode: CodeLineNode, index: number) => { - const parsed = parsedLines[index] - - if (parsed) { - const currentDiffType = lineNode.getDiffType() - const currentOldLineNumber = lineNode.getOldLineNumber() - const currentNewLineNumber = lineNode.getNewLineNumber() - const currentContent = lineNode.getTextContent() - - if (parsed.diffType !== currentDiffType) { - lineNode.setDiffType(parsed.diffType) - } - - if (parsed.oldLineNumber !== currentOldLineNumber) { - lineNode.setOldLineNumber(parsed.oldLineNumber) - } - - if (parsed.newLineNumber !== currentNewLineNumber) { - lineNode.setNewLineNumber(parsed.newLineNumber) - } - - const cleanContent = parsed.content - if (cleanContent !== currentContent) { - $setLineContentWithInlineDiff( - lineNode, - cleanContent, - parsed.diffType, - inlineDiffByIndex.get(index), - ) - } - } - }) - }, - ) + // The CodeBlockNode transform is only needed for interactive editors + // where diff-formatted text might be pasted in. In diff mode, + // buildDiffContent() already handled everything above. + const removeTransform = isDiffMode + ? () => {} + : editor.registerNodeTransform(CodeBlockNode, (codeBlockNode: CodeBlockNode) => { + if ($hasUpdateTag("agenta:bulk-clear")) { + return + } + + // Skip re-processing during the diff initial content update. + // buildDiffContent() already set diff types on all line nodes; + // re-parsing here would strip them because the content is + // already cleaned (no pipe-delimited format). + if ($hasUpdateTag("diff-initial-content")) { + return + } + + if (diffBuiltEditors.has(editor)) { + return + } + const codeLines = $getAllCodeLines(codeBlockNode) + + // Quick check: if lines already have diff properties set (from initial creation), + // verify a small sample to see if they're already correct and skip the full scan. + // This avoids the expensive re-parse of all 5k+ lines on the initial transform pass. + if (codeLines.length > 100) { + let alreadyAnnotated = 0 + const sampleSize = Math.min(10, codeLines.length) + for (let i = 0; i < sampleSize; i++) { + if (codeLines[i].getDiffType() !== null) { + alreadyAnnotated++ + } + } + // If most sampled lines already have diff types, the initial creation + // already set everything — skip the full transform + if (alreadyAnnotated >= sampleSize * 0.8) { + return + } + } + + const blockText = codeBlockNode.getTextContent() + + if (!isDiffContent(blockText)) { + codeLines.forEach((line: CodeLineNode) => { + if (line.getDiffType() !== null) { + line.setDiffType(null) + } + }) + return + } + + const parsedLines = codeLines.map((lineNode) => + parseDiffLine(lineNode.getTextContent()), + ) + + const inlineDiffByIndex = new Map() + for (let i = 0; i < parsedLines.length - 1; i++) { + const current = parsedLines[i] + const next = parsedLines[i + 1] + if (!current || !next) continue + + const isReplacementPair = + current.diffType === "removed" && + next.diffType === "added" && + typeof current.content === "string" && + typeof next.content === "string" + + if (!isReplacementPair) continue + + const inlinePair = buildInlineDiffPair(current.content, next.content) + if (!inlinePair) continue + + if (inlinePair.removed.length > 0) { + inlineDiffByIndex.set(i, inlinePair.removed) + } + if (inlinePair.added.length > 0) { + inlineDiffByIndex.set(i + 1, inlinePair.added) + } + } + + codeLines.forEach((lineNode: CodeLineNode, index: number) => { + const parsed = parsedLines[index] + + if (parsed) { + const currentDiffType = lineNode.getDiffType() + const currentOldLineNumber = lineNode.getOldLineNumber() + const currentNewLineNumber = lineNode.getNewLineNumber() + const currentContent = lineNode.getTextContent() + + if (parsed.diffType !== currentDiffType) { + lineNode.setDiffType(parsed.diffType) + } + + if (parsed.oldLineNumber !== currentOldLineNumber) { + lineNode.setOldLineNumber(parsed.oldLineNumber) + } + + if (parsed.newLineNumber !== currentNewLineNumber) { + lineNode.setNewLineNumber(parsed.newLineNumber) + } + + const cleanContent = parsed.content + if (cleanContent !== currentContent) { + $setLineContentWithInlineDiff( + lineNode, + cleanContent, + parsed.diffType, + inlineDiffByIndex.get(index), + ) + } + } + }) + }) + + // Build diff content. Uses skipTransforms to avoid Lexical's + // $applyAllTransforms loop which freezes with many dirty nodes. + if (isDiffMode) { + buildDiffContent() + } return () => { diffBuiltEditors.delete(editor) From e6807fc14ce59e4bff12e247b157c968afe16081 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Fri, 13 Mar 2026 12:05:51 +0100 Subject: [PATCH 6/8] refactor(editor): remove lazy loading for DiffView in commit modal Remove Suspense wrapper and lazy import for DiffView component. Import DiffView directly to eliminate loading skeleton and simplify rendering path. Comment out diffReady state check and fallback skeleton UI. --- .../commit/components/EntityCommitContent.tsx | 29 +++++++++---------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/web/packages/agenta-entity-ui/src/modals/commit/components/EntityCommitContent.tsx b/web/packages/agenta-entity-ui/src/modals/commit/components/EntityCommitContent.tsx index 34cbcf2bc7..ac7c7d3eca 100644 --- a/web/packages/agenta-entity-ui/src/modals/commit/components/EntityCommitContent.tsx +++ b/web/packages/agenta-entity-ui/src/modals/commit/components/EntityCommitContent.tsx @@ -9,6 +9,7 @@ import {lazy, Suspense, useState, useEffect} from "react" import {formatCount} from "@agenta/shared/utils" import {VersionBadge} from "@agenta/ui/components/presentational" +import {DiffView} from "@agenta/ui/editor" import {cn, textColors} from "@agenta/ui/styles" import {Input, Alert, Typography, Skeleton, Radio} from "antd" import {useAtomValue, useSetAtom} from "jotai" @@ -23,7 +24,7 @@ import { } from "../state" // Lazy load DiffView to avoid bundling Lexical editor in _app chunk -const DiffView = lazy(() => import("@agenta/ui/editor").then((mod) => ({default: mod.DiffView}))) +// const DiffView = dynamic(() => import("@agenta/ui/editor").then((mod) => ({default: mod.DiffView}))) const {TextArea} = Input const {Text} = Typography @@ -283,7 +284,17 @@ export function EntityCommitContent({
- {diffReady ? ( + + {/* {diffReady ? ( @@ -291,24 +302,12 @@ export function EntityCommitContent({
} > - ) : (
- )} + )} */} )} From 1bc9aa46fc940cfd99cbd448ed36b385175c7783 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Fri, 13 Mar 2026 12:25:22 +0100 Subject: [PATCH 7/8] refactor(editor): remove unused imports from EntityCommitContent Remove lazy and Suspense imports that are no longer needed after removing lazy loading for DiffView component. --- .../src/modals/commit/components/EntityCommitContent.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/packages/agenta-entity-ui/src/modals/commit/components/EntityCommitContent.tsx b/web/packages/agenta-entity-ui/src/modals/commit/components/EntityCommitContent.tsx index ac7c7d3eca..44ba0d392d 100644 --- a/web/packages/agenta-entity-ui/src/modals/commit/components/EntityCommitContent.tsx +++ b/web/packages/agenta-entity-ui/src/modals/commit/components/EntityCommitContent.tsx @@ -5,7 +5,7 @@ * Supports version info, changes summary, and diff view via adapter. */ -import {lazy, Suspense, useState, useEffect} from "react" +import {useState, useEffect} from "react" import {formatCount} from "@agenta/shared/utils" import {VersionBadge} from "@agenta/ui/components/presentational" From 48c9d17285f9cbfd1a307443c155dba90ce88387 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Fri, 13 Mar 2026 12:30:03 +0100 Subject: [PATCH 8/8] refactor(editor): remove unused Skeleton import from EntityCommitContent Remove Skeleton import from antd that is no longer needed after removing loading skeleton UI. Also rename unused diffReady state variable to _. --- .../src/modals/commit/components/EntityCommitContent.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/packages/agenta-entity-ui/src/modals/commit/components/EntityCommitContent.tsx b/web/packages/agenta-entity-ui/src/modals/commit/components/EntityCommitContent.tsx index 44ba0d392d..2980e43f9a 100644 --- a/web/packages/agenta-entity-ui/src/modals/commit/components/EntityCommitContent.tsx +++ b/web/packages/agenta-entity-ui/src/modals/commit/components/EntityCommitContent.tsx @@ -11,7 +11,7 @@ import {formatCount} from "@agenta/shared/utils" import {VersionBadge} from "@agenta/ui/components/presentational" import {DiffView} from "@agenta/ui/editor" import {cn, textColors} from "@agenta/ui/styles" -import {Input, Alert, Typography, Skeleton, Radio} from "antd" +import {Input, Alert, Typography, Radio} from "antd" import {useAtomValue, useSetAtom} from "jotai" import { @@ -78,7 +78,7 @@ export function EntityCommitContent({ // Defer DiffView mounting until after the first paint so the modal // shell and form appear immediately without being blocked by Lexical // editor creation + DOM reconciliation. - const [diffReady, setDiffReady] = useState(false) + const [_, setDiffReady] = useState(false) useEffect(() => { if (!context?.diffData?.original || !context?.diffData?.modified) { setDiffReady(false)