fix #38: don't mis-attribute undo/redo as fresh human authorship in the preview

Undo in the editor rendered wrong marks in the F10 review preview: restored
baseline text (and reverted Claude text) was colored as human-authored. Root
cause — attributionController.onDidChange attributed every non-seam document
change to currentAuthor() (always human) and ignored e.reason, so an undo/redo
that re-inserts text created a fresh human span over it. renderReview is pure;
the wrong marks came from these false author spans.

Fix: on e.reason === Undo|Redo, reconcile span geometry but do NOT attribute the
re-inserted chars — an undo is history navigation, not authorship, so restored
text stays neutral (unattributed) rather than falsely claimed by the human.
applyChange gains an `attributeInserted` flag (default true; false on undo/redo);
seam matching is also skipped on undo/redo (the seam only applies forward edits).

Restored text becomes neutral rather than recovering its exact prior provenance
(e.g. undoing a deletion of Claude's text shows it unattributed, not blue) —
perfect restoration would need an attribution history stack synced to the editor
undo stack (fragile due to edit coalescing); filed mentally as a follow-up. The
neutral behavior removes the misleading marks, which is the reported defect.

Tests: E2E reproduces the mid-edit-undo case (buffer stays dirty so the disk-sync
guard doesn't mask it) and asserts restored text is unattributed; +3 unit tests
for the geometric-only applyChange path. 197 unit + 50 E2E green; typecheck clean.

Closes #38

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-06-12 03:37:22 -07:00
parent 21e889b0a3
commit 469c8c11fc
4 changed files with 131 additions and 13 deletions
+8 -1
View File
@@ -49,12 +49,19 @@ export function coalesce(spans: LiveSpan[]): LiveSpan[] {
* Apply one document edit (the half-open range [start,end) replaced by
* newLength chars) authored by `author`. Existing spans shift/split/clip;
* inserted chars become a new span of `author` (INV-7).
*
* `attributeInserted` (default true) controls whether inserted chars get a new
* span. Pass `false` for an UNDO/REDO change (#38): the geometry of existing
* spans is still reconciled, but re-inserted text is NOT freshly attributed —
* an undo is history navigation, not authorship, so restored text stays neutral
* rather than being falsely claimed by the current author.
*/
export function applyChange(
spans: LiveSpan[],
edit: TextEdit,
author: Provenance,
ctx: TrackerCtx,
attributeInserted = true,
): LiveSpan[] {
const delta = edit.newLength - (edit.end - edit.start);
const out: LiveSpan[] = [];
@@ -71,7 +78,7 @@ export function applyChange(
if (right) out.push(left ? { ...right, id: ctx.newId() } : right);
}
}
if (edit.newLength > 0) {
if (attributeInserted && edit.newLength > 0) {
out.push({
id: ctx.newId(),
start: edit.start,