diff --git a/src/trackChangesModel.ts b/src/trackChangesModel.ts index 84dbfa8..57668e9 100644 --- a/src/trackChangesModel.ts +++ b/src/trackChangesModel.ts @@ -828,6 +828,17 @@ export function renderReview( return curOff + delta; }; + // F12/#64 (INV-49/50): blocks are split from `landedText`, so `blk.start` is a + // landedText offset — but `authorSpans` arrive in `currentText` coordinates. A + // colored block sitting AFTER a length-changing pending proposal would otherwise + // be mis-colored (the offsets diverge by the revert delta). Map the spans into + // landedText coordinates once so authorship coloring stays aligned. + const landedSpans: AuthorSpan[] = authorSpans.map((s) => ({ + ...s, + start: toLanded(s.start), + end: toLanded(s.end), + })); + // Associate each resolved proposal with the landedText block index whose range // it anchors into: the largest block with start <= anchorStart (the containing // block, or the nearest preceding block when the anchor sits in a gap). A @@ -857,7 +868,7 @@ export function renderReview( const blockIndex = op.kind === "removed" ? -1 : ci; const blk = op.kind === "removed" ? undefined : ranges[ci++]; const colored = (raw: string): string => - blk && !clean ? colorByAuthor(raw, blk.start, authorSpans, render) : render(raw); + blk && !clean ? colorByAuthor(raw, blk.start, landedSpans, render) : render(raw); bodyParts.push(renderReviewOp(op, render, colored, srcAttr(blk))); const here = blockIndex >= 0 ? byBlock.get(blockIndex) : undefined; if (here) for (const p of here) bodyParts.push(proposalBlockHtml(p, render)); diff --git a/test/trackChangesModel.test.ts b/test/trackChangesModel.test.ts index 800c590..85efc0d 100644 --- a/test/trackChangesModel.test.ts +++ b/test/trackChangesModel.test.ts @@ -392,6 +392,26 @@ describe("renderReview", () => { // the applied paragraph is NOT also emitted as a word-merged changed block expect(html).not.toContain("brown"); // no double-render of the change as a baseline diff }); + + test("renderReview keeps author coloring aligned on a block AFTER a length-changing pending proposal (INV-49/50)", () => { + // block 1 has a pending proposal whose applied text is MUCH LONGER than its + // original; block 2 carries a Claude authorship span (in currentText coords). + const baseline = "one short.\n\ntwo stable here.\n"; + const current = "one MUCH LONGER REPLACED TEXT.\n\ntwo stable here.\n"; + const proposals: ProposalView[] = [{ + id: "pr_1", + anchorStart: current.indexOf("one MUCH LONGER REPLACED TEXT."), + anchorEnd: current.indexOf("one MUCH LONGER REPLACED TEXT.") + "one MUCH LONGER REPLACED TEXT.".length, + replaced: "one short.", + replacement: "one MUCH LONGER REPLACED TEXT.", + original: "one short.", + }]; + const sStart = current.indexOf("stable"); + const authorSpans = [{ start: sStart, end: sStart + "stable".length, author: "claude" as const }]; + const html = renderReview(baseline, current, authorSpans, proposals); + // the Claude coloring wraps "stable" exactly — not shifted by the proposal's length delta. + expect(html).toMatch(/stable<\/span>/); + }); }); import { renderTrackChanges as rtc2 } from "../src/trackChangesModel";