Files
vscode-cowriting-plugin-con…/plans/2026-06-11-f7-rendered-track-changes-preview.md
T

50 KiB
Raw Blame History

F7 Rendered Track-Changes Preview Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add a read-only webview that renders a markdown document beside its source editor and marks what changed since the F6 baseline (prose <ins>/<del>, atomic code/mermaid "changed" badge), updating live as the human and Claude edit.

Architecture: A pure, vscode-free render engine (trackChangesModel.ts) turns (baselineText, currentText) into annotated HTML via a block-level diff (LCS over normalized blocks) + word-level prose refinement, with code/mermaid fences kept atomic. A vscode-layer controller (trackChangesPreview.ts) owns one sealed webview panel per markdown document, reads the F6 baseline from the reused DiffViewController, and posts HTML on open / debounced edit / baseline-epoch change. The webview asset (media/preview.tsout/media/preview.js, a separate esbuild bundle) swaps in the HTML and runs mermaid.run() over .mermaid blocks. The document, sidecar, and baseline are never mutated (INV-20).

Tech Stack: TypeScript, VS Code extension API (webview), markdown-it + diff (host bundle), mermaid (webview bundle only), esbuild (two entries), vitest (unit), @vscode/test-electron + mocha (host E2E). No LLM anywhere; no network/CDN (INV-21).

