469c8c11fc
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>
93 lines
3.1 KiB
TypeScript
93 lines
3.1 KiB
TypeScript
/**
|
|
* AttributionTracker — pure span algebra over offset ranges (spec §6.2/§6.5).
|
|
* Char-honest (INV-7): attributes exactly the characters each author produced —
|
|
* split character-precisely, coalesce only adjacent same-author/same-turn
|
|
* spans. vscode-free; the controller converts vscode change events to TextEdit.
|
|
*/
|
|
import type { TextEdit } from "./anchorer";
|
|
import type { Provenance } from "./model";
|
|
|
|
export interface LiveSpan {
|
|
id: string;
|
|
/** half-open [start, end) character offsets in the live document. */
|
|
start: number;
|
|
end: number;
|
|
author: Provenance;
|
|
turnId?: string;
|
|
/** ISO-8601; survives splits/merges (earliest wins on merge). */
|
|
createdAt: string;
|
|
}
|
|
|
|
/** Injected effects so the algebra stays pure and deterministic in tests. */
|
|
export interface TrackerCtx {
|
|
newId: () => string;
|
|
now: () => string;
|
|
turnId?: string;
|
|
}
|
|
|
|
function sameAuthor(a: LiveSpan, b: LiveSpan): boolean {
|
|
return JSON.stringify(a.author) === JSON.stringify(b.author) && a.turnId === b.turnId;
|
|
}
|
|
|
|
/** Merge adjacent same-author/same-turn spans; drop empties; sort by start. */
|
|
export function coalesce(spans: LiveSpan[]): LiveSpan[] {
|
|
const sorted = spans.filter((s) => s.end > s.start).sort((a, b) => a.start - b.start);
|
|
const out: LiveSpan[] = [];
|
|
for (const s of sorted) {
|
|
const last = out[out.length - 1];
|
|
if (last && last.end === s.start && sameAuthor(last, s)) {
|
|
last.end = s.end;
|
|
if (s.createdAt < last.createdAt) last.createdAt = s.createdAt;
|
|
} else {
|
|
out.push({ ...s });
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* 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[] = [];
|
|
for (const s of spans) {
|
|
if (s.end <= edit.start) {
|
|
out.push({ ...s });
|
|
} else if (s.start >= edit.end) {
|
|
out.push({ ...s, start: s.start + delta, end: s.end + delta });
|
|
} else {
|
|
const left = s.start < edit.start ? { ...s, end: edit.start } : null;
|
|
const right =
|
|
s.end > edit.end ? { ...s, start: edit.end + delta, end: s.end + delta } : null;
|
|
if (left) out.push(left);
|
|
if (right) out.push(left ? { ...right, id: ctx.newId() } : right);
|
|
}
|
|
}
|
|
if (attributeInserted && edit.newLength > 0) {
|
|
out.push({
|
|
id: ctx.newId(),
|
|
start: edit.start,
|
|
end: edit.start + edit.newLength,
|
|
author,
|
|
turnId: ctx.turnId,
|
|
createdAt: ctx.now(),
|
|
});
|
|
}
|
|
return coalesce(out);
|
|
}
|