From bcacbf062d509f12f33a6dd694fcea3e2ff43139 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Fri, 12 Jun 2026 00:13:54 -0700 Subject: [PATCH] F10 SLICE-2: fix renderReview author-coloring for duplicate blocks; tidy renderReviewOp + escape proposal id (#29) Co-Authored-By: Claude Opus 4.8 (1M context) --- src/trackChangesModel.ts | 21 +++++++++++---------- test/trackChangesModel.test.ts | 12 ++++++++++++ 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/src/trackChangesModel.ts b/src/trackChangesModel.ts index 6c28726..0f92c97 100644 --- a/src/trackChangesModel.ts +++ b/src/trackChangesModel.ts @@ -437,7 +437,7 @@ function proposalBlockHtml(p: ProposalView, render: (src: string) => string): st `` + `` + ``; - return `
${actions}${before}${after}
`; + return `
${actions}${before}${after}
`; } function renderReviewOp( @@ -445,9 +445,9 @@ function renderReviewOp( render: (src: string) => string, colored: (raw: string) => string, ): string { - if (op.kind === "removed") return renderOp(op, render); - if (op.kind === "changed" && op.atomic) return renderOp(op, render); - if (op.kind === "changed") return renderOp(op, render); // word-merged / + // removed blocks and any changed block (atomic fences diffed whole; non-atomic prose + // word-merged) render via renderOp โ€” no author sentinels (deletions neutral, spec ยง6.7). + if (op.kind === "removed" || op.kind === "changed") return renderOp(op, render); return `
${colored(op.block.raw)}
`; } @@ -468,12 +468,13 @@ export function renderReview( const render = opts.render ?? defaultRender; const ranges = splitBlocksWithRanges(currentText); const ops = diffBlocks(baselineText, currentText); - const colored = (raw: string): string => { - const blk = ranges.find((r) => r.raw === raw); - if (!blk) return render(raw); - return colorByAuthor(raw, blk.start, authorSpans, render); - }; - const bodyParts = ops.map((op) => renderReviewOp(op, render, colored)); + let ci = 0; // pointer into ranges; advances for every op with a current-side block + const bodyParts = ops.map((op) => { + const blk = op.kind === "removed" ? undefined : ranges[ci++]; + const colored = (raw: string): string => + blk ? colorByAuthor(raw, blk.start, authorSpans, render) : render(raw); + return renderReviewOp(op, render, colored); + }); const anchored = proposals.filter((p) => p.anchorStart !== null); const unanchored = proposals.filter((p) => p.anchorStart === null); const proposalParts = [...anchored, ...unanchored].map((p) => proposalBlockHtml(p, render)); diff --git a/test/trackChangesModel.test.ts b/test/trackChangesModel.test.ts index 0a27195..55c6ef5 100644 --- a/test/trackChangesModel.test.ts +++ b/test/trackChangesModel.test.ts @@ -295,6 +295,18 @@ describe("renderReview", () => { expect(html).toContain("mermaid"); expect(html).not.toContain("cw-by-"); }); + test("renderReview: author-colors the correct block when two paragraphs are identical", () => { + // Two identical paragraphs; baseline has only the first, so the SECOND is an + // added block authored by human. Its span must color THAT block, not the first. + const baseline = "Hello world"; + const current = "Hello world\n\nHello world"; + // second "Hello world" starts at offset 13; "world" at 19..24 + const spans = [{ start: 19, end: 24, author: "human" as const }]; + const html = renderReview(baseline, current, spans, []); + // exactly one cw-by-human span (the added second block's "world"), not zero, not on the first. + const count = (html.match(/cw-by-human/g) ?? []).length; + expect(count).toBe(1); + }); }); import { renderTrackChanges as rtc2 } from "../src/trackChangesModel";