Skip to content

fix: track new lines in suggested mode#3598

Closed
harbournick wants to merge 1 commit into
mainfrom
nick/it-1136-track-changes-paragraph-split-enter-and-paragraph-style
Closed

fix: track new lines in suggested mode#3598
harbournick wants to merge 1 commit into
mainfrom
nick/it-1136-track-changes-paragraph-split-enter-and-paragraph-style

Conversation

@harbournick
Copy link
Copy Markdown
Collaborator

No description provided.

@harbournick harbournick self-assigned this Jun 1, 2026
@harbournick harbournick requested a review from a team as a code owner June 1, 2026 19:39
@linear-code
Copy link
Copy Markdown

linear-code Bot commented Jun 1, 2026

IT-1136

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 663198e717

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

}
if (op.kind === 'rejectParagraphSplit' && op.mark) {
tr.step(new RemoveMarkStep(op.from, op.to, op.mark));
if (!rejectParagraphSplitAt(tr, op.from, op.anchor)) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Map later ops after rejecting paragraph splits

When rejecting a paragraph-split change as part of a larger decision, e.g. rejectTrackedChanges({ scope: 'all' }) with another tracked change later in the document, this tr.join(...) changes document positions while the rest of sortedOps still use the original positions computed by buildMutationPlan. Existing mark ops were position-stable and content deletions are applied in reverse, but this new doc-changing op runs in the forward mark pass, so later remove/add/replace operations can target the wrong range or fail after the join shrinks the document. The join should be applied in an order that preserves positions or subsequent op ranges should be mapped through tr.mapping.

Useful? React with 👍 / 👎.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Jun 1, 2026

I wasn't able to get the ecma-spec MCP calls approved in this session (every call and the follow-up question returned "haven't granted it yet"), so the verification below rests on established ECMA-376 schema knowledge plus the code/tests in the diff, not live spec queries. I've flagged anything I'd normally have double-checked against the tool. Here's the review.


Status: FAIL

The paragraph-split tracking mechanism is mostly well-designed, but there are a couple of spec-type issues plus a non-standard extension worth calling out.

1. Non-numeric w:id on w:ins and w:pPrChange (ST_DecimalNumber violation)

w:id on track-change elements (w:ins, w:pPrChange, w:rPrChange) is typed ST_DecimalNumber — an integer. The new helpers mint the id via getTrackFormatChangeWordId, whose no-allocator fallback returns sourceId || logicalId, and logicalId is the mark's id, which is a UUID-style string.

  • helpers.js:73createParagraphSplitInsertionElement writes 'w:id': getTrackFormatChangeWordId(...) on w:ins.
  • helpers.js:55createParagraphSplitPropertiesChange writes the same as id on the w:pPrChange.

The PR's own test demonstrates the bad output: generate-paragraph-properties.test.js asserts 'w:id': 'logical-change-id' on the emitted w:ins. A UUID there is not a valid ST_DecimalNumber. The allocator path (which mints decimal ids like '12') is compliant; it's the fallback that emits a non-integer id. Same latent pattern already existed in createRunPropertiesChangeElement, but this PR extends it to two new element types. Spec: https://ooxml.dev/spec?q=ins

2. Foreign sd: attributes on w:pPrChange are not schema-valid (non-standard extension)

pPrChange-translator.js:19-21 emits xmlns:sd, sd:paragraphSplit, and sd:paragraphSplitAnchor on <w:pPrChange>. CT_PPrChange (via CT_TrackChange) defines only w:author, w:id, w:date and has no attribute wildcard, so a strict validator rejects these, and Word will drop unknown foreign attributes on round-trip. They're properly namespaced (sd:, not w:), so they don't masquerade as standard OOXML — this is a deliberate vendor extension rather than a fake w: attribute — but the metadata won't survive interop with Word. Worth confirming that's intended, and that the w:ins channel (point 4) is the real source of truth on round-trip. Spec: https://ooxml.dev/spec?q=pPrChange

3. Required w:author can be omitted

w:author is required on CT_TrackChange (w:pPrChange, w:ins). Both createParagraphSplitPropertiesChange (helpers.js:56) and createParagraphSplitInsertionElement (helpers.js:74) source it from trackFormatMark.attrs?.author, which may be undefined, and createAttributeHandler('w:author') only emits the attribute when present — the pPrChange decode test (pPrChange-translator.test.js) shows output with w:id but no w:author. In the UI split path author is user.name || '' (empty string still satisfies presence), so it only bites for marks lacking an author. Minor, but it's a missing-required-attribute path. Spec: https://ooxml.dev/spec?q=pPrChange

What's correct: Representing a tracked paragraph split by placing <w:ins> inside the paragraph-mark <w:rPr> (createParagraphSplitInsertionElement + prependParagraphSplitInsertion) is the spec-correct mechanism — the paragraph-mark run properties is CT_ParaRPr, whose content model leads with the paragraph-mark track-change group (w:ins/w:del), and prepending it ahead of the rPr properties keeps child ordering valid. That part is sound.

Please re-run the ecma-spec lookups (CT_TrackChange attributes/ST_DecimalNumber, CT_ParaRPr children) to confirm points 1–3 if you want the authoritative cross-check — I couldn't execute them here.

Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cubic analysis

4 issues found across 16 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/super-editor/src/editors/v1/extensions/comment/tracked-change-display.js">

<violation number="1" location="packages/super-editor/src/editors/v1/extensions/comment/tracked-change-display.js:121">
P2: New `paragraphSplit` display type is emitted without updating the shared `trackedChangeDisplayType` union, causing a typed API-contract drift.</violation>
</file>

<file name="packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/generate-paragraph-properties.js">

<violation number="1" location="packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/generate-paragraph-properties.js:28">
P1: `findParagraphSplitTrackFormatMark` can lose an already-found split mark because the descendants callback overwrites `found` on later siblings, causing paragraph split tracking metadata to be skipped.</violation>
</file>

<file name="packages/super-editor/src/editors/v1/extensions/track-changes/review-model/decision-engine.js">

<violation number="1" location="packages/super-editor/src/editors/v1/extensions/track-changes/review-model/decision-engine.js:733">
P1: Rejecting a tracked paragraph split is planned per mark-run instead of per logical split, causing repeated joins and reject failures on multi-run spans.</violation>

<violation number="2" location="packages/super-editor/src/editors/v1/extensions/track-changes/review-model/decision-engine.js:992">
P1: `tr.join()` inside `rejectParagraphSplitAt` alters the document structure and shifts all subsequent positions, but remaining operations in the plan still reference their original pre-computed `from`/`to` values. When multiple changes are rejected together (e.g., `rejectAll`) and a paragraph-split is not the last operation, later mark-removal or content-deletion steps will target stale positions—causing incorrect edits or runtime failures. Either apply paragraph-split rejections in a separate pass (reverse order), or map subsequent operation positions through `tr.mapping` after the join.</violation>
</file>

Linked issue analysis

Linked issue: IT-1136: Track Changes: paragraph split (Enter) and paragraph-style changes are applied directly, not recorded as tracked changes

Status Acceptance criteria Notes
Pressing Enter (split paragraph) in Suggesting mode records a tracked paragraph-split change rather than applying the split directly split-run.js adds addTrackedParagraphSplitMark to create a trackFormat mark on the Enter path; tests assert a tracked change is created and that paragraph texts reflect the split.
Tracked paragraph-split changes are rendered in the UI as a ‘new line’ / 'Added new line' change tracked-change-display recognizes paragraphSplit snapshots and CommentDialog.vue renders a specific 'Added new line' message; tests verify the display text.
Import restores paragraph-split snapshots from SuperDoc/DOCX metadata into a trackFormat mark markImporter handles paragraph split metadata and creates paragraphSplit snapshots; tests verify handleStyleChangeMarksV2 restores paragraphSplit snapshots from inline paragraph metadata.
Export emits Word-visible paragraph insertion (w:ins) and pPrChange with SuperDoc paragraphSplit metadata, using the Word id allocator when provided generate-paragraph-properties and helpers create w:ins insertion element and pPrChange attributes with SuperDoc namespace/attrs; tests assert insertion element, change attrs, and that the allocator is called.
Rejecting a tracked paragraph-split restores (joins) the paragraphs and removes the trackFormat mark decision-engine plans and applies a 'rejectParagraphSplit' op which removes the mark and joins the paragraphs; tests exercise rejecting the tracked split and assert the join and removal of the change.

Reply with feedback, questions, or to request a fix.

Fix all with cubic | Re-trigger cubic

Comment on lines +28 to +29
found = childMarks.find((mark) => isParagraphSplitTrackFormatMark(mark)) || null;
return !found;
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot Jun 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: findParagraphSplitTrackFormatMark can lose an already-found split mark because the descendants callback overwrites found on later siblings, causing paragraph split tracking metadata to be skipped.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/generate-paragraph-properties.js, line 28:

<comment>`findParagraphSplitTrackFormatMark` can lose an already-found split mark because the descendants callback overwrites `found` on later siblings, causing paragraph split tracking metadata to be skipped.</comment>

<file context>
@@ -1,5 +1,70 @@
+    let found = null;
+    node.descendants((child) => {
+      const childMarks = Array.isArray(child?.marks) ? child.marks : [];
+      found = childMarks.find((mark) => isParagraphSplitTrackFormatMark(mark)) || null;
+      return !found;
+    });
</file context>
Suggested change
found = childMarks.find((mark) => isParagraphSplitTrackFormatMark(mark)) || null;
return !found;
if (!found) {
found = childMarks.find((mark) => isParagraphSplitTrackFormatMark(mark)) || null;
}
return !found;
Fix with Cubic

Comment on lines +733 to 748
for (const run of getSegmentMarkRuns(seg)) {
const paragraphSplit = snapshotAttrsForType(run.mark.attrs?.before, 'paragraphSplit');
if (paragraphSplit) {
ops.push({
kind: 'rejectParagraphSplit',
from: run.from,
to: run.to,
changeId: change.id,
side: SegmentSide.Formatting,
mark: run.mark,
anchor: paragraphSplit.anchor === 'source' ? 'source' : 'inserted',
});
} else {
ops.push({
kind: 'restoreFormat',
from: run.from,
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot Jun 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Rejecting a tracked paragraph split is planned per mark-run instead of per logical split, causing repeated joins and reject failures on multi-run spans.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/super-editor/src/editors/v1/extensions/track-changes/review-model/decision-engine.js, line 733:

<comment>Rejecting a tracked paragraph split is planned per mark-run instead of per logical split, causing repeated joins and reject failures on multi-run spans.</comment>

<file context>
@@ -726,8 +727,22 @@ const planFormattingDecision = ({ ops, change, decision, retired }) => {
+      continue;
+    }
+
+    for (const run of getSegmentMarkRuns(seg)) {
+      const paragraphSplit = snapshotAttrsForType(run.mark.attrs?.before, 'paragraphSplit');
+      if (paragraphSplit) {
</file context>
Fix with Cubic

tr.step(new RemoveMarkStep(op.from, op.to, op.mark));
continue;
}
if (op.kind === 'rejectParagraphSplit' && op.mark) {
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot Jun 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: tr.join() inside rejectParagraphSplitAt alters the document structure and shifts all subsequent positions, but remaining operations in the plan still reference their original pre-computed from/to values. When multiple changes are rejected together (e.g., rejectAll) and a paragraph-split is not the last operation, later mark-removal or content-deletion steps will target stale positions—causing incorrect edits or runtime failures. Either apply paragraph-split rejections in a separate pass (reverse order), or map subsequent operation positions through tr.mapping after the join.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/super-editor/src/editors/v1/extensions/track-changes/review-model/decision-engine.js, line 992:

<comment>`tr.join()` inside `rejectParagraphSplitAt` alters the document structure and shifts all subsequent positions, but remaining operations in the plan still reference their original pre-computed `from`/`to` values. When multiple changes are rejected together (e.g., `rejectAll`) and a paragraph-split is not the last operation, later mark-removal or content-deletion steps will target stale positions—causing incorrect edits or runtime failures. Either apply paragraph-split rejections in a separate pass (reverse order), or map subsequent operation positions through `tr.mapping` after the join.</comment>

<file context>
@@ -947,6 +989,13 @@ const applyPlan = ({ state, plan }) => {
         tr.step(new RemoveMarkStep(op.from, op.to, op.mark));
         continue;
       }
+      if (op.kind === 'rejectParagraphSplit' && op.mark) {
+        tr.step(new RemoveMarkStep(op.from, op.to, op.mark));
+        if (!rejectParagraphSplitAt(tr, op.from, op.anchor)) {
</file context>
Fix with Cubic

const paragraphSplit = findSnapshotByType(before, 'paragraphSplit') || findSnapshotByType(after, 'paragraphSplit');
if (paragraphSplit) {
return {
trackedChangeDisplayType: ParagraphSplitDisplayType,
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot Jun 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: New paragraphSplit display type is emitted without updating the shared trackedChangeDisplayType union, causing a typed API-contract drift.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/super-editor/src/editors/v1/extensions/comment/tracked-change-display.js, line 121:

<comment>New `paragraphSplit` display type is emitted without updating the shared `trackedChangeDisplayType` union, causing a typed API-contract drift.</comment>

<file context>
@@ -109,6 +115,14 @@ const snapshotAttrsEqual = (a, b) => {
+  const paragraphSplit = findSnapshotByType(before, 'paragraphSplit') || findSnapshotByType(after, 'paragraphSplit');
+  if (paragraphSplit) {
+    return {
+      trackedChangeDisplayType: ParagraphSplitDisplayType,
+      trackedChangeText: 'new line',
+    };
</file context>
Fix with Cubic

@harbournick harbournick closed this Jun 1, 2026
@codecov-commenter
Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants