Signal drop!
Relay (operand.online) is unreachable.
Usually, a dropped signal means an upgrade is happening. Hold on!
Sorry, no connección.
Hang in there while we get back on track
gram: docs
> ./openspec/changes/tracked-structural-changes/design.md
# Design: Tracked Structural Changes
## OOXML reference
Most structural revision markers carry the attribute triple `w:id` (required, `xsd:int`), `w:author` (required, `xsd:string` display name), `w:date` (optional, `xsd:dateTime`). The schema-level base is `CT_TrackChange` (`wml.xsd:805`). A few exceptions extend `CT_Markup` instead and carry **only `w:id`**:
- `<w:tblGridChange>` (`CT_TblGridChange`, extends `CT_Markup`, `wml.xsd:893`) — id only.
Per-marker prior-state elements (`*Change`) carry a **full prior snapshot**, not a diff. The schema base of each prior element is a `*Base` type (e.g. `pPrChange` contains `CT_PPrBase`, not `CT_PPr`), so the prior **cannot** itself nest `rPr`, `sectPr`, or a further `*Change`.
### Schema-mandated child ordering inside `<w:rPr>` and `<w:pPr>`
The serializer MUST honor these orderings or Word and strict readers will reject the output:
- Inside a run `<w:rPr>` (per `EG_RPrContent`, `wml.xsd:1784`): regular base properties first, **`<w:rPrChange>` last**.
- Inside a paragraph-mark `<w:pPr><w:rPr>` (per `EG_ParaRPrTrackChanges`, `wml.xsd:1837`): `<w:ins>` / `<w:del>` / `<w:moveFrom>` / `<w:moveTo>` **first** (in that order), then base properties, then `<w:rPrChange>` last.
- Inside `<w:pPr>` (per `CT_PPr`, `wml.xsd:1044`): base properties first, **`<w:pPrChange>` last**.
- Inside `<w:tcPr>`, `<w:trPr>`: structural change elements appear in `EG_CellMarkupElements` / `EG_TrackChange` positions per `wml.xsd:977,2330`; the `*Change` snapshot variants appear last.
### `cellMerge` is vertical-merge tracking, not horizontal
`CT_CellMergeTrackChange` (`wml.xsd:811`) extends `CT_TrackChange` with **`vMerge`** and **`vMergeOrig`** attributes of type `ST_AnnotationVMerge` (values `cont`, `rest`). There is **no `val` attribute** and there is no horizontal-merge dimension in this element. **Horizontal merge** tracking in Word is conveyed by `<w:cellIns>` on the merging cell and `<w:cellDel>` on each absorbed cell (the absorbed cells remain in the row XML until the merge is accepted; on accept they are removed and the surviving cell's `gridSpan` is increased).
### Distinct paragraph-mark rPr change
`CT_ParaRPrChange` (`wml.xsd:938`) is a **separate element** from `CT_RPrChange` (the regular run rPr change). It tracks changes to the formatting of the paragraph-mark glyph itself ("the paragraph mark used to be bold"), and lives at the **end of `CT_ParaRPr`** (i.e. `<w:pPr><w:rPr><w:rPrChange>` uses `CT_ParaRPrChange`, not `CT_RPrChange`). Wiring it to the inline `revision_change` mark would write to the wrong schema position. It needs its own paragraph-node attr.
### Tier 1 — MUST support for round-trip without data loss
| Marker | Location | Tracks | Currently parsed | Currently serialized |
| ----------------------------------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------------ | --------------------------------------- | -------------------------- |
| `<w:ins>` / `<w:del>` wrapping runs | inside `<w:p>` | inline insert/delete | yes (mark) | yes |
| `<w:pPr><w:rPr><w:ins/>` | inside `<w:pPr><w:rPr>` | paragraph-mark inserted | **no** | no |
| `<w:pPr><w:rPr><w:del/>` | inside `<w:pPr><w:rPr>` | paragraph-mark deleted | **no** | no |
| `<w:pPrChange>` | last child of `<w:pPr>` | prior paragraph props (full snapshot, frozen) | yes (model only, dropped at toProseDoc) | yes (only if model has it) |
| `<w:rPrChange>` (run) | last child of `<w:rPr>` | prior run props (full snapshot, frozen) | yes (model only, dropped) | yes (only if model has it) |
| `<w:rPrChange>` (paragraph-mark, `CT_ParaRPrChange`) | last child of `<w:pPr><w:rPr>` | prior paragraph-mark rPr | **no (separate code path)** | no |
| `<w:sectPrChange>` | last child of `<w:sectPr>` (both body-level and `pPr/sectPr` placements legal) | prior section props | **no** | no |
| `<w:trPr><w:ins/>` | inside `<w:trPr>` | row inserted | **no** | no |
| `<w:trPr><w:del/>` | inside `<w:trPr>` | row deleted | **no** | no |
| `<w:trPrChange>` | child of `<w:trPr>` | prior row props | yes (model only, dropped) | yes (only if model has it) |
| `<w:tcPr><w:cellIns/>` | inside `<w:tcPr>` (choice, exclusive) | cell inserted | yes (model only, dropped) | no |
| `<w:tcPr><w:cellDel/>` | inside `<w:tcPr>` (choice, exclusive) | cell deleted (and used to mark cells absorbed by horizontal merge) | yes (model only, dropped) | no |
| `<w:tcPr><w:cellMerge vMerge=…>` | inside `<w:tcPr>` (choice, exclusive) | vertical cell merge | yes (model only, dropped) | no |
| `<w:tcPrChange>` | child of `<w:tcPr>` | prior cell props | yes (model only, dropped) | no |
| `<w:tblPrChange>` | child of `<w:tblPr>` | prior table props | yes (model only, dropped) | no |
| `<w:tblPrExChange>` | child of `<w:tblPrEx>` **inside `<w:tr>`** (per-row exceptions) | prior table-exception props for this row | yes (model only, dropped) | no |
| `<w:tblGridChange>` (`CT_TblGridChange`, **id only**) | child of `<w:tblGrid>` | prior grid (column widths) | yes (model only, dropped) | no |
### Tier 2 — SHOULD support (covered later)
| Marker | Location | Tracks |
| -------------------------------------------------------------------------- | ------------------ | ------------------------------------------------------------------ |
| `<w:moveFrom>` / `<w:moveTo>` | wrap runs | block move source / destination |
| `<w:moveFromRangeStart/End>` | inline | move-source span anchors |
| `<w:moveToRangeStart/End>` | inline | move-target span anchors |
| `<w:numPr><w:ins/>` (in schema; `<w:numberingChange>` is NOT in `wml.xsd`) | inside `<w:numPr>` | list-item assignment inserted (no `numPr/del` exists — limitation) |
### Tier 3 — NICE-TO-HAVE (out of scope)
`<w:customXmlInsRangeStart/End>` and its variants (parser SHOULD at minimum round-trip them as opaque to avoid stripping third-party tracking).
### Identity and grouping
Sidebar entries and `acceptChangeById` resolution group by the **triple `(w:id, w:author, w:date)`**, not bare `w:id`. The schema does not enforce id uniqueness and Word does not scope ids by author, so id collisions across authors are possible.
### Accept / Reject behavior
Per-marker semantics. Word's behavior is the reference (sources cited in research notes accompanying this proposal).
| Marker | Accept | Reject |
| ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------ |
| inline `w:ins` | keep text, drop wrapper | remove text and wrapper |
| inline `w:del` | remove text and wrapper | keep text, drop wrapper |
| `pPr/rPr/ins` (paragraph-mark ins, on first paragraph of the split) | clear marker, keep split | join this paragraph with next; **resulting paragraph inherits the _second_ paragraph's `pPr`**; clear marker |
| `pPr/rPr/del` (paragraph-mark del) | join this paragraph with next; resulting paragraph inherits the second paragraph's `pPr`; clear marker | clear marker, keep split |
| `pPrChange` | clear `pPrChange`, current props win | restore full prior `pPr` snapshot, clear `pPrChange` |
| `rPrChange` (run) | clear, current rPr wins | restore full prior `rPr` snapshot, clear |
| `paraRPrChange` (paragraph-mark) | clear, current paragraph-mark rPr wins | restore full prior paragraph-mark rPr, clear |
| `sectPrChange` | clear, current section props win | restore full prior `sectPr`, clear |
| `trPr/ins` | clear marker | delete row |
| `trPr/del` | delete row | clear marker |
| `trPrChange` | clear | restore prior `trPr`, clear |
| `cellIns` | clear marker | delete cell (and adjust `gridSpan`) |
| `cellDel` | delete cell (and adjust `gridSpan` of surviving cells) | clear marker |
| `cellMerge` (vertical, `vMerge`/`vMergeOrig` pair on adjacent rows' cells sharing a `(id, author, date)` triple) | apply the vertical merge | clear markers |
| `tcPrChange` | clear | restore prior `tcPr`, clear |
| `tblPrChange` / `tblPrExChange` (per-row) | clear | restore prior, clear |
| `tblGridChange` (id-only) | clear | restore prior `<w:tblGrid>`, clear |
#### Edge cases
- `pPrIns` reject on the **last paragraph in the document** has no following sibling to join with. Behavior: clear attr without joining, log a diagnostic, return `true`.
- `pPrDel` accept on the **first paragraph** has no previous sibling. Behavior: clear attr without joining, log a diagnostic, return `true`. (Word emits this rarely; conformance still requires it.)
- `trDel` accept on the **only row** of a table leaves the table with zero rows, which is invalid per PM schema. Behavior: accept removes the entire table node.
- `cellIns` / `cellDel` / `cellMerge` on the same cell are mutually exclusive per `EG_CellMarkupElements` (`wml.xsd:977`). Authoring a second on a cell that already carries one collapses both (insert-then-delete in the same session ⇒ no marker; insert-then-merge ⇒ keep only the more recent).
- Adjacent paragraphs each carrying `pPrIns`: rejecting either is per-id, and "join with following" uses the post-acceptance position, not the original (so rejecting in id-order is well-defined).
## PM schema additions
All revision attrs use the shape:
```ts
type RevisionInfo = {
revisionId: number; // matches OOXML w:id (xsd:int)
author: string; // display name from w:author
date: string | null; // ISO 8601 UTC ("…Z"), null if w:date absent
};
type PropertyChangeInfo<P> = RevisionInfo & {
prior: P; // full frozen snapshot; not field-merged
};
revisionId is a number to match (a) OOXML w:id (decimal integer), (b) the existing InsertionExtension / DeletionExtension mark attrs in TrackedChangeExtensions.ts, (c) the existing agents-package acceptChange(id: number) signature in packages/agents/src/changes.ts.
date is normatively ISO 8601 with explicit Z (UTC) and no fractional seconds; the parser accepts any valid xsd:dateTime and normalizes; missing w:date is tolerated as null.
prior for pPrChange is the parsed ParagraphFormatting snapshot without _originalFormatting, _sectionProperties, or any nested change. prior is frozen on first edit within a (id, author, date) revision session and never updated by subsequent edits in the same session. After every edit, the implementation compares the resulting current properties against prior; if all prior fields equal current, the *Change attr is cleared (Word's "no-op net change ⇒ no revision" behavior).
Relationship to existing _originalFormatting attrs
ParagraphAttrs._originalFormatting (and the TableAttrs / TableCellAttrs counterparts) already exists in packages/core/src/prosemirror/schema/nodes.ts. Its purpose is parse-time baseline preservation so unhandled OOXML round-trips losslessly. pPrChange.prior is edit-time pre-snapshot for revision tracking — a different lifecycle. The two coexist and never overwrite each other. On save, the serializer reads _originalFormatting for fields the editor doesn't model and pPrChange.prior for tracked-change history; both are emitted in their respective XML positions.
Multi-author / multi-session changes per node
Existing model types (Paragraph.propertyChanges: ParagraphPropertyChange[]) treat property changes as an array: two authors editing the same paragraph stack changes. To preserve this, the new attrs are arrays:
// ParagraphAttrs additions
pPrIns: RevisionInfo | null;
pPrDel: RevisionInfo | null;
pPrChange: (PropertyChangeInfo < ParagraphFormatting > []) | null;
paraRPrChange: (PropertyChangeInfo < ParagraphMarkFormatting > []) | null;
sectPrChange: PropertyChangeInfo<SectionFormatting> | null;
sectPrChangeBodyLevel: boolean | null; // true if the sectPr is body-level rather than pPr-level
pPrIns and pPrDel remain single-valued (only one author can be "the one who inserted" a given paragraph mark).
table / table_row / table_cell node — additions
// table
tblPrChange: PropertyChangeInfo<TableFormatting>[] | null
tblGridChange: PropertyChangeInfoNoAuthor<TableGrid> | null // CT_Markup: id only
// table_row
trIns: RevisionInfo | null
trDel: RevisionInfo | null
trPrChange: PropertyChangeInfo<TableRowFormatting>[] | null
tblPrExChange: PropertyChangeInfo<TablePropertyExceptions>[] | null
// table_cell (cellIns / cellDel / cellMerge are mutually exclusive per schema)
cellMarker: | { kind: 'ins', info: RevisionInfo }
| { kind: 'del', info: RevisionInfo }
| { kind: 'merge', info: RevisionInfo, vMerge: 'rest' | 'cont', vMergeOrig?: ... }
| null
tcPrChange: PropertyChangeInfo<TableCellFormatting>[] | null
PropertyChangeInfoNoAuthor<P> is { revisionId: number; prior: P } for the CT_Markup-extending change types (only tblGridChange). Sidebar entries for these display "Unknown" for author/date.
New revision_change mark (for run rPrChange only)
revisionChange: {
attrs: { revisionId, author, date, prior: RunFormatting }
inclusive: false
}
Used only for run-level <w:rPrChange>. The paragraph-mark CT_ParaRPrChange does NOT use this mark — it uses the paragraph node's paraRPrChange attr.
Mark identity / merging: PM Mark.eq compares attr objects by reference. To allow adjacent runs that share a revision to render as a single span, prior is canonicalized at creation: keys are deterministically sorted, and the resulting object is Object.freezed. A shared prior reference is reused when possible. findAdjacentRevision in suggestionMode.ts is extended to deep-compare revision_change attrs for adjacency.
Cache key updates
hashParagraphBlock (packages/core/src/layout-bridge/measuring/cache.ts) keys paragraph measurements by a fixed allowlist of attrs (text, alignment, indent, spacing, default font, borders, suppress flag). Adding the new revision attrs without updating this hash would cause cross-doc cache bleed: two paragraphs with identical text and different pPrIns would share an entry. The hash MUST include:
pPrIns/pPrDelpresence (any non-null pulls a discriminator into the key — a struck/inserted pilcrow takes width).pPrChange/paraRPrChangepresence (margin change bar).- Any
revision_changemark on the paragraph's runs (subtle paint cue). sectPrChangepresence (gutter cue).
Equivalent updates apply to hashTableBlock if it exists (verify at implementation), and to whatever measures TableBlock.
Parser changes
Files:
packages/core/src/docx/paragraphParser/properties.ts— addparseParagraphMarkRevision(rPrEl)for<w:pPr><w:rPr><w:ins/>and<w:del/>. AddparseParagraphMarkRPrChange(rPrEl)forCT_ParaRPrChange(NOT the run rPrChange path).packages/core/src/docx/sectionParser.tsorpackages/core/src/docx/documentParser.ts(locate the existing section parse; the spec file name was a guess — verify at implementation) — parse<w:sectPrChange>from bothpPr/sectPrand body-levelsectPr.packages/core/src/docx/paragraphParser/content.ts— already parsespPrChange; ensure it flows to the modelParagraph.propertyChangesarray.packages/core/src/docx/runParser.ts— already parses runrPrChange; ensure it flows.packages/core/src/docx/tableParser.ts— wire the already-parsedtrPrChange,cellIns,cellDel,cellMerge,tcPrChange,tblPrChange,tblGridChangeto the model. Add reads fortrPr/ins,trPr/del. MovetblPrExChangefrom table-level to row-level model storage (existing parse may need relocation).
No new parser categories — most of the work is surfacing what's already read.
Conversion changes
packages/core/src/prosemirror/conversion/toProseDoc/:
paragraph.ts— pass throughpPrIns,pPrDel,pPrChange[],paraRPrChange[],sectPrChangefrom the modelParagraphto PM node attrs.table.ts— pass through every table revision attr to PM nodes.run.ts— wrap runs withrPrChangein therevision_changemark; canonicalizeprior.
packages/core/src/prosemirror/conversion/fromProseDoc/ — inverse, including the array-shaped attrs.
packages/core/src/prosemirror/utils/extractTrackedChanges.ts — must be extended to traverse node attrs in addition to text marks, so the sidebar shows structural revisions.
Serializer changes
packages/core/src/docx/serializer/paragraphSerializer.ts — emit <w:pPr><w:rPr><w:ins/> / <w:del/> (in the schema-mandated first position inside the rPr) when pPrIns / pPrDel is set. Emit paraRPrChange as the last child of <w:pPr><w:rPr>. Existing pPrChange serialization stays; verify it goes at the end of <w:pPr> per the ordering rule above.
packages/core/src/docx/serializer/runSerializer.ts (or wherever run rPr is emitted) — rPrChange must be the last child of <w:rPr>. Verify and fix if not.
packages/core/src/docx/tableSerializer.ts — emit the new attrs back to OOXML. Emit <w:cellMerge w:vMerge="rest|cont"/> (NOT w:val). Emit <w:tblGridChange w:id="…"/> with id only, no author/date. Move tblPrExChange emission to per-row position.
Section emission lives in documentSerializer.ts / paragraphSerializer.ts (the spec file sectionSerializer.ts does not currently exist; this is an edit, not a creation, unless we refactor). Emit <w:sectPrChange> as the last child of <w:sectPr> in whichever placement (pPr/sectPr or body-level) the source used.
Suggesting-mode keymap
packages/core/src/prosemirror/plugins/suggestionMode.ts currently:
handleTextInputwraps typed text ininsertionmark.handleKeyDownintercepts Backspace / Delete to wrap deletion mark instead of actually deleting.- Block-boundary cases fall through.
- Has no Enter handler.
The existing Enter is splitBlockClearBorders in BaseKeymapExtension.ts:96-119,216,223 — it copies paragraph style attrs into the new paragraph and clears borders. The suggesting-mode Enter handler MUST compose with this, not replace it: call splitBlockClearBorders first, then post-process the resulting split to set pPrIns on the first paragraph. Otherwise paragraph-style inheritance regresses on every suggesting-mode split.
Plugin keymap fires before extension keymap; we use that ordering deliberately, but the plugin handler must invoke the extension command synchronously rather than dispatching independently.
New behavior:
- Enter at non-empty selection inside a paragraph — invoke
splitBlockClearBorders, then in the same transaction setpPrIns: { revisionId, author, date }on the first of the two resulting paragraphs. Normative: the first paragraph (P1) receives the marker. The second (P2) is untouched. - Enter while selection covers content — wrap covered content in
deletionmark (existing behavior), then apply rule 1. - Enter in an empty paragraph — same as rule 1; the new (empty) paragraph below gets nothing, the original (now also empty) carries
pPrIns. - Backspace at paragraph start (collapsed, non-first paragraph) — instead of joining, set
pPrDelon the previous paragraph (whose mark is being eaten). The actual join is deferred to accept. The PM transaction is a pure attr update; the cursor lands at the end of the previous paragraph. - Delete at paragraph end (collapsed, non-last paragraph) — symmetrical, set
pPrDelon the current paragraph. - Selection spans paragraph boundary then user presses any deletion key — wrap inline content with
deletion, mark any fully-covered paragraph marks withpPrDel. Cursor lands at the original selection'sfrom. - Backspace at the first paragraph start — no-op (no previous paragraph to mark).
- Wrap commands for table operations —
addRow,deleteRow,addColumn,deleteColumn,mergeCells(vertical only — horizontal merge tracking is via per-cell ins/del),splitCellsget suggesting-aware variants. These live inpackages/core/src/prosemirror/extensions/nodes/TableExtension.ts. - Paragraph-property commands — snapshot prior
pPron first edit per (id, author, date) session; on subsequent edits in the same session, do not overwrite the snapshot. After each edit, if all snapshotted fields equal current values, clearpPrChange. - Run-property commands — snapshot prior
rProf each affected run as arevision_changemark; same session/clear-on-equal semantics. - Table / section property commands — analogous to (9).
The snapshot-on-property-edit machinery is not an existing dispatcher; it's new infrastructure. Phase 1 lands it as a small withSuggestingSnapshot(commandImpl, snapshotter) helper in packages/core/src/prosemirror/plugins/suggestionMode.ts, applied at each property-command site.
Accept / Reject command surface
packages/core/src/prosemirror/commands/comments.ts houses the existing acceptChange(from, to) / rejectChange(from, to). We add:
acceptChangeById(revisionId: number)— resolves the revision in node attrs or marks, grouped by(id, author, date)triple, applies the per-marker semantic from the table above in a single PM transaction (one undo step).rejectChangeById(revisionId: number)— symmetric.acceptAll()/rejectAll()— extended to walk node attrs as well as marks; returns the count of distinct(id, author, date)triples resolved, not the count of marker sites.acceptChangesInRange(from, to)/rejectChangesInRange(from, to)— range-scoped variants (standard track-changes UX).
Mechanics:
- For
pPrInsaccept: clear the attr. - For
pPrInsreject: clear the attr and join with the following paragraph. The resulting paragraph inherits the second paragraph'spPr. Uses the existing PMjoinForwardwith the suggesting-mode keymap temporarily bypassed. - For
pPrInsreject on the last paragraph: clear attr only, log diagnostic, returntrue. - For
pPrDelaccept: join with following; resulting paragraph inherits second'spPr. Clear attr. - For
pPrDelaccept on first paragraph (rare but legal): clear attr, log diagnostic, returntrue. - For
trDelaccept on the only row of a table: remove the table node. - For
*Changeaccept: clear the attr. - For
*Changereject: restore the prior properties on the node/run/mark, then clear the attr. Restoration usessetNodeMarkupfor nodes; for marks, replace the marked range with the same content under a re-derived rPr. - For
cellMerge(vertical) accept: rebuild the affected cell'svMergeper OOXML to apply the merge. - For
cellMergereject: clear the marker.
Resolution order in acceptAll / rejectAll: inline run-level → run-property marks → paragraph-property → paragraph-mark → cell-level → row-level → table-level → section-level. Inner-to-outer ensures structural revisions resolve after the content they contain.
Idempotence: acceptChangeById / rejectChangeById on a revisionId not found in the document return false (no-op). This includes double-accept of an already-resolved id.
Cross-revision dependencies: Rejecting a pPrIns on a paragraph that also carries pPrChange joins the paragraph with the next, destroying the host. The implementation MUST first reject the pPrChange (restore prior) if and only if the pPrIns reject would remove the paragraph. Otherwise inner revisions are preserved on the surviving paragraph.
Painter cues
packages/core/src/layout-painter/renderParagraph.ts and renderTable.ts are the canonical painter (per CLAUDE.md "Key file map"); both React and Vue inherit from this. Painter edits go here, not in packages/react/src/layout-painter/:
renderParagraph.ts— ifpPrInsset, append<span class="ep-revision-pilcrow ep-revision-ins" data-revision-id="…">¶</span>. IfpPrDel, same with classep-revision-del(strikethrough via CSS). IfpPrChange(any in the array), emit a margin change bar. IfsectPrChange, gutter cue.renderTable.ts— fortrIns/trDel/ cellMarker variants, paint colored borders and change bar. ForcellMerge(vertical), render as if merged but with a dashed boundary at the would-be cell edge until accepted.- All revision DOM carries
data-revision-idplusdata-revision-authoranddata-revision-dateso the sidebar grouping can use the(id, author, date)triple without re-reading PM state.
renderRun.ts (or wherever run-level rendering lives) — revision_change mark renders with class ep-revision-change (subtle wavy underline or background tint; exact CSS in editor.css).
packages/vue/src/composables/useDocxEditor.ts — verify it picks up painter output transparently (it should, via the shared layout-painter). The Vue adapter's only change for this feature is wiring the new data-revision-id events to the sidebar.
FlowBlock measurement
FlowBlock invariant holds — no new variants. ParagraphBlock.attrs (the contract consumed by hashParagraphBlock and measureBlock) needs the new revision-presence flags plumbed through toFlowBlocks. This is a small but easy-to-miss task: without it, the cache and measurement are inconsistent.
Review sidebar
packages/react/src/components/UnifiedSidebar.tsx and packages/react/src/hooks/useCommentSidebarItems.tsx already render both comments and tracked-change entries (TrackedChangeEntry). Extend TrackedChangeEntry to carry structural revisions and update extractTrackedChanges to walk node attrs in addition to text marks. Each entry shows:
- Author (display from
w:author) and date. - Revision-kind label (i18n key — see below).
- A short human description ("Inserted paragraph", "Deleted row 4", "Changed alignment from Left to Right").
- Click → scroll-to-block via
data-revision-id. - Accept / Reject buttons →
acceptChangeById/rejectChangeById.
The sidebar groups entries by the (id, author, date) triple (not bare id) so cross-author id collisions are not silently merged. Multi-site revisions (a row + its cells under one (id, author, date)) render as a single entry.
comments-sidebar.spec.ts is a required regression for every phase (CLAUDE.md lists it as gated on the files this change touches).
Vue parity
Painter changes land in packages/core/src/layout-painter/ and inherit into Vue. Suggestion-mode keymap is in packages/core/src/prosemirror/plugins/, framework-agnostic. The only Vue-specific work is sidebar wiring in packages/vue/src/, mirroring whatever the React sidebar does.
The parity contract (scripts/parity/parity.contract.json) is updated when acceptChangeById / rejectChangeById / acceptChangesInRange / rejectChangesInRange are added to the public DocxEditorRef.
Public API impact
New public exports on DocxEditorRef:
acceptChangeById(revisionId: number): booleanrejectChangeById(revisionId: number): booleanacceptChangesInRange(from: number, to: number): numberrejectChangesInRange(from: number, to: number): number
acceptChange / rejectChange / acceptAll / rejectAll signatures unchanged but behavior extended (additive).
Public @public types receiving new fields (per docs/api/docx-editor-core/prosemirror-schema.api.md): ParagraphAttrs, TableAttrs, TableRowAttrs, TableCellAttrs. Each new attr is a public-API addition. Snapshot regen via bun run api:extract after each PR.
Agents package (packages/agents/src/changes.ts): existing acceptChange(body, id: number) operates on the parsed Document model. Phase 1 extends it to handle structural-revision fields on Paragraph and the new cell/row revision fields, or explicitly defers with a tracking issue. The chosen course is documented in tasks.md.
Test plan
Per phase (see tasks.md):
- OOXML round-trip fixture tests (
packages/core/src/docx/__tests__/fixtures/tracked-structural/) — minimal hand-crafted DOCX per revision type, parse → assert model fields → serialize → assert semantically equivalent XML (canonical comparison: same element, same attribute values; attribute order and namespace prefix ignored). Helper:assertOoxmlEquivalent(a, b). - Conversion unit tests (
packages/core/src/__tests__/conversion/) —Document → PM → Document, deep-equal. Per-marker, including edge cases. - Property-based test — generate N random tracked-change ops, accept-all should equal applying ops without tracking; reject-all should leave document unchanged.
- Playwright (
packages/react/src/__tests__/playwright/tracked-changes-structural.spec.ts) — Enter splits in suggesting mode producespPrIns; Backspace/Delete producepPrDel; accept clears; reject reverses. Table ops. Property edits. Edge cases (last paragraph, single-row table). comments-sidebar.spec.ts— required regression every phase.- Painter screenshot tests — visual regression for change bars, pilcrows, row/cell strikes.
bun run typecheck && bun run i18n:validate && bun run check:parity-contract && bun run api:check— green every phase.
Phasing
Three landable PRs. Phase 1 lands the snapshot-and-restore infrastructure even though it ships only paragraph-mark + section revisions, so Phases 2 and 3 are independent:
- Phase 1 — paragraph-mark (
pPrIns/pPrDel),sectPrChange, snapshot/restore infra, all 15 i18n key stubs, cache-key update,extractTrackedChangesextension, agents-package extension or defer note. Closes #614. - Phase 2 — tables (rows, cells, merges, property changes).
- Phase 3 — run-level
revisionChangemark,pPrChange+paraRPrChangeend-to-end (parse, convert, serialize, accept/reject, painter), per-property command snapshotting wired site-by-site.
Open questions / risks
- Selection mapping across
pPrDel— PM owns selection. The "deferred-join boundary feels joined" UX needs either a NodeView wrapper that hides the boundary from PM's selection traversal, or accepting that arrow keys cross it like a normal boundary. Recommend the latter for Phase 1, with a follow-up issue to evaluate NodeView. Find/replace,Mod-A, comment anchors all assume real PM positions; trying to virtualize is high-risk. - Collab branch (
feat/controlled-comments-collab) — this design is single-user. Structural-attr edits need step-level conflict semantics for two-author overlap. Coordinate with that branch before Phase 1 lands or accept a non-goal of collab support; recommend the latter and a tracking issue. w:authorId— Word sometimes writes a stable author id alongside the display name. We do not model it; sidebar dedup is by display name only. Two distinct users sharing "Jane" merge in the sidebar. Acceptable trade-off; document as a known limitation.- Move tracking (Tier 2) — parsed move runs are already preserved at the inline level. Phase 1 must not regress: ensure
EG_ParaRPrTrackChangesordering allowspPrInsandpPrMoveFrom/pPrMoveToto coexist on the same paragraph mark in the serializer (perwml.xsd:1844, the schema permits this). pPrChange.priorshape vs_originalFormattingshape — both storeParagraphFormatting-like data. Define the canonical shape used by each at implementation time; do not let them silently diverge.
Key files
| Layer | File | Change |
|---|---|---|
| Schema | packages/core/src/prosemirror/schema/nodes.ts |
Add revision attrs to paragraph, table, table_row, table_cell |
| Schema | packages/core/src/prosemirror/extensions/marks/TrackedChangeExtensions.ts |
Add revision_change mark (run only) + human descriptions |
| Parser | packages/core/src/docx/paragraphParser/properties.ts |
Read pPr/rPr/ins, pPr/rPr/del, CT_ParaRPrChange |
| Parser | packages/core/src/docx/documentParser.ts or sectionParser.ts if it exists |
Read sectPrChange (both pPr-level and body-level) |
| Parser | packages/core/src/docx/tableParser.ts |
Wire existing table revision parses; add trPr/ins, trPr/del; relocate tblPrExChange to row |
| Cache | packages/core/src/layout-bridge/measuring/cache.ts |
Add revision attrs to hashParagraphBlock / hashTableBlock |
| Conversion | packages/core/src/prosemirror/conversion/toProseDoc/paragraph.ts |
Map paragraph revisions to attrs |
| Conversion | packages/core/src/prosemirror/conversion/toProseDoc/table.ts |
Map table revisions to attrs |
| Conversion | packages/core/src/prosemirror/conversion/toProseDoc/run.ts |
Map run rPrChange to mark with canonicalized prior |
| Conversion | packages/core/src/prosemirror/conversion/fromProseDoc/* |
Inverse |
| Conversion | packages/core/src/prosemirror/utils/extractTrackedChanges.ts |
Walk node attrs in addition to marks |
| Layout | packages/core/src/layout-engine/toFlowBlocks* |
Plumb new attrs into ParagraphBlock.attrs |
| Serializer | packages/core/src/docx/serializer/paragraphSerializer.ts |
Emit pPr/rPr/ins/del first; emit paraRPrChange last; enforce ordering |
| Serializer | packages/core/src/docx/serializer/runSerializer.ts (or wherever) |
Enforce rPrChange last in rPr |
| Serializer | packages/core/src/docx/tableSerializer.ts |
Emit row/cell/table revisions; cellMerge uses vMerge (no val); tblGridChange id-only |
| Serializer | packages/core/src/docx/serializer/documentSerializer.ts (or paragraphSerializer.ts) |
Emit sectPrChange at both pPr-level and body-level placements |
| Plugin | packages/core/src/prosemirror/plugins/suggestionMode.ts |
Block-boundary keymap composing with splitBlockClearBorders; snapshot-on-property-edit helper |
| Commands | packages/core/src/prosemirror/commands/comments.ts |
Add acceptChangeById, rejectChangeById, acceptChangesInRange, rejectChangesInRange; extend accept/reject all |
| Commands | packages/core/src/prosemirror/extensions/nodes/TableExtension.ts |
Suggesting-aware row/column/merge commands |
| Painter | packages/core/src/layout-painter/renderParagraph.ts |
Pilcrow + change bar |
| Painter | packages/core/src/layout-painter/renderTable.ts |
Row/cell cues; dashed cellMerge boundary |
| Painter | packages/core/src/layout-painter/renderRun.ts (or equivalent) |
revision_change mark cue |
| Sidebar (React) | packages/react/src/components/UnifiedSidebar.tsx and useCommentSidebarItems.tsx |
Group by (id, author, date) triple; structural revision items |
| Sidebar (Vue) | packages/vue/src/components/UnifiedSidebar.vue (or equivalent) |
Mirror |
| Agents | packages/agents/src/changes.ts |
Extend or defer (decision in tasks.md) |
| i18n | packages/i18n/en.json + all 6 sibling locales |
15 keys (stubs in Phase 1; values land per phase) |
| Parity | scripts/parity/parity.contract.json |
New DocxEditorRef methods |
| API | docs/api/*.api.md |
Snapshot regen per phase |
| Tests | packages/core/src/docx/__tests__/fixtures/tracked-structural/ |
Per-marker fixtures |
| Tests | packages/react/src/__tests__/playwright/tracked-changes-structural.spec.ts |
Per-phase end-to-end |