Spec: vscode-cowriting-plugin-content/specs/coauthoring-rendered-preview.md (F7, #21). Invariants INV-20..23.


File Structure

  • Create src/trackChangesModel.ts — pure render engine (INV-22/23). Exports splitBlocks, diffBlocks, renderTrackChanges, types Block/BlockOp. No vscode, no DOM.
  • Create test/trackChangesModel.test.ts — vitest unit suite for the engine.
  • Create media/preview.ts — webview client: receives {type:"render", html, epoch, summary}, swaps innerHTML, runs mermaid.run(), per-block error chips. Bundles mermaid.
  • Create media/preview.css — theme-aware marks (ins, del, .cw-added/removed/changed, .cw-badge, .cw-error).
  • Create src/trackChangesPreview.tsTrackChangesPreviewController (vscode layer): one panel per markdown doc, sealed CSP shell, debounced refresh, test seam.
  • Create test/e2e/suite/trackChangesPreview.test.ts — host E2E (auto-globbed by runTest.ts).
  • Create test/e2e/fixtures/workspace/docs/preview.md — markdown fixture (prose + mermaid + code + a TARGET sentence).
  • Create test/e2e/fixtures/workspace/docs/notes.txt — non-markdown fixture (markdown-guard test).
  • Create docs/MANUAL-SMOKE-F7.md — manual smoke script (§6.8).
  • Modify src/diffViewController.ts — additive onDidChangeBaseline event fired on every capture().
  • Modify src/extension.ts — construct TrackChangesPreviewController (workspace-independent, like F6), add to CowritingApi.
  • Modify esbuild.mjs — second entry building media/preview.tsout/media/preview.js (IIFE, browser, bundles mermaid).
  • Modify package.jsonmarkdown-it + diff + mermaid deps, @types/markdown-it + @types/diff devDeps, command + keybinding contributions.
  • Modify README.md — F7 section.

Task 1: Add dependencies and types

Files:

  • Modify: package.json

  • Step 1: Add runtime + dev dependencies

Edit package.json. In "dependencies" add markdown-it, diff, mermaid; in "devDependencies" add @types/markdown-it, @types/diff. Result:

  "dependencies": {
    "@cline/sdk": "0.0.46",
    "diff": "^7.0.0",
    "markdown-it": "^14.1.0",
    "mermaid": "^11.0.0"
  },
  "devDependencies": {
    "@types/diff": "^7.0.0",
    "@types/markdown-it": "^14.1.0",
    "@types/mocha": "^10.0.7",
    "@types/node": "^22.0.0",
    "@types/vscode": "^1.90.0",
    "@vscode/test-electron": "^2.4.0",
    "ajv": "^8.20.0",
    "esbuild": "^0.23.0",
    "glob": "^11.0.0",
    "mocha": "^10.7.0",
    "typescript": "^5.5.0",
    "vitest": "^2.0.0"
  }
  • Step 2: Install

Run: npm install Expected: lockfile updates, node_modules/markdown-it, node_modules/diff, node_modules/mermaid present, exit 0.

  • Step 3: Commit
git add package.json package-lock.json
git commit -m "feat(f7): add markdown-it + diff + mermaid deps (#21)"

Task 2: Block splitting (splitBlocks)

Files:

  • Create: src/trackChangesModel.ts

  • Test: test/trackChangesModel.test.ts

  • Step 1: Write the failing test

Create test/trackChangesModel.test.ts:

import { describe, it, expect } from "vitest";
import { splitBlocks } from "../src/trackChangesModel";

describe("splitBlocks", () => {
  it("splits prose paragraphs on blank lines, dropping empties", () => {
    const blocks = splitBlocks("# Heading\n\nFirst para.\n\nSecond para.\n");
    expect(blocks.map((b) => b.type)).toEqual(["prose", "prose", "prose"]);
    expect(blocks[0].raw).toBe("# Heading");
    expect(blocks[2].raw).toBe("Second para.");
  });

  it("keeps a fenced code block whole and tags it `code`", () => {
    const text = "Intro.\n\n```ts\nconst a = 1;\n\nconst b = 2;\n```\n\nOutro.\n";
    const blocks = splitBlocks(text);
    expect(blocks.map((b) => b.type)).toEqual(["prose", "code", "prose"]);
    expect(blocks[1].raw).toBe("```ts\nconst a = 1;\n\nconst b = 2;\n```");
  });

  it("tags a mermaid fence `mermaid`", () => {
    const blocks = splitBlocks("```mermaid\nflowchart LR\n  a-->b\n```\n");
    expect(blocks).toHaveLength(1);
    expect(blocks[0].type).toBe("mermaid");
  });

  it("normalizes the key (whitespace-collapsed) for matching", () => {
    const blocks = splitBlocks("Hello    world\n");
    expect(blocks[0].key).toBe("hello world");
  });
});
  • Step 2: Run test to verify it fails

Run: npx vitest run test/trackChangesModel.test.ts Expected: FAIL — splitBlocks not exported / module missing.

  • Step 3: Write minimal implementation

Create src/trackChangesModel.ts:

/**
 * trackChangesModel — F7 pure, vscode-free render engine (spec §6.2, INV-22/23).
 *
 * `renderTrackChanges(baselineText, currentText)` returns the annotated HTML body
 * for the preview webview: a block-level diff (LCS over normalized blocks) with
 * word-level `<ins>`/`<del>` refinement inside changed PROSE blocks, and CODE /
 * MERMAID fences kept ATOMIC (INV-23 — never word-refined, never partially
 * rendered). Deterministic: same inputs → identical HTML (INV-22). No vscode, no
 * DOM — `markdown-it` and `diff` are libraries; mermaid runs later in the webview.
 */
import MarkdownIt from "markdown-it";
import { diffArrays, diffWords } from "diff";

export type BlockType = "prose" | "code" | "mermaid";

export interface Block {
  /** Verbatim source of the block (no surrounding blank lines). */
  raw: string;
  /** Normalized match key: lowercased, whitespace-collapsed. */
  key: string;
  type: BlockType;
}

function normalize(raw: string): string {
  return raw.trim().replace(/\s+/g, " ").toLowerCase();
}

function makeBlock(raw: string, type: BlockType): Block {
  return { raw, key: normalize(raw), type };
}

/** Split markdown into top-level blocks; fenced code/mermaid stay whole. */
export function splitBlocks(text: string): Block[] {
  const lines = text.split(/\r?\n/);
  const blocks: Block[] = [];
  let buf: string[] = [];
  const flushProse = () => {
    const raw = buf.join("\n");
    if (raw.trim()) blocks.push(makeBlock(raw, "prose"));
    buf = [];
  };
  let i = 0;
  while (i < lines.length) {
    const line = lines[i];
    const fence = line.match(/^(\s*)(`{3,}|~{3,})(.*)$/);
    if (fence) {
      flushProse();
      const marker = fence[2][0]; // ` or ~
      const info = fence[3].trim().split(/\s+/)[0].toLowerCase();
      const fenceLines = [line];
      i++;
      while (i < lines.length) {
        fenceLines.push(lines[i]);
        const closed = lines[i].trim().startsWith(marker.repeat(3));
        i++;
        if (closed) break;
      }
      blocks.push(makeBlock(fenceLines.join("\n"), info === "mermaid" ? "mermaid" : "code"));
      continue;
    }
    if (line.trim() === "") {
      flushProse();
      i++;
      continue;
    }
    buf.push(line);
    i++;
  }
  flushProse();
  return blocks;
}
  • Step 4: Run test to verify it passes

Run: npx vitest run test/trackChangesModel.test.ts Expected: PASS (4 tests).

  • Step 5: Commit
git add src/trackChangesModel.ts test/trackChangesModel.test.ts
git commit -m "feat(f7): block splitter for the render engine (#21)"

Task 3: Block diff (diffBlocks)

Files:

  • Modify: src/trackChangesModel.ts

  • Test: test/trackChangesModel.test.ts

  • Step 1: Write the failing test

Append to test/trackChangesModel.test.ts:

import { diffBlocks } from "../src/trackChangesModel";

describe("diffBlocks", () => {
  const kinds = (base: string, cur: string) => diffBlocks(base, cur).map((o) => o.kind);

  it("unchanged doc → all unchanged", () => {
    const doc = "Alpha.\n\nBravo.\n";
    expect(kinds(doc, doc)).toEqual(["unchanged", "unchanged"]);
  });

  it("a pure addition → an added op", () => {
    const ops = diffBlocks("Alpha.\n", "Alpha.\n\nBravo.\n");
    expect(ops.map((o) => o.kind)).toEqual(["unchanged", "added"]);
  });

  it("a pure deletion → a removed op", () => {
    const ops = diffBlocks("Alpha.\n\nBravo.\n", "Alpha.\n");
    expect(ops.map((o) => o.kind)).toEqual(["unchanged", "removed"]);
  });

  it("a prose modification → a non-atomic changed op", () => {
    const ops = diffBlocks("The quick fox.\n", "The slow fox.\n");
    expect(ops).toHaveLength(1);
    expect(ops[0].kind).toBe("changed");
    expect(ops[0].kind === "changed" && ops[0].atomic).toBe(false);
  });

  it("a code-fence change → an ATOMIC changed op (INV-23)", () => {
    const base = "```ts\nconst a = 1;\n```\n";
    const cur = "```ts\nconst a = 2;\n```\n";
    const ops = diffBlocks(base, cur);
    expect(ops).toHaveLength(1);
    expect(ops[0].kind).toBe("changed");
    expect(ops[0].kind === "changed" && ops[0].atomic).toBe(true);
  });

  it("a mermaid-fence change → an ATOMIC changed op", () => {
    const base = "```mermaid\nflowchart LR\n a-->b\n```\n";
    const cur = "```mermaid\nflowchart LR\n a-->c\n```\n";
    const ops = diffBlocks(base, cur);
    expect(ops[0].kind).toBe("changed");
    expect(ops[0].kind === "changed" && ops[0].atomic).toBe(true);
  });

  it("a reorder → removed + added (not changed)", () => {
    const ops = diffBlocks("One.\n\nTwo.\n", "Two.\n\nOne.\n");
    // Two. matches (unchanged), One. moves: one removed + one added.
    const k = ops.map((o) => o.kind).sort();
    expect(k).toContain("unchanged");
  });
});
  • Step 2: Run test to verify it fails

Run: npx vitest run test/trackChangesModel.test.ts Expected: FAIL — diffBlocks not exported.

  • Step 3: Write minimal implementation

Append to src/trackChangesModel.ts:

export type BlockOp =
  | { kind: "unchanged"; block: Block }
  | { kind: "added"; block: Block }
  | { kind: "removed"; block: Block }
  | { kind: "changed"; block: Block; before: Block; atomic: boolean };

/**
 * Diff two block sequences. Matching is by normalized key (jsdiff `diffArrays`);
 * adjacent removed-then-added runs are paired element-wise into `changed` ops, the
 * surplus staying `removed` / `added`. A `changed` op is ATOMIC (INV-23) when
 * either side is a code/mermaid fence — rendered whole, never word-refined.
 */
export function diffBlocks(baselineText: string, currentText: string): BlockOp[] {
  const before = splitBlocks(baselineText);
  const after = splitBlocks(currentText);
  const changes = diffArrays(
    before.map((b) => b.key),
    after.map((b) => b.key),
  );

  const ops: BlockOp[] = [];
  let bi = 0; // index into `before`
  let ci = 0; // index into `after`
  for (let n = 0; n < changes.length; n++) {
    const ch = changes[n];
    const count = ch.count ?? ch.value.length;
    if (!ch.added && !ch.removed) {
      for (let k = 0; k < count; k++) ops.push({ kind: "unchanged", block: after[ci++] });
      bi += count;
      continue;
    }
    if (ch.removed) {
      const next = changes[n + 1];
      const addCount = next?.added ? (next.count ?? next.value.length) : 0;
      const paired = Math.min(count, addCount);
      for (let k = 0; k < paired; k++) {
        const beforeBlk = before[bi++];
        const afterBlk = after[ci++];
        const atomic = beforeBlk.type !== "prose" || afterBlk.type !== "prose";
        ops.push({ kind: "changed", block: afterBlk, before: beforeBlk, atomic });
      }
      for (let k = paired; k < count; k++) ops.push({ kind: "removed", block: before[bi++] });
      for (let k = paired; k < addCount; k++) ops.push({ kind: "added", block: after[ci++] });
      if (next?.added) n++; // consumed the paired added run
      continue;
    }
    // a lone added run (no preceding removed)
    for (let k = 0; k < count; k++) ops.push({ kind: "added", block: after[ci++] });
  }
  return ops;
}
  • Step 4: Run test to verify it passes

Run: npx vitest run test/trackChangesModel.test.ts Expected: PASS (all diffBlocks tests + Task 2 tests).

  • Step 5: Commit
git add src/trackChangesModel.ts test/trackChangesModel.test.ts
git commit -m "feat(f7): block-level diff with atomic fences (#21)"

Task 4: Render to annotated HTML (renderTrackChanges)

Files:

  • Modify: src/trackChangesModel.ts

  • Test: test/trackChangesModel.test.ts

  • Step 1: Write the failing test

Append to test/trackChangesModel.test.ts:

import { renderTrackChanges } from "../src/trackChangesModel";

describe("renderTrackChanges", () => {
  it("wraps each block in a cw-blk div with its kind class", () => {
    const html = renderTrackChanges("Alpha.\n", "Alpha.\n\nBravo.\n");
    expect(html).toContain('class="cw-blk cw-unchanged"');
    expect(html).toContain('class="cw-blk cw-added"');
  });

  it("emits inline <ins>/<del> for a prose modification", () => {
    const html = renderTrackChanges("The quick fox.\n", "The slow fox.\n");
    expect(html).toContain("<ins>");
    expect(html).toContain("<del>");
    expect(html).toContain("cw-changed");
  });

  it("renders a changed CODE fence atomically: cw-changed, no inline ins/del", () => {
    const html = renderTrackChanges("```ts\nconst a = 1;\n```\n", "```ts\nconst a = 2;\n```\n");
    expect(html).toContain("cw-changed");
    expect(html).not.toContain("<ins>");
    expect(html).not.toContain("<del>");
    expect(html).toContain("cw-badge");
  });

  it("emits <pre class=\"mermaid\"> for a mermaid fence + a changed badge", () => {
    const html = renderTrackChanges(
      "```mermaid\nflowchart LR\n a-->b\n```\n",
      "```mermaid\nflowchart LR\n a-->c\n```\n",
    );
    expect(html).toContain('<pre class="mermaid">');
    expect(html).toContain("a--&gt;c"); // current source, escaped
    expect(html).toContain("cw-badge");
  });

  it("unchanged doc renders with no marks", () => {
    const html = renderTrackChanges("Alpha.\n", "Alpha.\n");
    expect(html).not.toContain("cw-added");
    expect(html).not.toContain("cw-removed");
    expect(html).not.toContain("cw-changed");
  });

  it("is deterministic (same inputs → identical HTML) (INV-22)", () => {
    const a = renderTrackChanges("X.\n\nY.\n", "X.\n\nZ.\n");
    const b = renderTrackChanges("X.\n\nY.\n", "X.\n\nZ.\n");
    expect(a).toBe(b);
  });
});
  • Step 2: Run test to verify it fails

Run: npx vitest run test/trackChangesModel.test.ts Expected: FAIL — renderTrackChanges not exported.

  • Step 3: Write minimal implementation

Append to src/trackChangesModel.ts:

const md = new MarkdownIt({ html: true, linkify: false, breaks: false });
// mermaid fences → <pre class="mermaid">SRC</pre> for client-side rendering; all
// other fences fall through to markdown-it's default (escaped <pre><code>).
const defaultFence = md.renderer.rules.fence!.bind(md.renderer.rules);
md.renderer.rules.fence = (tokens, idx, options, env, self) => {
  const info = tokens[idx].info.trim().split(/\s+/)[0].toLowerCase();
  if (info === "mermaid") {
    return `<pre class="mermaid">${md.utils.escapeHtml(tokens[idx].content.replace(/\n$/, ""))}</pre>\n`;
  }
  return defaultFence(tokens, idx, options, env, self);
};

/** Build a markdown string with inline <ins>/<del> from a word-level prose diff. */
function wordMergedMarkdown(beforeRaw: string, afterRaw: string): string {
  return diffWords(beforeRaw, afterRaw)
    .map((part) => {
      if (part.added) return `<ins>${part.value}</ins>`;
      if (part.removed) return `<del>${part.value}</del>`;
      return part.value;
    })
    .join("");
}

function safeRender(src: string): string {
  try {
    return md.render(src);
  } catch (err) {
    const message = err instanceof Error ? err.message : String(err);
    return `<div class="cw-error">Could not render this block: ${md.utils.escapeHtml(message)}</div>`;
  }
}

function renderOp(op: BlockOp): string {
  let cls: string;
  let inner: string;
  let badge = "";
  switch (op.kind) {
    case "unchanged":
      cls = "cw-unchanged";
      inner = safeRender(op.block.raw);
      break;
    case "added":
      cls = "cw-added";
      inner = safeRender(op.block.raw);
      if (op.block.type !== "prose") badge = '<span class="cw-badge">added</span>';
      break;
    case "removed":
      cls = "cw-removed";
      inner = safeRender(op.block.raw);
      break;
    case "changed":
      cls = "cw-changed";
      if (op.atomic) {
        inner = safeRender(op.block.raw); // the NEW block, whole (INV-23)
        badge = '<span class="cw-badge">changed</span>';
      } else {
        inner = safeRender(wordMergedMarkdown(op.before.raw, op.block.raw));
      }
      break;
  }
  return `<div class="cw-blk ${cls}">${badge}${inner}</div>`;
}

export interface RenderOptions {
  /** Counts of changed/added/removed blocks, for the header summary. */
  // (reserved; callers derive counts from diffBlocks)
}

/** Pure entry point: annotated HTML body for the preview (INV-22). */
export function renderTrackChanges(baselineText: string, currentText: string): string {
  return diffBlocks(baselineText, currentText).map(renderOp).join("\n");
}
  • Step 4: Run test to verify it passes

Run: npx vitest run test/trackChangesModel.test.ts Expected: PASS (all render tests).

  • Step 5: Commit
git add src/trackChangesModel.ts test/trackChangesModel.test.ts
git commit -m "feat(f7): render annotated track-changes HTML (#21)"

Task 5: Error-chip fallback (PUC-6) made testable

Files:

  • Modify: src/trackChangesModel.ts
  • Test: test/trackChangesModel.test.ts

Why: safeRender already catches render errors, but markdown-it almost never throws on real input, so the catch is untested. Add a tiny, honest seam: an exported renderBlockHtml(op, render) that takes the per-block markdown renderer as a parameter (defaulting to the module's safeRender). The unit test injects a renderer that throws for one block to prove the chip appears AND the rest still renders.

  • Step 1: Write the failing test

Append to test/trackChangesModel.test.ts:

import { renderTrackChanges as renderWith } from "../src/trackChangesModel";

describe("error chip (PUC-6)", () => {
  it("a block whose render throws becomes an error chip; the rest still renders", () => {
    const throwing = (src: string) => {
      if (src.includes("BOOM")) throw new Error("kaboom");
      return `<p>${src}</p>`;
    };
    const html = renderWith("Good one.\n\nBOOM here.\n", "Good one.\n\nBOOM here.\n", { render: throwing });
    expect(html).toContain("cw-error");
    expect(html).toContain("kaboom");
    expect(html).toContain("<p>Good one.</p>"); // the healthy block still rendered
  });
});
  • Step 2: Run test to verify it fails

Run: npx vitest run test/trackChangesModel.test.ts Expected: FAIL — renderTrackChanges takes only 2 args / opts.render ignored.

  • Step 3: Write minimal implementation

In src/trackChangesModel.ts, replace the RenderOptions block and the safeRender/renderOp/renderTrackChanges definitions so the per-block renderer is injectable:

export interface RenderOptions {
  /** Per-block markdown→HTML renderer (test seam). Defaults to the bundled markdown-it. */
  render?: (src: string) => string;
}

function defaultRender(src: string): string {
  return md.render(src);
}

function chip(message: string): string {
  return `<div class="cw-error">Could not render this block: ${md.utils.escapeHtml(message)}</div>`;
}

function renderOp(op: BlockOp, render: (src: string) => string): string {
  const safe = (src: string): string => {
    try {
      return render(src);
    } catch (err) {
      return chip(err instanceof Error ? err.message : String(err));
    }
  };
  let cls: string;
  let inner: string;
  let badge = "";
  switch (op.kind) {
    case "unchanged":
      cls = "cw-unchanged";
      inner = safe(op.block.raw);
      break;
    case "added":
      cls = "cw-added";
      inner = safe(op.block.raw);
      if (op.block.type !== "prose") badge = '<span class="cw-badge">added</span>';
      break;
    case "removed":
      cls = "cw-removed";
      inner = safe(op.block.raw);
      break;
    case "changed":
      cls = "cw-changed";
      if (op.atomic) {
        inner = safe(op.block.raw);
        badge = '<span class="cw-badge">changed</span>';
      } else {
        inner = safe(wordMergedMarkdown(op.before.raw, op.block.raw));
      }
      break;
  }
  return `<div class="cw-blk ${cls}">${badge}${inner}</div>`;
}

export function renderTrackChanges(
  baselineText: string,
  currentText: string,
  opts: RenderOptions = {},
): string {
  const render = opts.render ?? defaultRender;
  return diffBlocks(baselineText, currentText).map((op) => renderOp(op, render)).join("\n");
}

Delete the now-unused standalone safeRender function.

  • Step 4: Run test to verify it passes

Run: npx vitest run test/trackChangesModel.test.ts Expected: PASS (error-chip test + all prior tests).

  • Step 5: Typecheck + full unit suite

Run: npm run typecheck && npm test Expected: PASS, no type errors.

  • Step 6: Commit
git add src/trackChangesModel.ts test/trackChangesModel.test.ts
git commit -m "feat(f7): per-block error-chip fallback (PUC-6, #21)"

Task 6: Webview asset (CSS + client JS) and second esbuild entry

Files:

  • Create: media/preview.css

  • Create: media/preview.ts

  • Modify: esbuild.mjs

  • Step 1: Create the stylesheet

Create media/preview.css:

/* F7 track-changes preview — theme-aware via VS Code webview CSS variables. */
body {
  font-family: var(--vscode-font-family);
  font-size: var(--vscode-font-size);
  color: var(--vscode-foreground);
  background: var(--vscode-editor-background);
  padding: 0 1.2rem 2rem;
  line-height: 1.5;
}
#cw-header {
  position: sticky;
  top: 0;
  background: var(--vscode-editor-background);
  border-bottom: 1px solid var(--vscode-panel-border);
  padding: 0.5rem 0;
  font-size: 0.85em;
  opacity: 0.85;
  display: flex;
  gap: 1rem;
}
#cw-summary .cw-add { color: var(--vscode-gitDecoration-addedResourceForeground); }
#cw-summary .cw-del { color: var(--vscode-gitDecoration-deletedResourceForeground); }
.cw-blk { position: relative; }
ins, .cw-added {
  background: var(--vscode-diffEditor-insertedTextBackground, rgba(0, 255, 0, 0.15));
  text-decoration: none;
}
del, .cw-removed {
  background: var(--vscode-diffEditor-removedTextBackground, rgba(255, 0, 0, 0.15));
  text-decoration: line-through;
  opacity: 0.7;
}
.cw-changed { outline: 2px solid var(--vscode-diffEditor-insertedTextBackground, rgba(0, 255, 0, 0.25)); outline-offset: 2px; }
.cw-badge {
  position: absolute;
  top: 0;
  right: 0;
  font-size: 0.7em;
  padding: 0 0.4em;
  border-radius: 3px;
  background: var(--vscode-badge-background);
  color: var(--vscode-badge-foreground);
}
.cw-error {
  border: 1px solid var(--vscode-inputValidation-errorBorder);
  background: var(--vscode-inputValidation-errorBackground);
  color: var(--vscode-errorForeground);
  padding: 0.3rem 0.6rem;
  border-radius: 3px;
  font-size: 0.85em;
}
pre.mermaid { text-align: center; background: transparent; }
pre.mermaid[data-cw-error] { color: var(--vscode-errorForeground); }
  • Step 2: Create the client script

Create media/preview.ts:

/**
 * F7 preview webview client (sealed sandbox, INV-21). Receives annotated HTML
 * from the extension host and swaps it in; runs mermaid over `.mermaid` blocks
 * (mermaid needs a DOM, so it runs here, not in the host). Bundled by esbuild as
 * a standalone IIFE → out/media/preview.js, so mermaid never enters the host
 * bundle. No network, no LLM.
 */
import mermaid from "mermaid";

interface RenderMessage {
  type: "render";
  html: string;
  epoch: string;
  summary: { added: number; removed: number; changed: number };
}

const body = document.getElementById("cw-body")!;
const header = document.getElementById("cw-epoch")!;
const summary = document.getElementById("cw-summary")!;

function themeFor(): "dark" | "default" {
  return document.body.classList.contains("vscode-dark") ||
    document.body.classList.contains("vscode-high-contrast")
    ? "dark"
    : "default";
}

async function renderMermaid(): Promise<void> {
  const nodes = Array.from(body.querySelectorAll<HTMLElement>("pre.mermaid"));
  if (nodes.length === 0) return;
  mermaid.initialize({ startOnLoad: false, theme: themeFor(), securityLevel: "strict" });
  try {
    await mermaid.run({ nodes });
  } catch {
    // mermaid.run already marks failed nodes; ensure a visible chip per failure.
    for (const n of nodes) {
      if (!n.querySelector("svg")) n.setAttribute("data-cw-error", "true");
    }
  }
}

window.addEventListener("message", (event: MessageEvent<RenderMessage>) => {
  const msg = event.data;
  if (msg?.type !== "render") return;
  body.innerHTML = msg.html;
  header.textContent = `Track changes since ${msg.epoch}`;
  summary.innerHTML =
    `<span class="cw-add">+${msg.summary.added + msg.summary.changed}</span> ` +
    `<span class="cw-del">${msg.summary.removed + msg.summary.changed}</span>`;
  void renderMermaid();
});
  • Step 3: Add the second esbuild entry

In esbuild.mjs, add a third options object after liveTurnOptions, and build it. Insert:

/** @type {import('esbuild').BuildOptions} */
const previewOptions = {
  entryPoints: ["media/preview.ts"],
  outfile: "out/media/preview.js",
  bundle: true,
  // The webview is a browser context; mermaid is bundled IN (and ONLY in) this
  // asset so it never bloats the extension-host bundle (the @cline/sdk size
  // discipline). No externals — everything is shipped to the sealed webview.
  platform: "browser",
  format: "iife",
  target: "es2020",
  sourcemap: true,
  logLevel: "info",
};

Then update the watch and build blocks to include it:

if (watch) {
  const ctx = await context(options);
  const ctxLive = await context(liveTurnOptions);
  const ctxPreview = await context(previewOptions);
  await Promise.all([ctx.watch(), ctxLive.watch(), ctxPreview.watch()]);
  console.log("esbuild: watching…");
} else {
  await build(options);
  await build(liveTurnOptions);
  await build(previewOptions);
  console.log("esbuild: build complete → out/extension.cjs + out/liveTurn.mjs + out/media/preview.js");
}
  • Step 4: Build to verify the webview bundle compiles

Run: npm run build Expected: build complete; out/media/preview.js exists (mermaid bundled, a few MB).

  • Step 5: Commit
git add media/preview.ts media/preview.css esbuild.mjs
git commit -m "feat(f7): sealed webview asset + esbuild preview bundle (#21)"

Task 7: Additive onDidChangeBaseline event on the F6 controller

Files:

  • Modify: src/diffViewController.ts

  • Step 1: Add the emitter and public event

In src/diffViewController.ts, after the existing onDidChangeEmitter field (line ~28), add:

  /** F7 (additive): fires on every baseline capture (open / advance / pin) so
   *  the track-changes preview refreshes without polling. Mirrors the internal
   *  content-provider change signal but carries the real document URI. */
  private readonly onDidChangeBaselineEmitter = new vscode.EventEmitter<{ uri: string }>();
  readonly onDidChangeBaseline = this.onDidChangeBaselineEmitter.event;
  • Step 2: Fire it on capture, and dispose it

In the constructor's this.disposables.push(...) list, add this.onDidChangeBaselineEmitter, alongside this.onDidChangeEmitter,.

In capture(...), after the existing this.onDidChangeEmitter.fire(this.baselineUri(document)); line, add:

    this.onDidChangeBaselineEmitter.fire({ uri: key });
  • Step 3: Typecheck

Run: npm run typecheck Expected: PASS.

  • Step 4: Commit
git add src/diffViewController.ts
git commit -m "feat(f7): additive onDidChangeBaseline event on DiffViewController (#21)"

Task 8: TrackChangesPreviewController (vscode layer)

Files:

  • Create: src/trackChangesPreview.ts

  • Step 1: Write the controller

Create src/trackChangesPreview.ts:

/**
 * TrackChangesPreviewController — F7 vscode layer (spec §6.2/§6.4). Owns one
 * sealed webview panel per markdown document, beside the source editor. On open /
 * debounced edit / F6 baseline-epoch change it reads the baseline (from the
 * reused DiffViewController) + the live buffer, runs the pure render engine, and
 * posts the HTML. Pure read-only: never mutates the document, sidecar, or
 * baseline (INV-20). The webview is sealed: local assets only, strict CSP,
 * per-load nonce, no network (INV-21).
 */
import * as path from "node:path";
import { randomBytes } from "node:crypto";
import * as vscode from "vscode";
import type { DiffViewController } from "./diffViewController";
import { renderTrackChanges, diffBlocks, type BlockOp } from "./trackChangesModel";

const VIEW_TYPE = "cowriting.trackChangesPreview";
const DEBOUNCE_MS = 150;

export class TrackChangesPreviewController implements vscode.Disposable {
  private readonly disposables: vscode.Disposable[] = [];
  private readonly panels = new Map<string, vscode.WebviewPanel>();
  private readonly lastModel = new Map<string, BlockOp[]>();
  private readonly debounces = new Map<string, NodeJS.Timeout>();

  constructor(
    private readonly diffView: DiffViewController,
    private readonly extensionUri: vscode.Uri,
  ) {
    this.disposables.push(
      vscode.commands.registerCommand("cowriting.showTrackChangesPreview", () =>
        this.show(vscode.window.activeTextEditor?.document),
      ),
      vscode.workspace.onDidChangeTextDocument((e) => this.onEdit(e.document)),
      this.diffView.onDidChangeBaseline(({ uri }) => this.refreshByUri(uri)),
    );
  }

  private isMarkdown(document: vscode.TextDocument): boolean {
    return document.languageId === "markdown";
  }

  /** Open or reveal the preview for a markdown document (PUC-1). */
  show(document: vscode.TextDocument | undefined): void {
    if (!document || !this.isMarkdown(document)) {
      void vscode.window.showWarningMessage(
        "Cowriting: open a Markdown document to use the track-changes preview (F6 covers other files).",
      );
      return;
    }
    const key = document.uri.toString();
    const existing = this.panels.get(key);
    if (existing) {
      existing.reveal(vscode.ViewColumn.Beside);
      this.refresh(document);
      return;
    }
    const name = path.basename(document.uri.path) || "untitled";
    const panel = vscode.window.createWebviewPanel(
      VIEW_TYPE,
      `Track changes: ${name}`,
      { viewColumn: vscode.ViewColumn.Beside, preserveFocus: true },
      {
        enableScripts: true,
        retainContextWhenHidden: false,
        localResourceRoots: [vscode.Uri.joinPath(this.extensionUri, "out", "media")],
      },
    );
    panel.webview.html = this.shellHtml(panel.webview);
    panel.onDidDispose(() => {
      this.panels.delete(key);
      this.lastModel.delete(key);
    }, null, this.disposables);
    this.panels.set(key, panel);
    this.refresh(document);
  }

  private onEdit(document: vscode.TextDocument): void {
    const key = document.uri.toString();
    if (!this.panels.has(key)) return;
    const pending = this.debounces.get(key);
    if (pending) clearTimeout(pending);
    this.debounces.set(
      key,
      setTimeout(() => {
        this.debounces.delete(key);
        this.refresh(document);
      }, DEBOUNCE_MS),
    );
  }

  private refreshByUri(uri: string): void {
    const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri);
    if (doc) this.refresh(doc);
  }

  /** Recompute the model + post HTML to the panel (no-op if no panel). */
  refresh(document: vscode.TextDocument): void {
    const key = document.uri.toString();
    const panel = this.panels.get(key);
    if (!panel) return;
    const baseline = this.diffView.getBaseline(key);
    const baselineText = baseline?.text ?? document.getText(); // no baseline → no marks
    const current = document.getText();
    const ops = diffBlocks(baselineText, current);
    this.lastModel.set(key, ops);
    const summary = {
      added: ops.filter((o) => o.kind === "added").length,
      removed: ops.filter((o) => o.kind === "removed").length,
      changed: ops.filter((o) => o.kind === "changed").length,
    };
    const epoch = this.epochLabel(baseline);
    void panel.webview.postMessage({
      type: "render",
      html: renderTrackChanges(baselineText, current),
      epoch,
      summary,
    });
  }

  private epochLabel(
    baseline: { reason: string; capturedAt: string } | undefined,
  ): string {
    if (!baseline) return "opened (no baseline yet)";
    const time = new Date(baseline.capturedAt).toLocaleTimeString();
    switch (baseline.reason) {
      case "machine-landing":
        return `Claude landed ${time}`;
      case "pinned":
        return `pinned ${time}`;
      default:
        return `opened ${time}`;
    }
  }

  private shellHtml(webview: vscode.Webview): string {
    const nonce = randomBytes(16).toString("base64");
    const scriptUri = webview.asWebviewUri(
      vscode.Uri.joinPath(this.extensionUri, "out", "media", "preview.js"),
    );
    const styleUri = webview.asWebviewUri(
      vscode.Uri.joinPath(this.extensionUri, "out", "media", "preview.css"),
    );
    // Sealed CSP (INV-21): no network. 'unsafe-inline' style is required only for
    // mermaid's dynamically injected <style> tags; scripts are nonce-gated and
    // strictly local (no remote/CDN script source).
    const csp =
      `default-src 'none'; ` +
      `img-src ${webview.cspSource} data:; ` +
      `font-src ${webview.cspSource}; ` +
      `style-src ${webview.cspSource} 'unsafe-inline'; ` +
      `script-src 'nonce-${nonce}';`;
    return `<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta http-equiv="Content-Security-Policy" content="${csp}" />
  <link href="${styleUri}" rel="stylesheet" />
  <title>Track changes</title>
</head>
<body>
  <div id="cw-header">
    <span id="cw-epoch">Track changes</span>
    <span id="cw-summary"></span>
  </div>
  <div id="cw-body"></div>
  <script nonce="${nonce}" src="${scriptUri}"></script>
</body>
</html>`;
  }

  // ---- test seam (§6.4) ----
  isOpen(uriString: string): boolean {
    return this.panels.has(uriString);
  }
  getLastModel(uriString: string): BlockOp[] | undefined {
    return this.lastModel.get(uriString);
  }

  dispose(): void {
    for (const t of this.debounces.values()) clearTimeout(t);
    for (const p of this.panels.values()) p.dispose();
    for (const d of this.disposables) d.dispose();
  }
}
  • Step 2: Typecheck

Run: npm run typecheck Expected: PASS.

  • Step 3: Commit
git add src/trackChangesPreview.ts
git commit -m "feat(f7): TrackChangesPreviewController (sealed webview, test seam) (#21)"

Task 9: Wire into the extension + package.json contributions

Files:

  • Modify: src/extension.ts

  • Modify: package.json

  • Step 1: Add the command + keybinding to package.json

In package.jsoncontributes.commands, add (after cowriting.pinDiffBaseline):

      {
        "command": "cowriting.showTrackChangesPreview",
        "title": "Cowriting: Open Track-Changes Preview",
        "category": "Cowriting"
      }

In contributes.keybindings, add (after the toggleDiffView binding):

      {
        "command": "cowriting.showTrackChangesPreview",
        "key": "ctrl+alt+r",
        "when": "editorLangId == markdown"
      }
  • Step 2: Construct and export the controller

In src/extension.ts:

(a) Add the import after the DiffViewController import:

import { TrackChangesPreviewController } from "./trackChangesPreview";

(b) Extend the CowritingApi interface:

export interface CowritingApi {
  threadController: ThreadController;
  attributionController: AttributionController;
  proposalController: ProposalController;
  versionGuard: VersionGuard;
  diffViewController: DiffViewController;
  trackChangesPreviewController: TrackChangesPreviewController;
}

(c) Right after context.subscriptions.push(diffViewController); (the F6 construction, ~line 57), construct F7 (workspace-independent, like F6 — its command must exist with or without a folder):

  // --- F7: rendered track-changes preview (Feature #21) — workspace-INDEPENDENT ---
  // Like F6, F7 works on any markdown doc (incl. untitled / out-of-folder), so it
  // is constructed regardless of an open folder and its command is always live.
  // It reuses the F6 baseline (INV-20) and adds no persistence.
  const trackChangesPreviewController = new TrackChangesPreviewController(
    diffViewController,
    context.extensionUri,
  );
  context.subscriptions.push(trackChangesPreviewController);

(d) In the with-root return { ... } statement at the end of activate, add the controller:

  return {
    threadController,
    attributionController,
    proposalController,
    versionGuard,
    diffViewController,
    trackChangesPreviewController,
  };

(The no-root early return undefined; stays as-is — the command is already registered by the controller constructed above, so it exists without a folder.)

  • Step 3: Typecheck + build

Run: npm run typecheck && npm run build Expected: PASS; out/extension.cjs and out/media/preview.js rebuilt.

  • Step 4: Commit
git add src/extension.ts package.json
git commit -m "feat(f7): register showTrackChangesPreview command + keybinding, wire controller (#21)"

Task 10: Host E2E fixtures + suite

Files:

  • Create: test/e2e/fixtures/workspace/docs/preview.md

  • Create: test/e2e/fixtures/workspace/docs/notes.txt

  • Create: test/e2e/suite/trackChangesPreview.test.ts

  • Step 1: Create the markdown fixture

Create test/e2e/fixtures/workspace/docs/preview.md:

# Preview fixture

A target sentence Claude will rewrite via the seam.

Some stable prose that does not change during the test run.

```mermaid
flowchart LR
  a --> b
const stable = true;

(Note: the fixture file literally contains the nested ``` fences shown above.)

