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
+19
View File
@@ -107,6 +107,25 @@ describe("multi-edit sequences", () => {
});
});
describe("applyChange — geometric-only (undo/redo, #38)", () => {
it("attributeInserted=false adds NO span for re-inserted text (restored text stays neutral)", () => {
// Undo re-inserts 'bravo ' at offset 6 into an empty span list → no span.
const r = applyChange([], { start: 6, end: 6, newLength: 6 }, HUMAN, ctx(), false);
expect(r).toEqual([]);
});
it("attributeInserted=false still SHIFTS existing spans by the edit delta", () => {
// A real human span sits after the re-insert point; it must shift right by 6,
// but the re-inserted chars themselves get no new span.
const r = applyChange([span("tail", 20, 30, HUMAN)], { start: 6, end: 6, newLength: 6 }, HUMAN, ctx(), false);
expect(ranges(r)).toEqual([[26, 36]]);
expect(authors(r)).toEqual(["human"]);
});
it("attributeInserted=false still reconciles geometry of a deletion (removes a covered span)", () => {
const r = applyChange([span("a", 3, 6, AGENT)], { start: 0, end: 10, newLength: 0 }, HUMAN, ctx(), false);
expect(r).toEqual([]);
});
});
describe("coalesce", () => {
it("merges adjacent same-author same-turn spans, keeps earliest createdAt", () => {
const a = { ...span("a", 0, 3, HUMAN), createdAt: "2026-06-09T00:00:00.000Z" };