···671671 }
672672 }
673673674674- // Trace back to produce diff hunks.
675675- const ops = [];
674674+ // Line lengths in the original document.
675675+ // All lines except the last have a trailing \n; the last does not.
676676+ const oldLineLengths = oldLines.map((l, idx) => l.length + (idx < oldLines.length - 1 ? 1 : 0));
677677+678678+ // Trace back to produce raw diff ops.
679679+ // Traceback preference: insert before delete. charOffset tracks the
680680+ // character position in the ORIGINAL document (only advances on matches
681681+ // and deletes, never on inserts — inserts reference the pre-delete offset).
682682+ const rawOps = [];
676683 let i = 0, j = 0;
677677- // Track character offset into oldStr.
678684 let charOffset = 0;
679679- // +1 for the \n separator. The last line has no trailing \n, so its
680680- // length is exact; however the OT engine clamps out-of-range positions,
681681- // so an off-by-one on the final line is safe in practice.
682682- const oldLineLengths = oldLines.map(l => l.length + 1);
683685684686 while (i < m || j < n) {
685687 if (i < m && j < n && oldLines[i] === newLines[j]) {
688688+ // Matching line — advance both pointers and the offset.
686689 charOffset += oldLineLengths[i];
687690 i++; j++;
688691 } else if (j < n && (i >= m || dp[i+1][j] <= dp[i][j+1])) {
689689- // Insert newLines[j]
690690- const insertText = newLines[j] + (j < n - 1 ? '\n' : '');
691691- ops.push({ from: charOffset, to: charOffset, insert: insertText });
692692+ // Insert newLines[j] at the current position in the old doc.
693693+ // Append \n if more new lines follow OR if there are still old
694694+ // lines remaining after this insertion point (line is mid-doc).
695695+ const insertText = newLines[j] + (j < n - 1 || i < m ? '\n' : '');
696696+ rawOps.push({ from: charOffset, to: charOffset, insert: insertText });
692697 j++;
693698 } else {
694694- // Delete oldLines[i]
699699+ // Delete oldLines[i].
695700 const deleteLen = oldLineLengths[i];
696696- ops.push({ from: charOffset, to: charOffset + deleteLen, insert: '' });
701701+ rawOps.push({ from: charOffset, to: charOffset + deleteLen, insert: '' });
697702 charOffset += deleteLen;
698703 i++;
699704 }
700705 }
706706+707707+ // Post-process: merge adjacent insert-at-X + delete-at-X pairs into a
708708+ // single replace op. The traceback emits insert before delete for the
709709+ // same position (insert doesn't advance charOffset, so the delete that
710710+ // follows still references the same offset). Merging is required because
711711+ // the server applies ops sequentially — a separate insert followed by a
712712+ // separate delete would hit the wrong bytes after the insert shifts things.
713713+ function mergeOps(raw) {
714714+ const merged = [];
715715+ for (let k = 0; k < raw.length; k++) {
716716+ const op = raw[k];
717717+ const next = raw[k + 1];
718718+ // Insert at X (pure insert: to === from) followed immediately by
719719+ // delete starting at X (pure delete: insert === '').
720720+ if (op.to === op.from && next && next.insert === '' && next.from === op.from) {
721721+ merged.push({ from: op.from, to: next.to, insert: op.insert });
722722+ k++; // skip next
723723+ } else {
724724+ merged.push(op);
725725+ }
726726+ }
727727+ return merged;
728728+ }
729729+730730+ const ops = mergeOps(rawOps);
701731702732 // Fallback: if diff is too fragmented, send a single full replacement.
703733 if (ops.length > 20) {