- [ ] **Step 2: Create the non-markdown fixture**

Create `test/e2e/fixtures/workspace/docs/notes.txt`:

Plain text, not markdown — F7 must refuse this (F6 covers it instead).


- [ ] **Step 3: Write the E2E suite**

Create `test/e2e/suite/trackChangesPreview.test.ts`:

```ts
import * as assert from "assert";
import * as path from "path";
import * as vscode from "vscode";
import type { CowritingApi } from "../../../src/extension";

const WS = process.env.E2E_WORKSPACE!;
const DOC_REL = "docs/preview.md";
const docUri = () => vscode.Uri.file(path.join(WS, DOC_REL)).toString();

async function openDoc(rel = DOC_REL): Promise<vscode.TextDocument> {
  const uri = vscode.Uri.file(path.join(WS, rel));
  const doc = await vscode.workspace.openTextDocument(uri);
  await vscode.window.showTextDocument(doc);
  return doc;
}
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, "extension exports trackChangesPreviewController");
  return api;
}
const settle = () => new Promise((r) => setTimeout(r, 400));
const kinds = (api: CowritingApi) =>
  (api.trackChangesPreviewController.getLastModel(docUri()) ?? []).map((o) => o.kind);

// Order-dependent (F2F6 pattern): later tests consume earlier state.
suite("F7 track-changes preview (host E2E — markdown only, programmatic seam, no LLM)", () => {
  const TARGET = "A target sentence Claude will rewrite via the seam.";
  const REPLACEMENT = "A SENTENCE CLAUDE REWROTE via the seam.";

  test("open on a markdown doc → panel open, opened baseline shows no changes (PUC-1)", async () => {
    const doc = await openDoc();
    const api = await getApi();
    assert.strictEqual(api.trackChangesPreviewController.isOpen(docUri()), false, "no panel yet");
    await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
    await settle();
    assert.strictEqual(api.trackChangesPreviewController.isOpen(docUri()), true, "panel open");
    const model = api.trackChangesPreviewController.getLastModel(docUri());
    assert.ok(model && model.length > 0, "a model was computed");
    assert.ok(
      model!.every((o) => o.kind === "unchanged"),
      "baseline == buffer at open → every block unchanged",
    );
    // sanity: the active tab is a webview of our view type
    void doc;
  });

  test("typing produces a changed/added block (PUC-2)", async () => {
    const doc = await openDoc();
    const api = await getApi();
    const edit = new vscode.WorkspaceEdit();
    // append a brand-new paragraph at the end
    const end = doc.positionAt(doc.getText().length);
    edit.insert(doc.uri, end, "\n\nA freshly typed paragraph.\n");
    assert.ok(await vscode.workspace.applyEdit(edit), "operator edit applied");
    await settle();
    assert.ok(
      kinds(api).some((k) => k === "added" || k === "changed"),
      "an added/changed block appears after typing",
    );
  });

  test("accepting a proposal advances the baseline; the landed block goes unchanged (PUC-3, INV-18)", async () => {
    const doc = await openDoc();
    const api = await getApi();
    const start = doc.getText().indexOf(TARGET);
    assert.ok(start >= 0, "fixture contains the target sentence");
    const id = await vscode.commands.executeCommand<string>("cowriting.proposeAgentEdit", {
      uri: doc.uri.toString(),
      start,
      end: start + TARGET.length,
      newText: REPLACEMENT,
      model: "sonnet",
      sessionId: "e2e-f7",
      turnId: "turn-f7-1",
    });
    assert.ok(id, "propose returns an id");
    assert.ok(await api.proposalController.acceptById(DOC_REL, id!), "accept applies via the seam");
    await settle();
    // The baseline advanced to include REPLACEMENT, so the buffer == baseline for
    // that block → it is NOT marked. Confirm the replacement is not flagged as a change.
    const model = api.trackChangesPreviewController.getLastModel(docUri()) ?? [];
    const replacementMarked = model.some(
      (o) => o.kind !== "unchanged" && o.block.raw.includes("CLAUDE REWROTE"),
    );
    assert.ok(!replacementMarked, "the just-landed text renders unmarked (baseline advanced)");
  });

  test("pin resets the baseline to now → preview shows no marks (PUC-4)", async () => {
    const doc = await openDoc();
    const api = await getApi();
    await vscode.commands.executeCommand("cowriting.pinDiffBaseline");
    await settle();
    assert.ok(
      kinds(api).every((k) => k === "unchanged"),
      "after pin, every block is unchanged (baseline == buffer)",
    );
    void doc;
  });

  test("a non-markdown doc → command warns and opens no panel (PUC-6, markdown-only)", async () => {
    const txt = await openDoc("docs/notes.txt");
    const api = await getApi();
    const key = txt.uri.toString();
    assert.notStrictEqual(txt.languageId, "markdown", "fixture is not markdown");
    await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
    await settle();
    assert.strictEqual(api.trackChangesPreviewController.isOpen(key), false, "no panel for non-markdown");
  });
});
  • Step 4: Build E2E + run

Run: npm run pretest:e2e && npm run test:e2e Expected: the new suite passes alongside the existing F2F6 suites; both workspace and no-workspace passes complete (the no-workspace pass exercises the F2 stubs only — F7's command exists but isn't invoked there). Exit 0.

If the E2E electron download/run is not available in this environment, build at minimum (npm run pretest:e2e) to confirm the suite compiles, and note the run in the transcript for the manual smoke / CI.

  • Step 5: Commit
git add test/e2e/suite/trackChangesPreview.test.ts test/e2e/fixtures/workspace/docs/preview.md test/e2e/fixtures/workspace/docs/notes.txt
git commit -m "test(f7): host E2E for the track-changes preview (#21)"

Task 11: Manual smoke doc + README section

Files:

  • Create: docs/MANUAL-SMOKE-F7.md

  • Modify: README.md

  • Step 1: Write the manual smoke script

Create docs/MANUAL-SMOKE-F7.md following the §6.8 "Live smoke" script. Content:

# Manual smoke — F7 rendered track-changes preview (#21)

The webview's *visual* rendering (mermaid, theming) is verified here, not in the
automated host E2E (the webview is a sealed sandbox — §6.8). Run once per change
that touches F7.

## Setup

1. `npm run build`
2. Launch the Extension Development Host (F5 in VS Code, or the Run panel).
3. Open a folder and a markdown document containing prose, a `mermaid` fenced
   diagram, and a `ts` code fence (e.g. copy `test/e2e/fixtures/workspace/docs/preview.md`).

## Steps

1. **Open the preview.** Run **"Cowriting: Open Track-Changes Preview"** (or
   `Ctrl+Alt+R`). A preview opens beside the editor, rendering the document.
   Header reads `Track changes since opened …`, summary `+0 0`. The mermaid
   diagram renders as a diagram; the code fence renders as highlighted code.
2. **Edit prose.** In the source editor, change a word in a paragraph. The
   preview updates (≈150 ms): the old word struck (`<del>`), the new word
   highlighted (`<ins>`); summary increments.
3. **Edit the mermaid.** Change `a --> b` to `a --> c`. The preview re-renders the
   **new** diagram with a **"changed"** badge at its top-right.
4. **Ask Claude + accept.** Select the target sentence → "Ask Claude to Edit
   Selection" → accept the proposal. The accepted text **drops its marks** (the
   baseline advanced; PUC-3).
5. **Pin.** Run "Cowriting: Pin Diff Baseline to Now". All marks clear (baseline
   == now; PUC-4).
6. **Theme.** Toggle light/dark (`Ctrl+K Ctrl+T`). Marks and the diagram restyle
   to the theme.
7. **Cleanliness.** `git status` shows nothing changed by the preview (INV-20).

## Pass criteria

All seven steps behave as described; no console errors in the webview devtools;
nothing written to the document, sidecar, or repo.
  • Step 2: Add a README F7 section

In README.md, add a section describing F7 (mirroring how F6 is documented):

## F7 — Rendered track-changes preview (#21)

`Ctrl+Alt+R` (or "Cowriting: Open Track-Changes Preview") opens a read-only
webview beside a **Markdown** editor that renders the document and marks what
changed since the F6 baseline: prose additions highlighted / deletions struck,
and code/mermaid fences shown whole with a "changed" badge (intra-diagram mermaid
diffing is deferred — #22). It updates live as you and Claude edit, and re-bases
when Claude lands an edit or you pin (reusing the F6 baseline — it adds no
persistence). Mermaid renders fully. F7 is **markdown-only**; for any other file,
use F6's diff toggle (`Ctrl+Alt+D`). Manual smoke: `docs/MANUAL-SMOKE-F7.md`.
  • Step 3: Commit
git add docs/MANUAL-SMOKE-F7.md README.md
git commit -m "docs(f7): manual smoke script + README section (#21)"

Task 12: Full verification

Files: (none — verification only)

  • Step 1: Typecheck

Run: npm run typecheck Expected: PASS, no errors.

  • Step 2: Unit suite

Run: npm test Expected: all vitest suites pass, including trackChangesModel.test.ts.

  • Step 3: Build

Run: npm run build Expected: out/extension.cjs, out/liveTurn.mjs, out/media/preview.js all produced.

  • Step 4: Host E2E

Run: npm run test:e2e Expected: all suites (F2F7) green. (If electron is unavailable locally, record that the suite compiles and defer the run to CI / manual smoke — note it in the transcript.)

  • Step 5: Confirm the repo is clean of preview side effects

Run: git status Expected: only intended source/doc changes; nothing written under .threads/ or the workspace by the preview (INV-20).


Self-Review (completed during planning)

Spec coverage:

  • Rendered preview beside editable source (PUC-1) → Task 8 (show, ViewColumn.Beside), Task 9 (command).
  • Marks since F6 baseline (PUC-2/3), epoch follows machine / pin → Task 7 (onDidChangeBaseline), Task 8 (refresh), Task 10 (E2E PUC-3/4).
  • Full mermaid support (PUC-5) → Task 4 (<pre class="mermaid">), Task 6 (mermaid.run()).
  • Mermaid change = whole-diagram badge (INV-23) → Task 3 (atomic), Task 4 (badge).
  • Prose inline ins/del (PUC-2) → Task 4 (wordMergedMarkdown).
  • Live update on edit + epoch (PUC-2/3/4) → Task 8 (debounce + event sub).
  • Pure read-only, no sidecar/contract impact (INV-20) → Task 8 (reads only), Task 12 step 5.
  • Sealed webview, no network/LLM, local mermaid (INV-21) → Task 6 (browser bundle), Task 8 (CSP/nonce/localResourceRoots).
  • Deterministic testable model (INV-22) → Tasks 25 (vscode-free, determinism test).
  • Atomic non-prose blocks (INV-23) → Task 3 (atomic flag), Task 4 (no word diff for atomic).
  • Graceful edges (PUC-6): non-markdown warn → Task 8/10; error chip → Task 5; no baseline → Task 8 (baselineText ?? getText()).
  • Unit + host E2E, no LLM in CI (§6.8) → Tasks 25 (unit), Task 10 (E2E).

Placeholder scan: none — every code step shows complete code.

Type consistency: Block/BlockOp defined in Task 2/3 and consumed unchanged in Tasks 4/5/8; renderTrackChanges(baselineText, currentText, opts?), diffBlocks, getBaseline(uriString) (existing F6), onDidChangeBaseline({uri}), isOpen/getLastModel consistent across tasks.