#48: pinning the baseline leaves the review panel fully un-annotated #52
@@ -441,6 +441,14 @@ function wordMergedMarkdown(beforeRaw: string, afterRaw: string): string {
|
|||||||
export interface RenderOptions {
|
export interface RenderOptions {
|
||||||
/** Per-block markdown→HTML renderer (test seam). Defaults to the bundled markdown-it. */
|
/** Per-block markdown→HTML renderer (test seam). Defaults to the bundled markdown-it. */
|
||||||
render?: (src: string) => string;
|
render?: (src: string) => string;
|
||||||
|
/**
|
||||||
|
* #48: the baseline was just PINNED (`reason === "pinned"`). With zero changes
|
||||||
|
* since that pin, the on-render is fully clean — no authorship coloring — so a
|
||||||
|
* pin reads as "this is my clean starting point". Distinct from a baseline
|
||||||
|
* advanced by a machine-landing (accept), which keeps its authorship coloring
|
||||||
|
* (F10 INV-33). Only consulted by `renderReview`.
|
||||||
|
*/
|
||||||
|
pinned?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultRender(src: string): string {
|
function defaultRender(src: string): string {
|
||||||
@@ -673,6 +681,14 @@ export function renderReview(
|
|||||||
const render = opts.render ?? defaultRender;
|
const render = opts.render ?? defaultRender;
|
||||||
const ranges = splitBlocksWithRanges(currentText);
|
const ranges = splitBlocksWithRanges(currentText);
|
||||||
const ops = diffBlocks(baselineText, currentText);
|
const ops = diffBlocks(baselineText, currentText);
|
||||||
|
// #48: right after a PIN (baseline reason "pinned") with no changes since, the
|
||||||
|
// panel is fully clean: no change marks (already absent) AND no authorship
|
||||||
|
// coloring, so the pin reads as "this is my clean starting point". Skip
|
||||||
|
// colorByAuthor for every block in this case; data-src mapping and any pending
|
||||||
|
// proposals (review actions, not annotations) are kept below. A baseline advanced
|
||||||
|
// by a machine-landing (accept) is ALSO zero-diff but is NOT pinned — it keeps
|
||||||
|
// its authorship coloring (F10 INV-33), so this is gated on the pin specifically.
|
||||||
|
const clean = opts.pinned === true && ops.every((o) => o.kind === "unchanged");
|
||||||
|
|
||||||
// Associate each resolved proposal with the current-side block index whose range
|
// Associate each resolved proposal with the current-side block index whose range
|
||||||
// it anchors into: the largest block with start <= anchorStart (the containing
|
// it anchors into: the largest block with start <= anchorStart (the containing
|
||||||
@@ -703,7 +719,7 @@ export function renderReview(
|
|||||||
const blockIndex = op.kind === "removed" ? -1 : ci;
|
const blockIndex = op.kind === "removed" ? -1 : ci;
|
||||||
const blk = op.kind === "removed" ? undefined : ranges[ci++];
|
const blk = op.kind === "removed" ? undefined : ranges[ci++];
|
||||||
const colored = (raw: string): string =>
|
const colored = (raw: string): string =>
|
||||||
blk ? colorByAuthor(raw, blk.start, authorSpans, render) : render(raw);
|
blk && !clean ? colorByAuthor(raw, blk.start, authorSpans, render) : render(raw);
|
||||||
bodyParts.push(renderReviewOp(op, render, colored, srcAttr(blk)));
|
bodyParts.push(renderReviewOp(op, render, colored, srcAttr(blk)));
|
||||||
const here = blockIndex >= 0 ? byBlock.get(blockIndex) : undefined;
|
const here = blockIndex >= 0 ? byBlock.get(blockIndex) : undefined;
|
||||||
if (here) for (const p of here) bodyParts.push(proposalBlockHtml(p, render));
|
if (here) for (const p of here) bodyParts.push(proposalBlockHtml(p, render));
|
||||||
|
|||||||
@@ -337,7 +337,7 @@ export class TrackChangesPreviewController implements vscode.Disposable {
|
|||||||
void panel.webview.postMessage({
|
void panel.webview.postMessage({
|
||||||
type: "render",
|
type: "render",
|
||||||
mode,
|
mode,
|
||||||
html: renderReview(baselineText, current, spans, proposals),
|
html: renderReview(baselineText, current, spans, proposals, { pinned: baseline?.reason === "pinned" }),
|
||||||
epoch: this.epochLabel(baseline),
|
epoch: this.epochLabel(baseline),
|
||||||
summary,
|
summary,
|
||||||
authorable,
|
authorable,
|
||||||
@@ -464,6 +464,7 @@ export class TrackChangesPreviewController implements vscode.Disposable {
|
|||||||
current,
|
current,
|
||||||
this.attribution.spansFor(doc),
|
this.attribution.spansFor(doc),
|
||||||
this.proposals.listProposals(doc),
|
this.proposals.listProposals(doc),
|
||||||
|
{ pinned: baseline?.reason === "pinned" },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
/** F10: current annotations mode for a panel (default on). */
|
/** F10: current annotations mode for a panel (default on). */
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import * as assert from "assert";
|
||||||
|
import * as fs from "fs";
|
||||||
|
import * as path from "path";
|
||||||
|
import * as vscode from "vscode";
|
||||||
|
import type { CowritingApi } from "../../../src/extension";
|
||||||
|
|
||||||
|
const WS = process.env.E2E_WORKSPACE!;
|
||||||
|
const settle = () => new Promise((r) => setTimeout(r, 400));
|
||||||
|
|
||||||
|
async function getApi(): Promise<CowritingApi> {
|
||||||
|
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
|
||||||
|
const api = (await ext.activate()) as CowritingApi;
|
||||||
|
assert.ok(api?.trackChangesPreviewController, "exports preview controller");
|
||||||
|
return api;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDocument; key: string }> {
|
||||||
|
const abs = path.join(WS, rel);
|
||||||
|
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||||
|
fs.writeFileSync(abs, body, "utf8");
|
||||||
|
const uri = vscode.Uri.file(abs);
|
||||||
|
const doc = await vscode.workspace.openTextDocument(uri);
|
||||||
|
await vscode.window.showTextDocument(doc);
|
||||||
|
await settle();
|
||||||
|
return { doc, key: uri.toString() };
|
||||||
|
}
|
||||||
|
|
||||||
|
// #48 host E2E (no LLM): pinning the baseline leaves the review panel fully clean
|
||||||
|
// — no authorship coloring on unchanged blocks — while re-divergence brings the
|
||||||
|
// annotations back. The author colors are read from the on-state renderReview HTML.
|
||||||
|
suite("S48 — pin → fully clean review panel (host E2E, no LLM)", () => {
|
||||||
|
test("pin clears authorship coloring; a later edit brings annotations back", async () => {
|
||||||
|
const { doc, key } = await freshDoc("docs/s48pin.md", "# S48\n\nAn original baseline paragraph.\n");
|
||||||
|
const api = await getApi();
|
||||||
|
const ctl = api.trackChangesPreviewController;
|
||||||
|
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
// Type a paragraph → a human attribution span + author coloring in the on-state.
|
||||||
|
const edit = new vscode.WorkspaceEdit();
|
||||||
|
edit.insert(doc.uri, doc.positionAt(doc.getText().length), "\n\nA freshly typed human paragraph.\n");
|
||||||
|
assert.ok(await vscode.workspace.applyEdit(edit), "operator edit applied");
|
||||||
|
await settle();
|
||||||
|
assert.match(ctl.renderHtmlFor(key), /cw-by-human/, "typed text is author-colored before the pin");
|
||||||
|
|
||||||
|
// Pin the baseline → zero diff → the panel must be fully clean.
|
||||||
|
ctl.receiveMessage(key, { type: "pinBaseline" });
|
||||||
|
await settle();
|
||||||
|
const pinned = ctl.renderHtmlFor(key);
|
||||||
|
assert.ok(!pinned.includes("cw-by-human"), "no human authorship coloring after pin");
|
||||||
|
assert.ok(!pinned.includes("cw-by-claude"), "no Claude authorship coloring after pin");
|
||||||
|
assert.ok(!pinned.includes("cw-add") && !pinned.includes("cw-del"), "no change marks after pin");
|
||||||
|
assert.match(pinned, /data-src-start/, "blocks still carry data-src offsets (INV-36 mapping kept)");
|
||||||
|
assert.ok(pinned.includes("freshly typed human paragraph"), "the body text is still rendered, just plain");
|
||||||
|
|
||||||
|
// Edit again → there are changes since the pinned baseline → annotations return.
|
||||||
|
const edit2 = new vscode.WorkspaceEdit();
|
||||||
|
edit2.insert(doc.uri, doc.positionAt(doc.getText().length), "\n\nA second typed paragraph diverges again.\n");
|
||||||
|
assert.ok(await vscode.workspace.applyEdit(edit2), "second operator edit applied");
|
||||||
|
await settle();
|
||||||
|
assert.match(ctl.renderHtmlFor(key), /cw-by-human/, "authorship coloring returns once the doc diverges from the pin");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -277,6 +277,54 @@ describe("renderReview", () => {
|
|||||||
expect(uIdx).toBeGreaterThan(html.indexOf("Beta there")); // unanchored still trails the body
|
expect(uIdx).toBeGreaterThan(html.indexOf("Beta there")); // unanchored still trails the body
|
||||||
expect(html).toContain("cw-proposal-unanchored");
|
expect(html).toContain("cw-proposal-unanchored");
|
||||||
});
|
});
|
||||||
|
// #48: a PINNED baseline with zero changes leaves the panel fully un-annotated —
|
||||||
|
// no authorship coloring on unchanged blocks — while pending proposals still show.
|
||||||
|
test("renderReview: pinned + zero diff with author spans renders NO authorship coloring", () => {
|
||||||
|
const doc = "Human wrote this.\n\nClaude wrote that.";
|
||||||
|
const spans: AuthorSpan[] = [
|
||||||
|
{ start: 0, end: 17, author: "human" },
|
||||||
|
{ start: 19, end: doc.length, author: "claude" },
|
||||||
|
];
|
||||||
|
const html = renderReview(doc, doc, spans, [], { pinned: true });
|
||||||
|
expect(html).not.toContain("cw-by-human");
|
||||||
|
expect(html).not.toContain("cw-by-claude");
|
||||||
|
expect(html).not.toContain("cw-add");
|
||||||
|
expect(html).not.toContain("cw-del");
|
||||||
|
// still a selection→source surface (INV-36): blocks carry data-src offsets.
|
||||||
|
expect(html).toContain("data-src-start");
|
||||||
|
// the body text is still there, just plain.
|
||||||
|
expect(html).toContain("Human wrote this.");
|
||||||
|
expect(html).toContain("Claude wrote that.");
|
||||||
|
});
|
||||||
|
test("renderReview: pinned + zero diff still renders a pending proposal block", () => {
|
||||||
|
const doc = "Human wrote this.\n\nClaude wrote that.";
|
||||||
|
const spans: AuthorSpan[] = [{ start: 0, end: 17, author: "human" }];
|
||||||
|
const proposals: ProposalView[] = [
|
||||||
|
{ id: "p1", anchorStart: 0, anchorEnd: 5, replaced: "Human", replacement: "Person" },
|
||||||
|
];
|
||||||
|
const html = renderReview(doc, doc, spans, proposals, { pinned: true });
|
||||||
|
expect(html).not.toContain("cw-by-human"); // body still clean
|
||||||
|
expect(html).toContain('data-proposal-id="p1"'); // proposal still shows (it is an action)
|
||||||
|
expect(html).toContain("Person");
|
||||||
|
});
|
||||||
|
test("renderReview: zero diff WITHOUT a pin (e.g. machine-landing) keeps authorship coloring (INV-33)", () => {
|
||||||
|
// accepting a Claude edit advances the baseline (zero diff) but is NOT a pin —
|
||||||
|
// the landed author coloring must remain.
|
||||||
|
const doc = "Human wrote this.\n\nClaude wrote that.";
|
||||||
|
const spans: AuthorSpan[] = [{ start: 19, end: doc.length, author: "claude" }];
|
||||||
|
const html = renderReview(doc, doc, spans, []); // no pinned flag
|
||||||
|
expect(html).toContain("cw-by-claude");
|
||||||
|
});
|
||||||
|
test("renderReview: with REAL changes since a pin, author coloring returns", () => {
|
||||||
|
// a genuine added block is still author-colored even when pinned (only the
|
||||||
|
// zero-diff-after-pin state is clean).
|
||||||
|
const baseline = "Hello world";
|
||||||
|
const current = "Hello world\n\nHello world";
|
||||||
|
const spans: AuthorSpan[] = [{ start: 19, end: 24, author: "human" }];
|
||||||
|
const html = renderReview(baseline, current, spans, [], { pinned: true });
|
||||||
|
expect((html.match(/cw-by-human/g) ?? []).length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
test("renderReview is deterministic with mixed anchored/unanchored proposals", () => {
|
test("renderReview is deterministic with mixed anchored/unanchored proposals", () => {
|
||||||
const doc = "one two three";
|
const doc = "one two three";
|
||||||
const proposals: ProposalView[] = [
|
const proposals: ProposalView[] = [
|
||||||
|
|||||||
Reference in New Issue
Block a user