Tracked changes
Suggestions your users can accept or reject, from people and agents alike.
These docs are for version 2.3.x. See the latest version.
Modes#
A document in suggesting mode records every change as a tracked suggestion
instead of applying it. This covers typing, toolbar commands, and agent tool
calls. An agent can draft in this mode, and the user reviews each change.
editor.view.setDocumentMode("suggesting");Set the starting mode per document, or editor-wide:
<ReviseEditor
defaultDocumentMode="suggesting"
initialDocuments={[{ id: "a", source: file, documentMode: "editing" }]}
/>defaultDocumentMode sets the starting mode only. Changing the prop later
does not affect a document that is already open. Use view.setDocumentMode()
for that.
The user can switch modes. To lock a participant into suggesting, use a
role instead. role="suggester" pins the mode,
refuses direct edits, and hides accept and reject everywhere.
Reviewing#
The review controller is the host API for tracked changes. It is not an
agent tool: it exposes interactive state and moves the caret and viewport.
const state = editor.review.getState();
// { open, targetCount, openSuggestionIds, currentSuggestionIds, ... }
editor.review.open();
editor.review.next();
editor.review.acceptCurrent();
editor.review.rejectCurrent();
editor.review.acceptAll();Subscribe to render your own review UI:
useEffect(() => editor.review.subscribe(setReviewState), [editor]);Targeted operations#
To act on specific suggestions, such as everything one agent run produced, pass their IDs:
const ids = editor.review.getOpenSuggestionIds();
editor.review.navigateToSuggestion(ids[0]);
editor.review.previewSuggestions(ids, "accept"); // preview only, no changes
editor.review.acceptSuggestions(ids); // { resolved, missing, unresolved }previewCurrent() and previewAll() do the same for the current stop and the
whole document. Pass null to clear a preview.
Display#
editor.review.setShowRemovals(false);
editor.review.setSuggestionViewMode("final"); // "all-markup" | "final" | "original""final" shows the document with every suggestion accepted. "original"
shows it with none accepted. Neither changes the document, so either works as
a read-only "clean copy" toggle.
Direct mode#
To apply an agent edit without tracking it, such as a formatting sweep or a
find-and-replace the user asked for, pass directMode per call:
await editor.tools.execute(
"style_blocks",
{ selectors: "*", attrs: [{ name: "fontFamily", value: "Georgia" }] },
{ directMode: true },
);In a delegated agent run, every tool call in the loop inherits the mode. One flag covers the whole task.
Direct mode skips tracked changes. Use it only for changes the user already approved in your UI.
Attribution#
Each suggestion records its author, and agent suggestions are marked as such.
review.getState().visibleAgentSuggestionIds lists only the agent's
suggestions, and nextAgentSuggestion() steps through only those. Use them to
review the agent's changes without the user's own edits.
Changes made through tools.execute() and agent runs are agent changes,
signed "Revise Agent". To sign them as your product's
assistant, name it:
<ReviseEditor agent={{ name: "Acme Assistant" }} />People's own edits use currentUser.
Export#
exportDocx() writes tracked changes as Word revision marks. A reviewer who
opens the file in Word sees the same suggestions, and accepting them there
gives the same result as accepting them in Revise.
Suggestions use the identity you pass as currentUser. Without it, human
suggestions export with the reviewer name "Anonymous" and agent
suggestions as "Revise Agent". The name is recorded at edit time, not at
export, so pass currentUser before anyone edits.
Word round trip#
DOCX import and export preserve revision marks. From a Word file, through the editor, and back:
| Word markup | Round trip |
|---|---|
w:ins / w:del, including nested insert-inside-delete | Preserved, as layered suggestions |
Run formatting changes (w:rPrChange) | Preserved, with the prior properties, so rejecting reverts |
Paragraph property changes (w:pPrChange) | Preserved, with the prior properties |
| Paragraph-mark (pilcrow) revisions | Preserved, as pending splits and merges |
| Tracked deletion of a section break | Preserved, both directions |
| Table rows, cells, and table properties | Preserved, as one resolvable change per table |
| Author, date, and Word revision ids | Preserved, both directions |
| Comments, threading, resolved state, anchors inside tracked runs | Preserved |
Tracked row insertions and deletions (w:trPr → w:ins / w:del), cell
revisions (w:cellIns, w:cellDel, w:cellMerge), and table property changes
(w:tblPrChange) import as resolvable suggestions. They export as the same
markup, with authors intact.
Linked moves (w:moveFrom / w:moveTo pairs) import as one move suggestion.
Accepting either half keeps the text at its destination. Rejecting either half
restores the original location. Export writes the paired move markup back. An
orphaned or mismatched half imports as an ordinary insertion or deletion, so
unrelated content is never resolved together.
Custom review panel#
listChanges() returns every pending change with the metadata a panel needs.
Use it to render the list yourself instead of using the built-in ribbon:
for (const change of editor.review.listChanges()) {
// { id, kind, author, authorType, createdAt, blockIds,
// insertedText, deletedText, description, agentModel,
// moveSourceBlockIds, moveDestinationBlockIds }
}
editor.review.getChange(id); // one row, or null once it is resolved| Field | Value |
|---|---|
kind | "insert", "delete", or "format" (as in Word), or "move" for a linked move pair |
author | A person's name, "Revise Agent", an external agent's name, or "Anonymous" when no identity was supplied. Imported Word redlines keep their original reviewer. |
blockIds | The blocks the change touches. navigateToSuggestion() scrolls to these. |
moveSourceBlockIds, moveDestinationBlockIds | Where moved text left and where it landed. deletedText and insertedText hold the text at each end. |
A replacement made in one operation, such as the agent's replace tool, is
one change with both deletedText and insertedText set. Its kind is the
one it was recorded with, usually "delete", so check for both texts to show it
as a replacement. One accept or reject settles both halves. A move is also one
change for both halves, and one accept or reject settles both locations.
Wire a row to the controller with the ID:
const verb =
change.kind === "move"
? "moved"
: change.kind === "format"
? "reformatted"
: change.deletedText && change.insertedText
? "replaced"
: change.kind === "delete"
? "removed"
: "added";
<li onClick={() => editor.review.navigateToSuggestion(change.id)}>
<b>{change.author}</b> {verb}{" "}
<q>{change.deletedText ?? change.insertedText}</q>
<button onClick={() => editor.review.acceptSuggestions([change.id])}>
Accept
</button>
</li>listChanges() is not a subscription. It computes the list from the
document on each call, in one pass. Call it when you render, for example
after review.subscribe() reports that the counts changed.