Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c9050c3e08 | |||
| 15e6f02bdf | |||
| c3cecdfaa2 | |||
| 02fbe9c77e | |||
| ae19df78fb | |||
| 7b59c324d5 | |||
| dcdd2ffc1c | |||
| 1d284d2868 | |||
| 05ca497900 | |||
| c579f67a43 | |||
| 5d1000096a | |||
| 2955e8d929 | |||
| 44ef0a21db | |||
| 08557827da | |||
| 0ad040711f | |||
| 94b1a9b0c2 |
@@ -8,3 +8,6 @@ test/e2e/fixtures/workspace/.threads/
|
|||||||
|
|
||||||
# Sandbox playground churn (the EDH workspace - play freely, commit nothing)
|
# Sandbox playground churn (the EDH workspace - play freely, commit nothing)
|
||||||
sandbox/.threads/
|
sandbox/.threads/
|
||||||
|
|
||||||
|
# brainstorming visual-companion scratch
|
||||||
|
.superpowers/
|
||||||
|
|||||||
@@ -0,0 +1,827 @@
|
|||||||
|
# Author-colored track changes 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:** Render every change as a track-changes diff where **style = operation** (underline = inserted, strikethrough = removed) and **color = author** (human green/red, Claude blue/purple), in both the rendered preview pane and the main editor pane.
|
||||||
|
|
||||||
|
**Architecture:** Fuse the two color systems that exist today — the *semantic* diff (green=add/red=remove) and the *authorship* tint (cw-by-claude/cw-by-human) — into one author-colored diff. Insertions are colored by their attribution span (data already exists). Deletions are colored by an **adjacency heuristic**: a struck run inherits the author of the insertion it is paired with in the same changed hunk; a standalone removed block falls back to neutral. Phase 1 does this in the pure render engine (preview). Phase 2 reverses INV-32 to bring the same author-colored track changes inline into the editor.
|
||||||
|
|
||||||
|
**Tech Stack:** TypeScript, VS Code extension API, `markdown-it`, `diff` (jsdiff), vitest (unit), Playwright/electron host harness (E2E). Render engine (`src/trackChangesModel.ts`) is pure and vscode-free.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- **Render engine stays pure / vscode-free / deterministic** — `src/trackChangesModel.ts` must not import `vscode`; same inputs → identical HTML (INV-22).
|
||||||
|
- **Author color mapping (verbatim):** human added `#3fb950` / human removed `#f85149` / Claude added `#58a6ff` / Claude removed `#bc8cff`. Insert = 2px underline in the author color + faint author-hued background; remove = line-through in the author color + faint background.
|
||||||
|
- **Convention:** underline = inserted, strikethrough = removed, color = author.
|
||||||
|
- **"Color means changed since the pinned baseline."** Unchanged-since-baseline text renders plain (no author coloring). The pin→clean behavior (#48) is preserved: a pinned, zero-diff doc renders with no annotation.
|
||||||
|
- **Deletion authorship = adjacency heuristic** (locked decision): paired deletions inherit the paired insertion's author; standalone deletions render neutral.
|
||||||
|
- **Two roles only:** `human` and `claude` (`AuthorKind`). No arbitrary palettes, no user-configurable colors (YAGNI).
|
||||||
|
- **Test command:** `npm test` (vitest, unit). E2E: `npm run test:e2e`. Typecheck: `npm run typecheck`.
|
||||||
|
- Commit after each task. Branch off `main` (do not commit to `main` directly).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 1 — Preview pane (independently shippable)
|
||||||
|
|
||||||
|
Delivers the full author-colored track-changes language in the rendered preview, where the attribution data already exists.
|
||||||
|
|
||||||
|
## File Structure (Phase 1)
|
||||||
|
|
||||||
|
- `src/trackChangesModel.ts` — add `authorAt()` + `wordDiffByAuthor()` pure helpers; route the review path through them; color proposal blocks by author; stop coloring unchanged blocks. Add `author?` to `ProposalView`.
|
||||||
|
- `src/trackChangesPreview.ts` — pass each proposal's author into its `ProposalView`.
|
||||||
|
- `media/preview.css` — author×operation classes; retire the semantic green/red and flat authorship tints.
|
||||||
|
- `test/trackChangesModel.test.ts` — new unit tests; update tests asserting old semantic classes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: `authorAt()` + `wordDiffByAuthor()` pure helpers
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/trackChangesModel.ts` (add after `wordMergedMarkdown`, ~line 475)
|
||||||
|
- Test: `test/trackChangesModel.test.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: existing `AuthorKind`, `AuthorSpan` (trackChangesModel.ts:557-562), `diffWordsWithSpace` (already imported, line 12).
|
||||||
|
- Produces:
|
||||||
|
- `authorAt(offset: number, spans: AuthorSpan[]): AuthorKind | null` — the author whose span covers `offset` (half-open), else null.
|
||||||
|
- `wordDiffByAuthor(beforeRaw: string, afterRaw: string, afterStart: number, spans: AuthorSpan[]): string` — inline HTML where each inserted run is `<ins class="cw-ins-{author}">` (author looked up at the run's `afterStart`-relative landed offset) and each removed run is `<del class="cw-del-{author}">` (author = the insertion it's paired with in the same change cluster; `cw-del-none` if standalone). Unchanged parts are emitted verbatim.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Add to `test/trackChangesModel.test.ts` (import `authorAt`, `wordDiffByAuthor` from `../src/trackChangesModel`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
describe("authorAt", () => {
|
||||||
|
const spans: AuthorSpan[] = [
|
||||||
|
{ start: 0, end: 5, author: "human" },
|
||||||
|
{ start: 5, end: 10, author: "claude" },
|
||||||
|
];
|
||||||
|
test("returns the covering span's author (half-open)", () => {
|
||||||
|
expect(authorAt(0, spans)).toBe("human");
|
||||||
|
expect(authorAt(4, spans)).toBe("human");
|
||||||
|
expect(authorAt(5, spans)).toBe("claude");
|
||||||
|
expect(authorAt(9, spans)).toBe("claude");
|
||||||
|
});
|
||||||
|
test("returns null outside any span", () => {
|
||||||
|
expect(authorAt(10, spans)).toBeNull();
|
||||||
|
expect(authorAt(-1, spans)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("wordDiffByAuthor", () => {
|
||||||
|
test("an inserted run is colored by the author covering its landed offset", () => {
|
||||||
|
// before "hello " ; after "hello brave " ; "brave " inserted at landed offset 6..12
|
||||||
|
const spans: AuthorSpan[] = [{ start: 6, end: 12, author: "claude" }];
|
||||||
|
const html = wordDiffByAuthor("hello ", "hello brave ", 0, spans);
|
||||||
|
expect(html).toContain('<ins class="cw-ins-claude">brave </ins>');
|
||||||
|
expect(html).toContain("hello ");
|
||||||
|
});
|
||||||
|
test("a paired deletion inherits the paired insertion's author (adjacency)", () => {
|
||||||
|
// "light" -> "dark", both in one cluster; "dark" is claude-inserted
|
||||||
|
const spans: AuthorSpan[] = [{ start: 0, end: 4, author: "claude" }];
|
||||||
|
const html = wordDiffByAuthor("light", "dark", 0, spans);
|
||||||
|
expect(html).toContain('<del class="cw-del-claude">light</del>');
|
||||||
|
expect(html).toContain('<ins class="cw-ins-claude">dark</ins>');
|
||||||
|
});
|
||||||
|
test("a standalone deletion (no paired insertion) is neutral", () => {
|
||||||
|
const html = wordDiffByAuthor("keep this word", "keep word", 0, []);
|
||||||
|
expect(html).toContain('<del class="cw-del-none">this </del>');
|
||||||
|
expect(html).not.toContain("cw-del-human");
|
||||||
|
expect(html).not.toContain("cw-del-claude");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `npm test -- trackChangesModel`
|
||||||
|
Expected: FAIL — `authorAt`/`wordDiffByAuthor` not exported.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
Add to `src/trackChangesModel.ts` after `wordMergedMarkdown` (line 475):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/** The author whose span covers `offset` (half-open [start,end)), or null. */
|
||||||
|
export function authorAt(offset: number, spans: AuthorSpan[]): AuthorKind | null {
|
||||||
|
for (const s of spans) if (offset >= s.start && offset < s.end) return s.author;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const insClass = (a: AuthorKind | null): string => `cw-ins-${a ?? "none"}`;
|
||||||
|
const delClass = (a: AuthorKind | null): string => `cw-del-${a ?? "none"}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Author-colored word diff for a changed PROSE block in the review.
|
||||||
|
* `afterStart` is the landed-text offset of `afterRaw[0]` so an inserted run's
|
||||||
|
* offset maps into `spans`. Insertions are colored by the author covering their
|
||||||
|
* offset; deletions inherit the author of the insertion they are paired with in
|
||||||
|
* the same contiguous change cluster (adjacency heuristic) — `cw-del-none` when a
|
||||||
|
* cluster has no insertion. Pure, deterministic.
|
||||||
|
*/
|
||||||
|
export function wordDiffByAuthor(
|
||||||
|
beforeRaw: string,
|
||||||
|
afterRaw: string,
|
||||||
|
afterStart: number,
|
||||||
|
spans: AuthorSpan[],
|
||||||
|
): string {
|
||||||
|
const parts = diffWordsWithSpace(beforeRaw, afterRaw);
|
||||||
|
// First pass: find each change cluster's insertion author (first added run).
|
||||||
|
// A cluster is a maximal run of added/removed parts bounded by unchanged parts.
|
||||||
|
let afterOff = afterStart;
|
||||||
|
const clusterAuthor: (AuthorKind | null)[] = []; // per part index, the cluster's del author
|
||||||
|
{
|
||||||
|
let off = afterStart;
|
||||||
|
let i = 0;
|
||||||
|
while (i < parts.length) {
|
||||||
|
const p = parts[i];
|
||||||
|
if (!p.added && !p.removed) {
|
||||||
|
clusterAuthor[i] = null;
|
||||||
|
off += p.value.length;
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// a cluster: scan to its end, capturing the first added run's author
|
||||||
|
let j = i;
|
||||||
|
let author: AuthorKind | null = null;
|
||||||
|
let scanOff = off;
|
||||||
|
while (j < parts.length && (parts[j].added || parts[j].removed)) {
|
||||||
|
if (parts[j].added && author === null) author = authorAt(scanOff, spans);
|
||||||
|
if (parts[j].added) scanOff += parts[j].value.length;
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
for (let k = i; k < j; k++) clusterAuthor[k] = author;
|
||||||
|
off = scanOff;
|
||||||
|
i = j;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Second pass: emit.
|
||||||
|
const out: string[] = [];
|
||||||
|
parts.forEach((p, i) => {
|
||||||
|
if (p.added) {
|
||||||
|
const a = authorAt(afterOff, spans);
|
||||||
|
out.push(`<ins class="${insClass(a)}">${p.value}</ins>`);
|
||||||
|
afterOff += p.value.length;
|
||||||
|
} else if (p.removed) {
|
||||||
|
out.push(`<del class="${delClass(clusterAuthor[i] ?? null)}">${p.value}</del>`);
|
||||||
|
} else {
|
||||||
|
out.push(p.value);
|
||||||
|
afterOff += p.value.length;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return out.join("");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `npm test -- trackChangesModel`
|
||||||
|
Expected: PASS (new describes green).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/trackChangesModel.ts test/trackChangesModel.test.ts
|
||||||
|
git commit -m "feat: authorAt + wordDiffByAuthor pure helpers (author-colored word diff w/ adjacency del)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Route the review path through `wordDiffByAuthor`; stop coloring unchanged blocks
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/trackChangesModel.ts` — `renderReviewOp` (769-780), the `colored` closure + changed-op handling in `renderReview` (889-897)
|
||||||
|
- Test: `test/trackChangesModel.test.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `wordDiffByAuthor` (Task 1), `landedSpans` + `ranges` already computed in `renderReview`.
|
||||||
|
- Produces: review HTML where changed prose blocks emit `cw-ins-{author}`/`cw-del-{author}`, added blocks are author-colored as insertions, and **unchanged blocks render plain**.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Add to the `renderReview` describe in `test/trackChangesModel.test.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
test("renderReview: a changed prose block colors ins/del by author (claude replace)", () => {
|
||||||
|
// baseline "light" -> current "dark"; "dark" attributed to claude at 0..4
|
||||||
|
const html = renderReview("light", "dark", [{ start: 0, end: 4, author: "claude" }], []);
|
||||||
|
expect(html).toContain('cw-ins-claude');
|
||||||
|
expect(html).toContain('cw-del-claude'); // adjacency: struck "light" inherits claude
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renderReview: an unchanged block renders plain (no author coloring)", () => {
|
||||||
|
const doc = "Alpha stays.";
|
||||||
|
const html = renderReview(doc, doc, [{ start: 0, end: 12, author: "human" }], []);
|
||||||
|
expect(html).not.toContain("cw-by-");
|
||||||
|
expect(html).not.toContain("cw-ins-");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renderReview: an added block is author-colored as an insertion", () => {
|
||||||
|
// baseline empty-ish -> add a new paragraph authored by human
|
||||||
|
const html = renderReview("Keep.", "Keep.\n\nFresh line.", [{ start: 6, end: 17, author: "human" }], []);
|
||||||
|
expect(html).toContain("cw-ins-human");
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `npm test -- trackChangesModel`
|
||||||
|
Expected: FAIL — changed blocks still emit plain `<ins>/<del>` via `wordMergedMarkdown`; unchanged blocks still carry `cw-by-*`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
In `src/trackChangesModel.ts`:
|
||||||
|
|
||||||
|
**(a)** Change `renderReviewOp` (769-780) so a changed PROSE block uses author coloring. Give it the colored-insertion path for `added` and the author word-diff for non-atomic `changed`. Replace the function body:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
function renderReviewOp(
|
||||||
|
op: BlockOp,
|
||||||
|
render: (src: string) => string,
|
||||||
|
colored: (raw: string) => string,
|
||||||
|
src: string,
|
||||||
|
changedHtml?: string,
|
||||||
|
): string {
|
||||||
|
// A non-atomic changed prose block is pre-rendered with author-colored ins/del
|
||||||
|
// (changedHtml). Removed blocks and atomic changed fences stay neutral via renderOp
|
||||||
|
// (standalone deletion → neutral, per the adjacency heuristic). `src` is "" for a
|
||||||
|
// removed block (no live source — INV-36).
|
||||||
|
if (op.kind === "changed" && !op.atomic && changedHtml !== undefined) {
|
||||||
|
return `<div class="cw-blk cw-changed"${src}>${changedHtml}</div>`;
|
||||||
|
}
|
||||||
|
if (op.kind === "removed" || op.kind === "changed") return renderOp(op, render, src);
|
||||||
|
return `<div class="cw-blk ${op.kind === "added" ? "cw-added" : "cw-unchanged"}"${src}>${colored(op.block.raw)}</div>`;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**(b)** In `renderReview` (the loop at 889-897), compute the author-colored changed HTML and stop coloring unchanged blocks. Replace the loop body:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
let ci = 0; // pointer into ranges; advances for every op with a current-side block
|
||||||
|
const bodyParts: string[] = [];
|
||||||
|
for (const op of ops) {
|
||||||
|
const blockIndex = op.kind === "removed" ? -1 : ci;
|
||||||
|
const blk = op.kind === "removed" ? undefined : ranges[ci++];
|
||||||
|
// Only ADDED blocks are author-colored (they are insertions since baseline);
|
||||||
|
// UNCHANGED blocks render plain (color means "changed since baseline").
|
||||||
|
const colored = (raw: string): string =>
|
||||||
|
blk && !clean && op.kind === "added" ? colorByAuthor(raw, blk.start, landedSpans, render) : render(raw);
|
||||||
|
// A non-atomic changed prose block: author-colored word diff (ins by author,
|
||||||
|
// del by adjacency). `blk.start` is the landed offset of the after-side text.
|
||||||
|
const changedHtml =
|
||||||
|
op.kind === "changed" && !op.atomic && blk && !clean
|
||||||
|
? render(wordDiffByAuthor(op.before.raw, op.block.raw, blk.start, landedSpans))
|
||||||
|
: undefined;
|
||||||
|
bodyParts.push(renderReviewOp(op, render, colored, srcAttr(blk), changedHtml));
|
||||||
|
const here = blockIndex >= 0 ? byBlock.get(blockIndex) : undefined;
|
||||||
|
if (here) for (const p of here) bodyParts.push(proposalBlockHtml(p, render));
|
||||||
|
}
|
||||||
|
for (const p of trailing) bodyParts.push(proposalBlockHtml(p, render));
|
||||||
|
return bodyParts.join("\n");
|
||||||
|
```
|
||||||
|
|
||||||
|
> Note: `render(wordDiffByAuthor(...))` passes the HTML-bearing string through markdown-it (same as `wordMergedMarkdown` was rendered). `colorByAuthor` on added blocks emits `cw-by-{author}` spans — Task 4 restyles those to insert styling in CSS.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `npm test -- trackChangesModel`
|
||||||
|
Expected: PASS for the new tests.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Update tests that assert the OLD review markup**
|
||||||
|
|
||||||
|
Two existing tests assert the old semantic output (test/trackChangesModel.test.ts:226-232). Update them:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
test("renderReview: human addition since baseline renders author-colored ins", () => {
|
||||||
|
const html = renderReview("hello", "hello world", [{ start: 6, end: 11, author: "human" }], []);
|
||||||
|
expect(html).toContain("cw-ins-human");
|
||||||
|
});
|
||||||
|
test("renderReview: a standalone deletion since baseline renders neutral struck del", () => {
|
||||||
|
const html = renderReview("hello world", "hello", [], []);
|
||||||
|
expect(html).toMatch(/cw-del-none|cw-removed/);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Run: `npm test -- trackChangesModel`
|
||||||
|
Expected: PASS (whole file green).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/trackChangesModel.ts test/trackChangesModel.test.ts
|
||||||
|
git commit -m "feat: author-colored changed/added blocks in review; unchanged blocks render plain"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Color pending proposal blocks by author
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/trackChangesModel.ts` — `ProposalView` (731-742) + `proposalBlockHtml` (744-767)
|
||||||
|
- Modify: `src/trackChangesPreview.ts` — populate `author` on each `ProposalView`
|
||||||
|
- Test: `test/trackChangesModel.test.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `AuthorKind` (557).
|
||||||
|
- Produces: `ProposalView.author?: AuthorKind` (defaults to `"claude"`); `proposalBlockHtml` emits `cw-del-{author}` / `cw-ins-{author}`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
test("renderReview: a proposal block colors its del/ins by author (claude default)", () => {
|
||||||
|
const proposals: ProposalView[] = [{ id: "p1", anchorStart: 0, anchorEnd: 5, replaced: "hello", replacement: "goodbye" }];
|
||||||
|
const html = renderReview("hello", "hello", [], proposals);
|
||||||
|
expect(html).toContain('cw-del-claude');
|
||||||
|
expect(html).toContain('cw-ins-claude');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `npm test -- trackChangesModel`
|
||||||
|
Expected: FAIL — `proposalBlockHtml` still emits `cw-del` / `cw-add`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
In `src/trackChangesModel.ts`, add `author?` to `ProposalView` (after line 741):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/** F12/#64 (INV-48): the pre-apply original, set after optimistic apply. */
|
||||||
|
original?: string;
|
||||||
|
/** Who made the proposal — colors the del/ins. Defaults to "claude". */
|
||||||
|
author?: AuthorKind;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Update `proposalBlockHtml` (753-754):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const who = p.author ?? "claude";
|
||||||
|
const before = p.replaced ? `<del class="cw-del-${who}">${safe(p.replaced)}</del>` : "";
|
||||||
|
const after = `<ins class="cw-ins-${who}">${safe(p.replacement)}</ins>`;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `npm test -- trackChangesModel`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Populate `author` from the live proposal**
|
||||||
|
|
||||||
|
In `src/trackChangesPreview.ts` find where `ProposalView`s are built for `renderReview` (the `listProposals` mapping near line 373). Add `author` from the proposal's provenance. Locate the mapping and add:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
author: p.author?.kind === "agent" ? "claude" : "human",
|
||||||
|
```
|
||||||
|
|
||||||
|
(where `p` is the live `Proposal` with `author: Provenance`). If the preview builds `ProposalView` via a helper, add the field there. Run `npm run typecheck` to confirm the field name matches the live `Proposal.author` shape (`model.ts:79-108`).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Run typecheck + tests**
|
||||||
|
|
||||||
|
Run: `npm run typecheck && npm test -- trackChangesModel`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/trackChangesModel.ts src/trackChangesPreview.ts test/trackChangesModel.test.ts
|
||||||
|
git commit -m "feat: color pending proposal blocks by author (Claude blue/purple)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: CSS — author×operation color system (preview)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `media/preview.css`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: classes emitted by Tasks 1-3: `cw-ins-human|claude|none`, `cw-del-human|claude|none`, `cw-by-human|claude` (added blocks), `cw-added`, `cw-removed`, `cw-changed`, `.cw-proposal`.
|
||||||
|
- Produces: the locked color mapping; unchanged text plain.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace the semantic diff + authorship-tint rules**
|
||||||
|
|
||||||
|
In `media/preview.css`, replace lines 21-33 and 55-58 with author-colored rules. New block:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Author-colored track changes — style = operation, color = author.
|
||||||
|
underline = inserted, strikethrough = removed; human green/red, Claude blue/purple. */
|
||||||
|
#cw-summary .cw-add { color: #3fb950; }
|
||||||
|
#cw-summary .cw-del { color: #f85149; }
|
||||||
|
.cw-blk { position: relative; }
|
||||||
|
|
||||||
|
/* base ins/del carry the operation; author classes carry the color */
|
||||||
|
ins { text-decoration: none; }
|
||||||
|
del { text-decoration: line-through; opacity: 0.7; }
|
||||||
|
|
||||||
|
.cw-ins-human { background: rgba(63,185,80,0.14); border-bottom: 2px solid #3fb950; text-decoration: none; }
|
||||||
|
.cw-ins-claude { background: rgba(88,166,255,0.15); border-bottom: 2px solid #58a6ff; text-decoration: none; }
|
||||||
|
.cw-ins-none { background: var(--vscode-diffEditor-insertedTextBackground, rgba(63,185,80,0.14)); text-decoration: none; }
|
||||||
|
.cw-del-human { background: rgba(248,81,73,0.11); text-decoration: line-through; text-decoration-color: #f85149; }
|
||||||
|
.cw-del-claude { background: rgba(188,140,255,0.13); text-decoration: line-through; text-decoration-color: #bc8cff; }
|
||||||
|
.cw-del-none { background: var(--vscode-diffEditor-removedTextBackground, rgba(248,81,73,0.11)); text-decoration: line-through; opacity: 0.7; }
|
||||||
|
|
||||||
|
/* whole-block ops: an added block is an insertion (author-colored inside via cw-by-*);
|
||||||
|
a standalone removed block is neutral struck (adjacency heuristic fallback) */
|
||||||
|
.cw-added { background: transparent; }
|
||||||
|
.cw-removed { background: var(--vscode-diffEditor-removedTextBackground, rgba(248,81,73,0.10)); text-decoration: line-through; opacity: 0.65; }
|
||||||
|
.cw-changed { outline: none; }
|
||||||
|
|
||||||
|
/* added-block author runs (colorByAuthor sentinels) read as insertions */
|
||||||
|
.cw-by-human { background: rgba(63,185,80,0.14); border-bottom: 2px solid #3fb950; text-decoration: none; }
|
||||||
|
.cw-by-claude { background: rgba(88,166,255,0.15); border-bottom: 2px solid #58a6ff; text-decoration: none; }
|
||||||
|
.cw-blk.cw-by-claude, .cw-blk.cw-by-human, .cw-blk.cw-mixed { outline: 2px solid currentColor; outline-offset: 2px; background: transparent; }
|
||||||
|
#cw-legend .cw-swatch { padding: 0 0.4em; border-radius: 3px; }
|
||||||
|
#cw-summary .cw-prop { opacity: 0.85; }
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Recolor the proposal block accent to neutral (author lives inside it now)**
|
||||||
|
|
||||||
|
The proposal block's left border is currently blue (`--vscode-charts-blue`). Keep it as a neutral "pending" frame — the del/ins inside now carry the author color. Change `media/preview.css:79-80`:
|
||||||
|
|
||||||
|
```css
|
||||||
|
border-left: 3px solid var(--vscode-panel-border, #555);
|
||||||
|
background: color-mix(in srgb, var(--vscode-foreground) 5%, transparent);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Manual visual verification**
|
||||||
|
|
||||||
|
Run the extension host (or load the smoke doc) and confirm in the preview: human insertions green-underlined, Claude insertions blue-underlined, Claude deletions purple-struck, standalone deletions neutral, unchanged text plain.
|
||||||
|
|
||||||
|
Run: `npm run build`
|
||||||
|
Expected: builds clean (CSS is bundled into the webview assets per the build).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add media/preview.css
|
||||||
|
git commit -m "feat: author-colored track-changes CSS for the preview (human green/red, Claude blue/purple)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Phase-1 host E2E — author colors render in the preview
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Test: the existing F10/F7 preview E2E suite (find under `test/e2e/` — the preview/review specs)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: the rendered preview webview HTML.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing E2E assertions**
|
||||||
|
|
||||||
|
In the preview E2E spec, after producing a Claude change + a human change on a doc, assert the webview body contains `cw-ins-claude` and `cw-ins-human`, and that an unchanged paragraph has no `cw-ins-`/`cw-by-` class. (Follow the existing pattern in that spec for opening the preview and reading `panel.webview` HTML or the DOM via the harness.)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify it fails (or update an assertion that checked old classes)**
|
||||||
|
|
||||||
|
Run: `npm run test:e2e`
|
||||||
|
Expected: FAIL if any existing assertion checked `cw-add`/`cw-by-*`-as-authorship; update those to the new classes.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Make it pass**
|
||||||
|
|
||||||
|
Adjust any E2E assertion referencing retired classes (`cw-add`, semantic `ins`/`del` colors, `cw-by-*` on unchanged blocks) to the new author classes.
|
||||||
|
|
||||||
|
Run: `npm run test:e2e`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add test/e2e
|
||||||
|
git commit -m "test(e2e): assert author-colored track changes render in the preview"
|
||||||
|
```
|
||||||
|
|
||||||
|
**>>> Phase 1 is independently shippable here. Open a PR for the preview pane before starting Phase 2, or continue.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 2 — Editor pane (reverses INV-32)
|
||||||
|
|
||||||
|
Brings the same author-colored track changes inline into the main editor: all changes-since-baseline (committed + pending), not just pending proposals.
|
||||||
|
|
||||||
|
## File Structure (Phase 2)
|
||||||
|
|
||||||
|
- `src/editorProposalController.ts` — per-author decoration types; recolor proposals; subscribe to baseline + attribution; decorate all committed insertions (author-colored) + committed deletion hints (adjacency); overlap stacks naturally.
|
||||||
|
- `src/extension.ts` — inject `DiffViewController` + `AttributionController` into `EditorProposalController`.
|
||||||
|
- Test: editor E2E suite.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Per-author decoration types; recolor proposals to Claude
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/editorProposalController.ts` (20-27 decoration types; 80-106 renderEditor)
|
||||||
|
- Test: editor E2E
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `decorationPlan` (already imported), proposal list.
|
||||||
|
- Produces: four insertion decoration types (`human`/`claude`) keyed by author, deletion-hint types colored by author; proposals decorate with Claude colors.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace the two decoration types with per-author maps**
|
||||||
|
|
||||||
|
In `src/editorProposalController.ts`, replace lines 20-27:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
private readonly insDeco: Record<"human" | "claude", vscode.TextEditorDecorationType> = {
|
||||||
|
human: vscode.window.createTextEditorDecorationType({
|
||||||
|
backgroundColor: "rgba(63,185,80,0.14)", borderColor: "#3fb950",
|
||||||
|
border: "0 0 2px 0", borderStyle: "solid",
|
||||||
|
}),
|
||||||
|
claude: vscode.window.createTextEditorDecorationType({
|
||||||
|
backgroundColor: "rgba(88,166,255,0.15)", borderColor: "#58a6ff",
|
||||||
|
border: "0 0 2px 0", borderStyle: "solid",
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
private readonly delDeco: Record<"human" | "claude", vscode.TextEditorDecorationType> = {
|
||||||
|
human: vscode.window.createTextEditorDecorationType({ after: { color: "#f85149" } }),
|
||||||
|
claude: vscode.window.createTextEditorDecorationType({ after: { color: "#bc8cff" } }),
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Update the constructor's disposables push (39-40) to dispose all four:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
this.insDeco.human, this.insDeco.claude, this.delDeco.human, this.delDeco.claude, this.lensEmitter,
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Recolor proposal decorations to Claude in `renderEditor`**
|
||||||
|
|
||||||
|
Replace the body of `renderEditor` (81-106) so it groups ranges per author and applies the right decoration type. Proposals are Claude:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
private renderEditor(editor: vscode.TextEditor): void {
|
||||||
|
const doc = editor.document;
|
||||||
|
const clearAll = () => {
|
||||||
|
for (const a of ["human", "claude"] as const) {
|
||||||
|
editor.setDecorations(this.insDeco[a], []);
|
||||||
|
editor.setDecorations(this.delDeco[a], []);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (doc.languageId !== "markdown") return clearAll();
|
||||||
|
const key = this.proposals.keyFor(doc);
|
||||||
|
const ins: Record<"human" | "claude", vscode.Range[]> = { human: [], claude: [] };
|
||||||
|
const del: Record<"human" | "claude", vscode.DecorationOptions[]> = { human: [], claude: [] };
|
||||||
|
// Pending proposals — always Claude (INV: proposals are agent-authored).
|
||||||
|
for (const v of this.proposals.listProposals(doc)) {
|
||||||
|
if (v.anchorStart === null || v.original === undefined || !this.proposals.isApplied(key, v.id)) continue;
|
||||||
|
const plan = decorationPlan(v.anchorStart, v.original, v.replacement);
|
||||||
|
for (const i of plan.insertions) ins.claude.push(new vscode.Range(doc.positionAt(i.start), doc.positionAt(i.end)));
|
||||||
|
for (const d of plan.deletions) {
|
||||||
|
del.claude.push({
|
||||||
|
range: new vscode.Range(doc.positionAt(d.at), doc.positionAt(d.at)),
|
||||||
|
renderOptions: { after: { contentText: ` ${d.text} `, textDecoration: "line-through" } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Committed changes-since-baseline are added in Task 7 (pushes into ins/del[author]).
|
||||||
|
this.decorateCommitted(editor, ins, del); // no-op stub until Task 7
|
||||||
|
for (const a of ["human", "claude"] as const) {
|
||||||
|
editor.setDecorations(this.insDeco[a], ins[a]);
|
||||||
|
editor.setDecorations(this.delDeco[a], del[a]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Overridden in Task 7 to add committed (non-proposal) author-colored changes. */
|
||||||
|
private decorateCommitted(
|
||||||
|
_editor: vscode.TextEditor,
|
||||||
|
_ins: Record<"human" | "claude", vscode.Range[]>,
|
||||||
|
_del: Record<"human" | "claude", vscode.DecorationOptions[]>,
|
||||||
|
): void {}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Typecheck + build**
|
||||||
|
|
||||||
|
Run: `npm run typecheck && npm run build`
|
||||||
|
Expected: clean.
|
||||||
|
|
||||||
|
- [ ] **Step 4: E2E — proposal shows Claude colors**
|
||||||
|
|
||||||
|
Update/extend the editor E2E to assert a pending proposal decorates (smoke: the decoration types exist and apply; the harness asserts via the proposal-applied path used today). Run: `npm run test:e2e` → PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/editorProposalController.ts test/e2e
|
||||||
|
git commit -m "feat: per-author editor decoration types; proposals decorate in Claude blue/purple"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Decorate committed changes-since-baseline by author (reverse INV-32)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/editorProposalController.ts` — inject baseline + attribution; implement `decorateCommitted`; subscribe to baseline/attribution change events
|
||||||
|
- Modify: `src/extension.ts` (line 122) — pass the two new deps
|
||||||
|
- Test: editor E2E
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `DiffViewController.getBaseline(uri)` (diffViewController.ts:133) + `onDidChangeBaseline` (32); `AttributionController.spansFor(document)` (attributionController.ts:405); pure `wordEditHunks` + `authorAt` (trackChangesModel.ts).
|
||||||
|
- Produces: committed insertions colored by `authorAt(currentOffset)`; committed deletion hints colored by adjacency (the paired insertion's author).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Inject the new dependencies**
|
||||||
|
|
||||||
|
Change the constructor signature (38):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
constructor(
|
||||||
|
private readonly proposals: ProposalController,
|
||||||
|
private readonly diffView: import("./diffViewController").DiffViewController,
|
||||||
|
private readonly attribution: import("./attributionController").AttributionController,
|
||||||
|
) {
|
||||||
|
```
|
||||||
|
|
||||||
|
Add subscriptions in the constructor disposables list:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
this.diffView.onDidChangeBaseline(({ uri }) => {
|
||||||
|
const ed = vscode.window.visibleTextEditors.find((e) => e.document.uri.toString() === uri);
|
||||||
|
if (ed) this.renderEditor(ed);
|
||||||
|
}),
|
||||||
|
```
|
||||||
|
|
||||||
|
In `src/extension.ts` (122) pass the deps (both already constructed above — `diffViewController` and `attributionController`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const editorProposalController = new EditorProposalController(
|
||||||
|
proposalController,
|
||||||
|
diffViewController,
|
||||||
|
attributionController,
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
(Confirm the local variable names in `extension.ts` for the diff-view + attribution controllers via `npm run typecheck`.)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Implement `decorateCommitted`**
|
||||||
|
|
||||||
|
Replace the stub with the real implementation. It diffs baseline → current, attributes insertions, and colors deletion hints by adjacency:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
private decorateCommitted(
|
||||||
|
editor: vscode.TextEditor,
|
||||||
|
ins: Record<"human" | "claude", vscode.Range[]>,
|
||||||
|
del: Record<"human" | "claude", vscode.DecorationOptions[]>,
|
||||||
|
): void {
|
||||||
|
const doc = editor.document;
|
||||||
|
const baseline = this.diffView.getBaseline(doc.uri.toString());
|
||||||
|
if (!baseline || baseline.reason === "pinned") return; // pin→clean: no committed marks
|
||||||
|
const current = doc.getText();
|
||||||
|
const spans = this.attribution.spansFor(doc); // AuthorSpan[] in current coords
|
||||||
|
const hunks = wordEditHunks(baseline.text, current); // start/end index INTO current
|
||||||
|
for (const h of hunks) {
|
||||||
|
// The replacement text occupies [h.start, h.start+replacement.length) in current.
|
||||||
|
// Re-derive the intra-hunk word diff to split ins vs del and find the author.
|
||||||
|
const original = baseline.text.slice(/* mapped */ 0, 0); // see note
|
||||||
|
const plan = decorationPlan(h.start, hunkOriginal(baseline.text, current, h), current.slice(h.start, h.end));
|
||||||
|
const author = authorAt(h.start, spans) ?? "human";
|
||||||
|
for (const i of plan.insertions) ins[author].push(new vscode.Range(doc.positionAt(i.start), doc.positionAt(i.end)));
|
||||||
|
for (const d of plan.deletions) {
|
||||||
|
del[author].push({
|
||||||
|
range: new vscode.Range(doc.positionAt(d.at), doc.positionAt(d.at)),
|
||||||
|
renderOptions: { after: { contentText: ` ${d.text} `, textDecoration: "line-through" } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Implementation note for the engineer:** `wordEditHunks(baseline, current)` returns hunks whose `start`/`end` index into **current** with a `replacement` that is the *baseline* text for that span (see trackChangesModel.ts:241-264 — `replacement` accumulates `removed` parts = baseline text, `end` advances over them). So for a committed change, the *original* (baseline) text of a hunk is `h.replacement` and the *applied* (current) text is `current.slice(h.start, h.end)`. Call `decorationPlan(h.start, h.replacement, current.slice(h.start, h.end))` directly — drop the `hunkOriginal`/`original` placeholder lines above. Verify this against a unit test before wiring (Step 3).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Unit-test the hunk→plan derivation (pure)**
|
||||||
|
|
||||||
|
Because the offset semantics are subtle, add a pure unit test in `test/trackChangesModel.test.ts` proving `wordEditHunks` + `decorationPlan` reconstruct a committed change. Add an exported helper if needed:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
test("committed change: wordEditHunks replacement is baseline text; current slice is applied", () => {
|
||||||
|
const baseline = "the light mode";
|
||||||
|
const current = "the dark mode";
|
||||||
|
const [h] = wordEditHunks(baseline, current);
|
||||||
|
expect(h.replacement).toContain("light"); // baseline side
|
||||||
|
expect(current.slice(h.start, h.end)).toContain("dark"); // applied side
|
||||||
|
const plan = decorationPlan(h.start, h.replacement, current.slice(h.start, h.end));
|
||||||
|
expect(plan.insertions.length).toBeGreaterThan(0);
|
||||||
|
expect(plan.deletions.some((d) => d.text.includes("light"))).toBe(true);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Run: `npm test -- trackChangesModel` → PASS. Then finalize `decorateCommitted` to use `h.replacement` + `current.slice(h.start, h.end)` (removing the placeholder lines).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Add the attribution-change refresh**
|
||||||
|
|
||||||
|
Editor decorations must refresh when attribution changes (e.g. after a human edit). If `AttributionController` exposes a change event, subscribe to it and call `renderEditor`; otherwise refresh on `vscode.workspace.onDidChangeTextDocument` for the active editor (debounced like `scheduleApply`). Add to the constructor disposables:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
vscode.workspace.onDidChangeTextDocument((e) => {
|
||||||
|
const ed = vscode.window.activeTextEditor;
|
||||||
|
if (ed && e.document === ed.document) this.scheduleRender(ed);
|
||||||
|
}),
|
||||||
|
```
|
||||||
|
|
||||||
|
with a small `scheduleRender(ed)` that debounces `renderEditor(ed)` (mirror `scheduleApply`, 50ms).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Typecheck + build + E2E**
|
||||||
|
|
||||||
|
Run: `npm run typecheck && npm run build && npm run test:e2e`
|
||||||
|
Expected: clean. E2E: a committed human edit shows green underline; an accepted Claude replace shows blue underline + purple struck hint; a pinned baseline shows nothing.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/editorProposalController.ts src/extension.ts test/trackChangesModel.test.ts test/e2e
|
||||||
|
git commit -m "feat: author-colored committed track changes inline in the editor (reverse INV-32)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: Overlap — stacked marks where both authors touch one span
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Test: `test/trackChangesModel.test.ts` (preview overlap) + editor E2E (editor overlap)
|
||||||
|
- Modify (if needed): `media/preview.css` (ensure `<ins>` nested in `<del>` and vice-versa render both marks)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: existing emitted classes.
|
||||||
|
- Produces: a span both inserted (author A) and being deleted (author B) shows both A's underline and B's strikethrough.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Editor overlap — verify decorations stack**
|
||||||
|
|
||||||
|
In the editor, an overlap arises when a committed insertion range (author A) is also covered by a pending proposal deletion (Claude). VS Code applies multiple decoration types to overlapping ranges, so the author-A insertion underline and the Claude deletion hint already coexist. Add an editor E2E asserting both decoration types are present on the overlap region. No code change expected; if the proposal's optimistic apply *replaces* the text (so A's range no longer exists), document that committed-insert + pending-delete overlap manifests as the proposal's struck original (Claude purple) adjacent to A's surviving insertion — assert that instead.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Preview overlap — CSS nesting**
|
||||||
|
|
||||||
|
Add a CSS test fixture / assertion: a `<del class="cw-del-claude"><span class="cw-ins-human">…</span></del>` renders with both strikethrough (Claude purple) and underline (human green). Add to `media/preview.css` if the nested case collapses:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.cw-del-claude .cw-ins-human, .cw-del-human .cw-ins-claude,
|
||||||
|
.cw-del-claude .cw-by-human, .cw-del-human .cw-by-claude { text-decoration: inherit; }
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run tests**
|
||||||
|
|
||||||
|
Run: `npm test && npm run test:e2e`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add media/preview.css test/trackChangesModel.test.ts test/e2e
|
||||||
|
git commit -m "feat: overlap renders stacked author marks (insert + delete) in both panes"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 9: Sweep retired markup + full suite + manual smoke
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `test/**`, `media/preview.css`, any code referencing retired classes.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Grep for retired classes/behaviors**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
grep -rn "cw-add\b\|cw-del\b\|diffEditor.insertedTextBackground\|gitDecoration.deletedResourceForeground" src/ media/ test/
|
||||||
|
```
|
||||||
|
Reconcile every hit: `cw-add`/`cw-del` (without `-author`) only legitimately remain in `#cw-summary`. The editor's old semantic ThemeColors are gone (replaced in Task 6).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Full unit + typecheck + build**
|
||||||
|
|
||||||
|
Run: `npm run typecheck && npm test && npm run build`
|
||||||
|
Expected: all green.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Full E2E**
|
||||||
|
|
||||||
|
Run: `npm run test:e2e`
|
||||||
|
Expected: all green (update any remaining old-class assertions).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Manual smoke (both panes)**
|
||||||
|
|
||||||
|
Open the sandbox doc, make a human edit + ask Claude to edit. Confirm: editor shows green/blue underlines + purple/red struck hints; preview shows the same; unchanged text plain in both; pin baseline → both panes clean; accept a proposal → it recolors as a committed change until re-pin.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit + open PR**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "chore: sweep retired semantic-diff markup; full suite green"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
**1. Spec coverage:**
|
||||||
|
- Model (style=op, color=author) → Tasks 1-4 (preview), 6-7 (editor). ✓
|
||||||
|
- Human green/red + Claude blue/purple, exact hexes → Global Constraints + Task 4 CSS + Task 6 decorations. ✓
|
||||||
|
- Both panes → Phase 1 (preview) + Phase 2 (editor). ✓
|
||||||
|
- Reverses INV-32 → Task 7. ✓
|
||||||
|
- Both overlap directions, stacked → Task 8. ✓
|
||||||
|
- Color = changed-since-baseline; pin→clean preserved → Task 2 (unchanged plain), Task 7 (`reason === "pinned"` guard). ✓
|
||||||
|
- Deletion authorship = adjacency heuristic; standalone neutral → Task 1 (`wordDiffByAuthor` cluster author + `cw-del-none`), Task 7 (`authorAt(h.start)`). ✓
|
||||||
|
- Components: trackChangesModel, editorProposalController, attributionController (read via spansFor), preview.css, extension.ts wiring → covered. ✓
|
||||||
|
- Testing: unit (pure render), editor decorations (E2E), host E2E, retire old assertions → Tasks 1-3, 5, 7-9. ✓
|
||||||
|
|
||||||
|
**2. Placeholder scan:** Task 7 Step 2 intentionally shows a *wrong* placeholder (`hunkOriginal`/`original`) and immediately corrects it in the implementation note + Step 3 unit test that pins the exact offsets — this is a guided derivation, not an unresolved placeholder. No other TBD/TODO.
|
||||||
|
|
||||||
|
**3. Type consistency:** `AuthorKind` (`"claude" | "human"`) used throughout; decoration maps keyed `"human" | "claude"`; `authorAt` returns `AuthorKind | null` → `?? "human"`/`?? "none"` at every call site; `ProposalView.author?: AuthorKind`; `wordDiffByAuthor(beforeRaw, afterRaw, afterStart, spans)` signature matches both its test and its `renderReview` call site.
|
||||||
|
|
||||||
|
## Execution Handoff
|
||||||
|
|
||||||
|
(Filled in after you review.)
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
# Author-colored track changes (both panes) — design
|
||||||
|
|
||||||
|
**Date:** 2026-06-26
|
||||||
|
**Status:** Design approved; ready for implementation planning
|
||||||
|
**Mockups:** `.superpowers/brainstorm/15095-1782504559/content/panes-states-v2.html`
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Today the plugin uses two unrelated color systems:
|
||||||
|
|
||||||
|
- **The diff** (proposed/changed text in both panes) uses *semantic* colors —
|
||||||
|
green = added, red = removed — regardless of who made the change.
|
||||||
|
- **Authorship** (F3) uses *author* colors (blue = Claude, green = human) but
|
||||||
|
only as a flat tint in the **preview** pane, and only on committed text, not
|
||||||
|
as a diff.
|
||||||
|
|
||||||
|
So you cannot look at a change and immediately see **who** made it. The product
|
||||||
|
goal is an experience like two humans collaborating in a track-changes document
|
||||||
|
(Google Docs suggesting mode / Word track changes): every edit is a diff, and a
|
||||||
|
glance tells you both *what changed* and *who changed it*.
|
||||||
|
|
||||||
|
## The model
|
||||||
|
|
||||||
|
Every change is rendered as a diff — insertions **and** deletions — where:
|
||||||
|
|
||||||
|
- **Style encodes the operation:** underline = inserted, strikethrough = removed.
|
||||||
|
- **Color encodes the author.**
|
||||||
|
|
||||||
|
| | Added | Removed |
|
||||||
|
| ------ | ---------------- | ---------------------- |
|
||||||
|
| Human | green underline | red strikethrough |
|
||||||
|
| Claude | blue underline | purple strikethrough |
|
||||||
|
|
||||||
|
Human keeps today's exact green/red. Claude gets blue/purple (blue is already
|
||||||
|
Claude's established attribution hue; purple reads as a removal without colliding
|
||||||
|
with human red).
|
||||||
|
|
||||||
|
**Color means "changed since the pinned baseline,"** not "authored forever." Once
|
||||||
|
a change is accepted and the baseline re-pins, its marks clear and the text
|
||||||
|
returns to plain. This preserves the existing pin→clean behavior (#48): a pinned,
|
||||||
|
zero-diff document renders with no annotation.
|
||||||
|
|
||||||
|
### Concrete colors
|
||||||
|
|
||||||
|
Carry the existing human values; add Claude's pair. (Final hex values are a
|
||||||
|
polish detail for implementation; these are the design intent.)
|
||||||
|
|
||||||
|
- Human added: `#3fb950` underline, bg `rgba(63,185,80,0.14)`
|
||||||
|
- Human removed: `#f85149` strikethrough, bg `rgba(248,81,73,0.11)`
|
||||||
|
- Claude added: `#58a6ff` underline, bg `rgba(88,166,255,0.15)`
|
||||||
|
- Claude removed: `#bc8cff` strikethrough, bg `rgba(188,140,255,0.13)`
|
||||||
|
|
||||||
|
Each change run is a subtle author-hued background tint **plus** a solid 2px
|
||||||
|
underline (insert) or strikethrough (delete) in the full author color — the solid
|
||||||
|
line carries the signal even where backgrounds are faint.
|
||||||
|
|
||||||
|
## Both panes (decision A)
|
||||||
|
|
||||||
|
Author-colored track changes appear in **both** the main editor pane and the
|
||||||
|
rendered preview pane.
|
||||||
|
|
||||||
|
- **Main editor pane:** gains full inline track-changes for *all*
|
||||||
|
changes-since-baseline — human and Claude, committed and pending — not just
|
||||||
|
pending proposals. **This reverses INV-32** ("editor fully clean") from F10:
|
||||||
|
the editor was deliberately kept clean, with all who-wrote-what coloring living
|
||||||
|
only in the preview. We are intentionally undoing that.
|
||||||
|
- **Rendered preview pane:** shows the same model (it already renders a diff +
|
||||||
|
authorship; this unifies them so ins/del runs are author-colored rather than
|
||||||
|
semantically colored).
|
||||||
|
|
||||||
|
Pending Claude proposals keep their Accept/Reject affordance in both panes —
|
||||||
|
CodeLens (`✓ Accept ▾ / ✗ Reject ▾`) in the editor, buttons in the preview.
|
||||||
|
|
||||||
|
## States (must all render correctly)
|
||||||
|
|
||||||
|
Base states: human-added, human-removed, Claude-added, Claude-removed.
|
||||||
|
|
||||||
|
**Overlap** — a single span both authors touched — stacks both marks:
|
||||||
|
|
||||||
|
- Human added it, Claude is removing it → green underline **+** purple strikethrough.
|
||||||
|
- Claude added it, human is removing it → blue underline **+** red strikethrough.
|
||||||
|
|
||||||
|
Block-level: a whole added block carries the author's add treatment (left border +
|
||||||
|
tint); a whole removed block carries the author's remove treatment (struck + tint).
|
||||||
|
|
||||||
|
Resting state: unchanged-since-baseline text renders plain. After Accept + re-pin,
|
||||||
|
changed text returns to plain.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The core change is **fusing the currently-separate semantic-diff and authorship
|
||||||
|
systems** so that every inserted/deleted run carries its author, and rendering
|
||||||
|
colors by that author rather than by add/remove semantics.
|
||||||
|
|
||||||
|
Components affected (per the current code map):
|
||||||
|
|
||||||
|
- **`src/trackChangesModel.ts`** — `renderReview` / `renderOp` /
|
||||||
|
`wordMergedMarkdown` / `colorByAuthor`. Today `<ins>`/`<del>` are emitted with
|
||||||
|
semantic green/red classes independent of author. Change: ins/del runs become
|
||||||
|
author-aware and emit author-colored classes; overlap emits a stacked-mark
|
||||||
|
class.
|
||||||
|
- **`src/editorProposalController.ts`** — today only pending proposals decorate,
|
||||||
|
using semantic `ThemeColor`s. Change: per-author/op `TextEditorDecorationType`s
|
||||||
|
(human-add/human-del/claude-add/claude-del + overlap), and decorate **all**
|
||||||
|
changes-since-baseline, not just proposals (the INV-32 reversal).
|
||||||
|
- **`src/attributionController.ts`** — supplies the per-character/per-run author
|
||||||
|
spans the diff needs to attribute insertions (and, see below, deletions).
|
||||||
|
- **`media/preview.css`** — recolor: keep human green/red, add Claude
|
||||||
|
blue/purple, encode underline=insert / strikethrough=delete, add overlap
|
||||||
|
(stacked-mark) classes for both directions.
|
||||||
|
|
||||||
|
## Open technical question (for the plan / tech-design stage)
|
||||||
|
|
||||||
|
**Deletion authorship.** Removed text is no longer in the buffer, so the renderer
|
||||||
|
must know *who deleted it* to color the strikethrough.
|
||||||
|
|
||||||
|
- For **pending proposals**, the deleter is trivially Claude (the proposal owns
|
||||||
|
the deletion).
|
||||||
|
- For **committed deletions** (baseline → current), we must confirm the F3 seam /
|
||||||
|
attribution model carries the **deleting actor**. F3 attributes
|
||||||
|
*current-buffer* characters; deleted characters are gone. If the seam's edit
|
||||||
|
events carry the actor that performed each removal, we attribute from that; if
|
||||||
|
not, deriving deletion authorship is the main thing the implementation plan has
|
||||||
|
to solve. Until resolved, a safe fallback is to color committed deletions by the
|
||||||
|
**turn actor** that produced the diff hunk.
|
||||||
|
|
||||||
|
This is the one genuinely open architectural item; everything else is
|
||||||
|
presentation.
|
||||||
|
|
||||||
|
## Out of scope (YAGNI)
|
||||||
|
|
||||||
|
- More than two authors / arbitrary per-collaborator palettes. Two roles
|
||||||
|
(human, Claude) only.
|
||||||
|
- User-configurable colors / theme settings. Ship one good scheme.
|
||||||
|
- Changing accept/reject *mechanics* — this is purely how changes are
|
||||||
|
**visualized**; the F4 proposal lifecycle is unchanged.
|
||||||
|
- Per-word authorship inside an accepted, re-pinned (plain) document.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- **Unit (pure render):** `trackChangesModel` renders each state to the expected
|
||||||
|
author-colored markup — the four base states, both overlap directions, block
|
||||||
|
add/remove, and resting/plain. Reuse the existing pure-render test pattern.
|
||||||
|
- **Editor decorations:** the decoration plan produces the right per-author/op
|
||||||
|
ranges, including the INV-32-reversal (committed changes now decorate) and
|
||||||
|
stacked overlap ranges.
|
||||||
|
- **Host E2E:** open a co-edited doc, assert both panes show author-colored
|
||||||
|
marks; accept a proposal + re-pin → marks clear (pin→clean preserved).
|
||||||
|
- Update/replace any test that asserts the **old** semantic green=add/red=remove
|
||||||
|
classes or the INV-32 clean-editor behavior.
|
||||||
+35
-20
@@ -18,19 +18,40 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
#cw-summary .cw-add { color: var(--vscode-gitDecoration-addedResourceForeground); }
|
/* Author-colored track changes — style = operation, color = author.
|
||||||
#cw-summary .cw-del { color: var(--vscode-gitDecoration-deletedResourceForeground); }
|
underline = inserted, strikethrough = removed; human green/red, Claude blue/purple. */
|
||||||
|
#cw-summary .cw-add { color: #3fb950; }
|
||||||
|
#cw-summary .cw-del { color: #f85149; }
|
||||||
.cw-blk { position: relative; }
|
.cw-blk { position: relative; }
|
||||||
ins, .cw-added {
|
|
||||||
background: var(--vscode-diffEditor-insertedTextBackground, rgba(0, 255, 0, 0.15));
|
/* base ins/del carry the operation; author classes carry the color */
|
||||||
text-decoration: none;
|
ins { text-decoration: none; }
|
||||||
}
|
del { text-decoration: line-through; opacity: 0.7; }
|
||||||
del, .cw-removed {
|
|
||||||
background: var(--vscode-diffEditor-removedTextBackground, rgba(255, 0, 0, 0.15));
|
.cw-ins-human { background: rgba(63,185,80,0.14); border-bottom: 2px solid #3fb950; text-decoration: none; }
|
||||||
text-decoration: line-through;
|
.cw-ins-claude { background: rgba(88,166,255,0.15); border-bottom: 2px solid #58a6ff; text-decoration: none; }
|
||||||
opacity: 0.7;
|
.cw-ins-none { background: var(--vscode-diffEditor-insertedTextBackground, rgba(63,185,80,0.14)); text-decoration: none; }
|
||||||
}
|
.cw-del-human { background: rgba(248,81,73,0.11); text-decoration: line-through; text-decoration-color: #f85149; }
|
||||||
.cw-changed { outline: 2px solid var(--vscode-diffEditor-insertedTextBackground, rgba(0, 255, 0, 0.25)); outline-offset: 2px; }
|
.cw-del-claude { background: rgba(188,140,255,0.13); text-decoration: line-through; text-decoration-color: #bc8cff; }
|
||||||
|
.cw-del-none { background: var(--vscode-diffEditor-removedTextBackground, rgba(248,81,73,0.11)); text-decoration: line-through; opacity: 0.7; }
|
||||||
|
|
||||||
|
/* Task 8 — overlap: if a future render path nests cw-ins-{A} inside cw-del-{B},
|
||||||
|
both marks must show. cw-ins-* sets text-decoration:none which would otherwise
|
||||||
|
suppress the parent del's strikethrough. `inherit` restores the parent's
|
||||||
|
line-through while the child's border-bottom underline is unaffected.
|
||||||
|
The engine currently emits SIBLING ins/del (never nested) so this rule is a
|
||||||
|
forward defensive guarantee only. See task-8-report.md for details. */
|
||||||
|
.cw-del-claude .cw-ins-human, .cw-del-human .cw-ins-claude { text-decoration: inherit; }
|
||||||
|
|
||||||
|
/* whole-block ops: an added block is an insertion (its author runs are emitted as
|
||||||
|
cw-ins-* via colorByAuthor with kind="ins"); a standalone removed block is neutral
|
||||||
|
struck (adjacency heuristic fallback) */
|
||||||
|
.cw-added { background: transparent; }
|
||||||
|
.cw-removed { background: var(--vscode-diffEditor-removedTextBackground, rgba(248,81,73,0.10)); text-decoration: line-through; opacity: 0.65; }
|
||||||
|
.cw-changed { outline: none; }
|
||||||
|
|
||||||
|
#cw-legend .cw-swatch { padding: 0 0.4em; border-radius: 3px; }
|
||||||
|
#cw-summary .cw-prop { opacity: 0.85; }
|
||||||
.cw-badge {
|
.cw-badge {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -52,12 +73,6 @@ del, .cw-removed {
|
|||||||
pre.mermaid { text-align: center; background: transparent; }
|
pre.mermaid { text-align: center; background: transparent; }
|
||||||
pre.mermaid[data-cw-error] { color: var(--vscode-errorForeground); }
|
pre.mermaid[data-cw-error] { color: var(--vscode-errorForeground); }
|
||||||
|
|
||||||
/* F9 authorship mode — inline author tints + block-level fence badges + toggle. */
|
|
||||||
.cw-by-claude { background: var(--vscode-editorInfo-foreground, rgba(64, 120, 242, 0.18)); text-decoration: none; }
|
|
||||||
.cw-by-human { background: var(--vscode-gitDecoration-addedResourceForeground, rgba(46, 160, 67, 0.18)); text-decoration: none; }
|
|
||||||
.cw-blk.cw-by-claude, .cw-blk.cw-by-human, .cw-blk.cw-mixed { outline: 2px solid currentColor; outline-offset: 2px; background: transparent; }
|
|
||||||
#cw-legend .cw-swatch { padding: 0 0.4em; border-radius: 3px; }
|
|
||||||
#cw-summary .cw-prop { opacity: 0.85; }
|
|
||||||
|
|
||||||
/* F11 — preview toolbar buttons (Pin baseline; adaptive Ask Claude). Theme-aware. */
|
/* F11 — preview toolbar buttons (Pin baseline; adaptive Ask Claude). Theme-aware. */
|
||||||
#cw-header button {
|
#cw-header button {
|
||||||
@@ -76,8 +91,8 @@ pre.mermaid[data-cw-error] { color: var(--vscode-errorForeground); }
|
|||||||
#cw-toggle { display: inline-flex; align-items: center; gap: 0.35em; cursor: pointer; }
|
#cw-toggle { display: inline-flex; align-items: center; gap: 0.35em; cursor: pointer; }
|
||||||
.cw-proposal {
|
.cw-proposal {
|
||||||
position: relative;
|
position: relative;
|
||||||
border-left: 3px solid var(--vscode-charts-blue, #4daafc);
|
border-left: 3px solid var(--vscode-panel-border, #555);
|
||||||
background: color-mix(in srgb, var(--vscode-charts-blue, #4daafc) 12%, transparent);
|
background: color-mix(in srgb, var(--vscode-foreground) 5%, transparent);
|
||||||
padding: 0.4em 0.6em; margin: 0.4em 0; border-radius: 3px;
|
padding: 0.4em 0.6em; margin: 0.4em 0; border-radius: 3px;
|
||||||
}
|
}
|
||||||
.cw-proposal-unanchored { border-left-style: dashed; opacity: 0.85; }
|
.cw-proposal-unanchored { border-left-style: dashed; opacity: 0.85; }
|
||||||
|
|||||||
+128
-29
@@ -7,24 +7,34 @@
|
|||||||
* for deletions (INV-52, decorationPlan/INV-49), and (3) provides a CodeLens
|
* for deletions (INV-52, decorationPlan/INV-49), and (3) provides a CodeLens
|
||||||
* `Accept ▾ / Reject ▾` above each block whose ▾ opens a QuickPick (this / all).
|
* `Accept ▾ / Reject ▾` above each block whose ▾ opens a QuickPick (this / all).
|
||||||
* Owns no proposal STATE — it is a view over ProposalController (which stays the
|
* Owns no proposal STATE — it is a view over ProposalController (which stays the
|
||||||
* pure F4 owner). A document with no pending proposals shows nothing (INV-32's
|
* pure F4 owner). Phase 2 decorates ALL committed changes-since-baseline by author
|
||||||
* spirit when nothing is pending).
|
* (human green / Claude blue + struck hints for deletions), plus pending proposals;
|
||||||
|
* a pinned baseline suppresses committed marks entirely (pin→clean, INV-48).
|
||||||
*/
|
*/
|
||||||
import * as vscode from "vscode";
|
import * as vscode from "vscode";
|
||||||
import type { ProposalController } from "./proposalController";
|
import type { ProposalController } from "./proposalController";
|
||||||
import { decorationPlan } from "./trackChangesModel";
|
import type { DiffViewController } from "./diffViewController";
|
||||||
|
import type { AttributionController } from "./attributionController";
|
||||||
|
import { decorationPlan, wordEditHunks, authorAt } from "./trackChangesModel";
|
||||||
import { isAuthorable } from "./workspacePath";
|
import { isAuthorable } from "./workspacePath";
|
||||||
|
|
||||||
export class EditorProposalController implements vscode.Disposable, vscode.CodeLensProvider {
|
export class EditorProposalController implements vscode.Disposable, vscode.CodeLensProvider {
|
||||||
private readonly disposables: vscode.Disposable[] = [];
|
private readonly disposables: vscode.Disposable[] = [];
|
||||||
private readonly insertionDeco = vscode.window.createTextEditorDecorationType({
|
private readonly insDeco: Record<"human" | "claude", vscode.TextEditorDecorationType> = {
|
||||||
backgroundColor: new vscode.ThemeColor("diffEditor.insertedTextBackground"),
|
human: vscode.window.createTextEditorDecorationType({
|
||||||
});
|
backgroundColor: "rgba(63,185,80,0.14)", borderColor: "#3fb950",
|
||||||
private readonly deletionDeco = vscode.window.createTextEditorDecorationType({
|
border: "0 0 2px 0", borderStyle: "solid",
|
||||||
// a non-editable struck-red hint injected AFTER the insertion (INV-52)
|
}),
|
||||||
after: { color: new vscode.ThemeColor("gitDecoration.deletedResourceForeground") },
|
claude: vscode.window.createTextEditorDecorationType({
|
||||||
textDecoration: "none",
|
backgroundColor: "rgba(88,166,255,0.15)", borderColor: "#58a6ff",
|
||||||
});
|
border: "0 0 2px 0", borderStyle: "solid",
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
private readonly delDeco: Record<"human" | "claude" | "none", vscode.TextEditorDecorationType> = {
|
||||||
|
human: vscode.window.createTextEditorDecorationType({ after: { color: "#f85149" } }),
|
||||||
|
claude: vscode.window.createTextEditorDecorationType({ after: { color: "#bc8cff" } }),
|
||||||
|
none: vscode.window.createTextEditorDecorationType({ after: { color: new vscode.ThemeColor("descriptionForeground") } }),
|
||||||
|
};
|
||||||
private readonly lensEmitter = new vscode.EventEmitter<void>();
|
private readonly lensEmitter = new vscode.EventEmitter<void>();
|
||||||
readonly onDidChangeCodeLenses = this.lensEmitter.event;
|
readonly onDidChangeCodeLenses = this.lensEmitter.event;
|
||||||
/** Pending debounce timers — one per URI — coalesce rapid-fire propose events
|
/** Pending debounce timers — one per URI — coalesce rapid-fire propose events
|
||||||
@@ -34,13 +44,29 @@ export class EditorProposalController implements vscode.Disposable, vscode.CodeL
|
|||||||
* optimisticApply runs concurrently with the still-in-progress propose loop,
|
* optimisticApply runs concurrently with the still-in-progress propose loop,
|
||||||
* causing "file changed in the meantime" workspace-edit conflicts. */
|
* causing "file changed in the meantime" workspace-edit conflicts. */
|
||||||
private readonly pendingApply = new Map<string, ReturnType<typeof setTimeout>>();
|
private readonly pendingApply = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
|
/** Debounce timers for committed-change re-render (one per URI, 50 ms). */
|
||||||
|
private readonly pendingRender = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
constructor(private readonly proposals: ProposalController) {
|
constructor(
|
||||||
|
private readonly proposals: ProposalController,
|
||||||
|
private readonly diffView: DiffViewController,
|
||||||
|
private readonly attribution: AttributionController,
|
||||||
|
) {
|
||||||
this.disposables.push(
|
this.disposables.push(
|
||||||
this.insertionDeco, this.deletionDeco, this.lensEmitter,
|
this.insDeco.human, this.insDeco.claude, this.delDeco.human, this.delDeco.claude, this.delDeco.none, this.lensEmitter,
|
||||||
vscode.languages.registerCodeLensProvider({ language: "markdown" }, this),
|
vscode.languages.registerCodeLensProvider({ language: "markdown" }, this),
|
||||||
this.proposals.onDidChangeProposals(({ uri }) => this.scheduleApply(uri)),
|
this.proposals.onDidChangeProposals(({ uri }) => this.scheduleApply(uri)),
|
||||||
vscode.window.onDidChangeActiveTextEditor((ed) => ed && this.renderEditor(ed)),
|
vscode.window.onDidChangeActiveTextEditor((ed) => ed && this.renderEditor(ed)),
|
||||||
|
// Refresh committed decorations when the baseline advances (pin / machine-landing / open).
|
||||||
|
this.diffView.onDidChangeBaseline(({ uri }) => {
|
||||||
|
const ed = vscode.window.visibleTextEditors.find((e) => e.document.uri.toString() === uri);
|
||||||
|
if (ed) this.renderEditor(ed);
|
||||||
|
}),
|
||||||
|
// Refresh committed decorations (debounced) when the active doc changes — attribution spans shift.
|
||||||
|
vscode.workspace.onDidChangeTextDocument((e) => {
|
||||||
|
const ed = vscode.window.activeTextEditor;
|
||||||
|
if (ed && e.document === ed.document) this.scheduleRender(ed);
|
||||||
|
}),
|
||||||
// the four QuickPick-backed menu commands
|
// the four QuickPick-backed menu commands
|
||||||
vscode.commands.registerCommand("cowriting.proposalAcceptMenu", (id?: string) => this.menu("accept", id)),
|
vscode.commands.registerCommand("cowriting.proposalAcceptMenu", (id?: string) => this.menu("accept", id)),
|
||||||
vscode.commands.registerCommand("cowriting.proposalRejectMenu", (id?: string) => this.menu("reject", id)),
|
vscode.commands.registerCommand("cowriting.proposalRejectMenu", (id?: string) => this.menu("reject", id)),
|
||||||
@@ -62,6 +88,20 @@ export class EditorProposalController implements vscode.Disposable, vscode.CodeL
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Debounce a committed-change re-render for `editor` (50 ms — lets rapid typing settle). */
|
||||||
|
private scheduleRender(editor: vscode.TextEditor): void {
|
||||||
|
const uri = editor.document.uri.toString();
|
||||||
|
const prev = this.pendingRender.get(uri);
|
||||||
|
if (prev !== undefined) clearTimeout(prev);
|
||||||
|
this.pendingRender.set(
|
||||||
|
uri,
|
||||||
|
setTimeout(() => {
|
||||||
|
this.pendingRender.delete(uri);
|
||||||
|
this.renderEditor(editor);
|
||||||
|
}, 50),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Apply any not-yet-applied proposals on the doc, then re-decorate + refresh lenses. */
|
/** Apply any not-yet-applied proposals on the doc, then re-decorate + refresh lenses. */
|
||||||
private async onProposalsChanged(uri: string): Promise<void> {
|
private async onProposalsChanged(uri: string): Promise<void> {
|
||||||
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri);
|
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri);
|
||||||
@@ -80,29 +120,86 @@ export class EditorProposalController implements vscode.Disposable, vscode.CodeL
|
|||||||
/** Decorate the editor for every applied proposal on its document (INV-52). */
|
/** Decorate the editor for every applied proposal on its document (INV-52). */
|
||||||
private renderEditor(editor: vscode.TextEditor): void {
|
private renderEditor(editor: vscode.TextEditor): void {
|
||||||
const doc = editor.document;
|
const doc = editor.document;
|
||||||
if (doc.languageId !== "markdown") {
|
const clearAll = () => {
|
||||||
editor.setDecorations(this.insertionDeco, []);
|
for (const a of ["human", "claude"] as const) editor.setDecorations(this.insDeco[a], []);
|
||||||
editor.setDecorations(this.deletionDeco, []);
|
for (const a of ["human", "claude", "none"] as const) editor.setDecorations(this.delDeco[a], []);
|
||||||
return;
|
};
|
||||||
}
|
if (doc.languageId !== "markdown") return clearAll();
|
||||||
const key = this.proposals.keyFor(doc);
|
const key = this.proposals.keyFor(doc);
|
||||||
const insertions: vscode.Range[] = [];
|
const ins: Record<"human" | "claude", vscode.Range[]> = { human: [], claude: [] };
|
||||||
const deletions: vscode.DecorationOptions[] = [];
|
const del: Record<"human" | "claude" | "none", vscode.DecorationOptions[]> = { human: [], claude: [], none: [] };
|
||||||
|
// Pending proposals — always Claude (INV: proposals are agent-authored).
|
||||||
for (const v of this.proposals.listProposals(doc)) {
|
for (const v of this.proposals.listProposals(doc)) {
|
||||||
if (v.anchorStart === null || v.original === undefined || !this.proposals.isApplied(key, v.id)) continue;
|
if (v.anchorStart === null || v.original === undefined || !this.proposals.isApplied(key, v.id)) continue;
|
||||||
const plan = decorationPlan(v.anchorStart, v.original, v.replacement);
|
const plan = decorationPlan(v.anchorStart, v.original, v.replacement);
|
||||||
for (const ins of plan.insertions) {
|
for (const i of plan.insertions) ins.claude.push(new vscode.Range(doc.positionAt(i.start), doc.positionAt(i.end)));
|
||||||
insertions.push(new vscode.Range(doc.positionAt(ins.start), doc.positionAt(ins.end)));
|
for (const d of plan.deletions) {
|
||||||
}
|
del.claude.push({
|
||||||
for (const del of plan.deletions) {
|
range: new vscode.Range(doc.positionAt(d.at), doc.positionAt(d.at)),
|
||||||
deletions.push({
|
renderOptions: { after: { contentText: ` ${d.text} `, textDecoration: "line-through" } },
|
||||||
range: new vscode.Range(doc.positionAt(del.at), doc.positionAt(del.at)),
|
});
|
||||||
renderOptions: { after: { contentText: ` ${del.text} `, textDecoration: "line-through" } },
|
}
|
||||||
|
}
|
||||||
|
// Committed changes-since-baseline are added by decorateCommitted (pushes into ins/del[author]).
|
||||||
|
this.decorateCommitted(editor, ins, del);
|
||||||
|
for (const a of ["human", "claude"] as const) editor.setDecorations(this.insDeco[a], ins[a]);
|
||||||
|
for (const a of ["human", "claude", "none"] as const) editor.setDecorations(this.delDeco[a], del[a]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decorates committed (non-proposal) changes-since-baseline by author (Task 7 / reverse INV-32).
|
||||||
|
* Diffs current buffer against the F6 baseline; attributes each changed run to its author;
|
||||||
|
* skips any hunk overlapping an applied pending proposal (the proposal loop owns those).
|
||||||
|
* pin→clean: returns without marking when baseline.reason === "pinned".
|
||||||
|
* Adjacency heuristic: a deletion in a hunk that has insertions inherits the hunk author;
|
||||||
|
* a standalone deletion (hunk with no insertions) renders neutral ("none") — matching the
|
||||||
|
* preview's cw-del-none treatment.
|
||||||
|
*/
|
||||||
|
private decorateCommitted(
|
||||||
|
editor: vscode.TextEditor,
|
||||||
|
ins: Record<"human" | "claude", vscode.Range[]>,
|
||||||
|
del: Record<"human" | "claude" | "none", vscode.DecorationOptions[]>,
|
||||||
|
): void {
|
||||||
|
const doc = editor.document;
|
||||||
|
const baseline = this.diffView.getBaseline(doc.uri.toString());
|
||||||
|
// No baseline yet, or baseline was pinned (pin→clean: no committed marks, INV-48).
|
||||||
|
if (!baseline || baseline.reason === "pinned") return;
|
||||||
|
const current = doc.getText();
|
||||||
|
// Nothing changed — short-circuit before the diff.
|
||||||
|
if (current === baseline.text) return;
|
||||||
|
const spans = this.attribution.spansFor(doc);
|
||||||
|
const key = this.proposals.keyFor(doc);
|
||||||
|
// Build the set of current-buffer ranges owned by applied pending proposals so we
|
||||||
|
// can skip hunks that fall inside them — the proposal loop already decorates those.
|
||||||
|
const appliedRanges: { start: number; end: number }[] = [];
|
||||||
|
for (const v of this.proposals.listProposals(doc)) {
|
||||||
|
if (v.anchorStart !== null && v.original !== undefined && this.proposals.isApplied(key, v.id)) {
|
||||||
|
appliedRanges.push({ start: v.anchorStart, end: v.anchorStart + v.replacement.length });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// wordEditHunks(current, baseline.text): start/end are in current coords; replacement is baseline text.
|
||||||
|
const hunks = wordEditHunks(current, baseline.text);
|
||||||
|
for (const h of hunks) {
|
||||||
|
// Skip hunks whose current-buffer range overlaps an applied pending proposal.
|
||||||
|
// The skip is whole-hunk and therefore conservative: a committed edit word-merged
|
||||||
|
// into a proposal's hunk is skipped entirely — rare at word granularity.
|
||||||
|
if (appliedRanges.some((r) => h.start < r.end && h.end > r.start)) continue;
|
||||||
|
const author = authorAt(h.start, spans) ?? "human";
|
||||||
|
// h.replacement = baseline text for this span; current.slice(h.start, h.end) = applied text.
|
||||||
|
const plan = decorationPlan(h.start, h.replacement, current.slice(h.start, h.end));
|
||||||
|
for (const i of plan.insertions) {
|
||||||
|
ins[author].push(new vscode.Range(doc.positionAt(i.start), doc.positionAt(i.end)));
|
||||||
|
}
|
||||||
|
// Adjacency heuristic: a deletion paired with an insertion in THIS hunk inherits the
|
||||||
|
// insertion author; a standalone deletion (no insertion in the hunk) is neutral.
|
||||||
|
const delAuthor = plan.insertions.length > 0 ? author : "none";
|
||||||
|
for (const d of plan.deletions) {
|
||||||
|
del[delAuthor].push({
|
||||||
|
range: new vscode.Range(doc.positionAt(d.at), doc.positionAt(d.at)),
|
||||||
|
renderOptions: { after: { contentText: ` ${d.text} `, textDecoration: "line-through" } },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
editor.setDecorations(this.insertionDeco, insertions);
|
|
||||||
editor.setDecorations(this.deletionDeco, deletions);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** CodeLensProvider: a `Accept ▾` / `Reject ▾` pair above each applied block. */
|
/** CodeLensProvider: a `Accept ▾` / `Reject ▾` pair above each applied block. */
|
||||||
@@ -146,6 +243,8 @@ export class EditorProposalController implements vscode.Disposable, vscode.CodeL
|
|||||||
dispose(): void {
|
dispose(): void {
|
||||||
for (const t of this.pendingApply.values()) clearTimeout(t);
|
for (const t of this.pendingApply.values()) clearTimeout(t);
|
||||||
this.pendingApply.clear();
|
this.pendingApply.clear();
|
||||||
|
for (const t of this.pendingRender.values()) clearTimeout(t);
|
||||||
|
this.pendingRender.clear();
|
||||||
for (const d of this.disposables) d.dispose();
|
for (const d of this.disposables) d.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -119,7 +119,7 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
|||||||
|
|
||||||
// --- F12 (#64): the editor surface — optimistic-apply proposals into the buffer,
|
// --- F12 (#64): the editor surface — optimistic-apply proposals into the buffer,
|
||||||
// decorate the diff, and provide Accept ▾/Reject ▾ CodeLens (INV-48/49/52/53). ---
|
// decorate the diff, and provide Accept ▾/Reject ▾ CodeLens (INV-48/49/52/53). ---
|
||||||
const editorProposalController = new EditorProposalController(proposalController);
|
const editorProposalController = new EditorProposalController(proposalController, diffViewController, attributionController);
|
||||||
context.subscriptions.push(editorProposalController);
|
context.subscriptions.push(editorProposalController);
|
||||||
|
|
||||||
// #46 (INV-42): accept every pending proposal on the active doc in one gesture
|
// #46 (INV-42): accept every pending proposal on the active doc in one gesture
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ export class ProposalController implements vscode.Disposable {
|
|||||||
replaced: p.original ?? fp?.text ?? "",
|
replaced: p.original ?? fp?.text ?? "",
|
||||||
replacement: p.replacement,
|
replacement: p.replacement,
|
||||||
original: p.original,
|
original: p.original,
|
||||||
|
author: p.author?.kind === "agent" ? "claude" : "human",
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+142
-32
@@ -474,6 +474,76 @@ function wordMergedMarkdown(beforeRaw: string, afterRaw: string): string {
|
|||||||
.join("");
|
.join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The author whose span covers `offset` (half-open [start,end)), or null. */
|
||||||
|
export function authorAt(offset: number, spans: AuthorSpan[]): AuthorKind | null {
|
||||||
|
for (const s of spans) if (offset >= s.start && offset < s.end) return s.author;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const insClass = (a: AuthorKind | null): string => `cw-ins-${a ?? "none"}`;
|
||||||
|
const delClass = (a: AuthorKind | null): string => `cw-del-${a ?? "none"}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Author-colored word diff for a changed PROSE block in the review.
|
||||||
|
* `afterStart` is the landed-text offset of `afterRaw[0]` so an inserted run's
|
||||||
|
* offset maps into `spans`. Insertions are colored by the author covering their
|
||||||
|
* offset; deletions inherit the author of the insertion they are paired with in
|
||||||
|
* the same contiguous change cluster (adjacency heuristic) — `cw-del-none` when a
|
||||||
|
* cluster has no insertion. Pure, deterministic.
|
||||||
|
*/
|
||||||
|
export function wordDiffByAuthor(
|
||||||
|
beforeRaw: string,
|
||||||
|
afterRaw: string,
|
||||||
|
afterStart: number,
|
||||||
|
spans: AuthorSpan[],
|
||||||
|
): string {
|
||||||
|
const parts = diffWordsWithSpace(beforeRaw, afterRaw);
|
||||||
|
// First pass: find each change cluster's insertion author (first added run).
|
||||||
|
// A cluster is a maximal run of added/removed parts bounded by unchanged parts.
|
||||||
|
let afterOff = afterStart;
|
||||||
|
const clusterAuthor: (AuthorKind | null)[] = []; // per part index, the cluster's del author
|
||||||
|
{
|
||||||
|
let off = afterStart;
|
||||||
|
let i = 0;
|
||||||
|
while (i < parts.length) {
|
||||||
|
const p = parts[i];
|
||||||
|
if (!p.added && !p.removed) {
|
||||||
|
clusterAuthor[i] = null;
|
||||||
|
off += p.value.length;
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// a cluster: scan to its end, capturing the first added run's author
|
||||||
|
let j = i;
|
||||||
|
let author: AuthorKind | null = null;
|
||||||
|
let scanOff = off;
|
||||||
|
while (j < parts.length && (parts[j].added || parts[j].removed)) {
|
||||||
|
if (parts[j].added && author === null) author = authorAt(scanOff, spans);
|
||||||
|
if (parts[j].added) scanOff += parts[j].value.length;
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
for (let k = i; k < j; k++) clusterAuthor[k] = author;
|
||||||
|
off = scanOff;
|
||||||
|
i = j;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Second pass: emit.
|
||||||
|
const out: string[] = [];
|
||||||
|
parts.forEach((p, i) => {
|
||||||
|
if (p.added) {
|
||||||
|
const a = authorAt(afterOff, spans);
|
||||||
|
out.push(`<ins class="${insClass(a)}">${p.value}</ins>`);
|
||||||
|
afterOff += p.value.length;
|
||||||
|
} else if (p.removed) {
|
||||||
|
out.push(`<del class="${delClass(clusterAuthor[i] ?? null)}">${p.value}</del>`);
|
||||||
|
} else {
|
||||||
|
out.push(p.value);
|
||||||
|
afterOff += p.value.length;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return out.join("");
|
||||||
|
}
|
||||||
|
|
||||||
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;
|
||||||
@@ -572,15 +642,6 @@ function isCloseSentinel(m: string): boolean {
|
|||||||
return m === SENT.claude.close || m === SENT.human.close;
|
return m === SENT.claude.close || m === SENT.human.close;
|
||||||
}
|
}
|
||||||
|
|
||||||
function authorBadge(authors: Set<AuthorKind>): { cls: string; label: string } | null {
|
|
||||||
if (authors.size === 0) return null;
|
|
||||||
if (authors.size > 1) return { cls: "cw-mixed", label: "mixed" };
|
|
||||||
const only = [...authors][0];
|
|
||||||
return only === "claude"
|
|
||||||
? { cls: "cw-by-claude", label: "Claude" }
|
|
||||||
: { cls: "cw-by-human", label: "You" };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Markdown emphasis / code delimiters whose RUNS must never be split by an
|
// Markdown emphasis / code delimiters whose RUNS must never be split by an
|
||||||
// injected sentinel — a sentinel between two run chars (e.g. `*|*`) breaks
|
// injected sentinel — a sentinel between two run chars (e.g. `*|*`) breaks
|
||||||
// markdown-it's delimiter pairing (#33 CASE1).
|
// markdown-it's delimiter pairing (#33 CASE1).
|
||||||
@@ -601,11 +662,38 @@ function clampOffDelimiterRun(raw: string, at: number): number {
|
|||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the offset within `raw` after block-level markdown markers (ATX
|
||||||
|
* headings, list markers, blockquote markers). Open sentinels must not be
|
||||||
|
* injected before these markers — a PUA char at position 0 would prevent
|
||||||
|
* markdown-it from recognising the block construct (e.g. `##` is only a
|
||||||
|
* heading marker when it is the very first char on the line).
|
||||||
|
*/
|
||||||
|
function blockContentStart(raw: string): number {
|
||||||
|
// ATX headings: "#{1,6} " at the start of the line.
|
||||||
|
const heading = raw.match(/^#{1,6} /);
|
||||||
|
if (heading) return heading[0].length;
|
||||||
|
// Unordered list markers: "- ", "* ", "+ ".
|
||||||
|
const ul = raw.match(/^[-*+] /);
|
||||||
|
if (ul) return ul[0].length;
|
||||||
|
// Ordered list markers: "N. " or "N) ".
|
||||||
|
const ol = raw.match(/^\d+[.)]\s+/);
|
||||||
|
if (ol) return ol[0].length;
|
||||||
|
// Blockquotes: "> " or ">".
|
||||||
|
const bq = raw.match(/^> ?/);
|
||||||
|
if (bq) return bq[0].length;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
/** Inject paired sentinels into a prose block's raw text for the spans clipped to it. */
|
/** Inject paired sentinels into a prose block's raw text for the spans clipped to it. */
|
||||||
function injectSentinels(raw: string, blockStart: number, spans: AuthorSpan[]): string {
|
function injectSentinels(raw: string, blockStart: number, spans: AuthorSpan[]): string {
|
||||||
const inserts: { at: number; marker: string }[] = [];
|
const inserts: { at: number; marker: string }[] = [];
|
||||||
|
// Open sentinels must not precede block-level markdown markers (heading/list/
|
||||||
|
// blockquote syntax at position 0) — a PUA char before "## " prevents markdown-it
|
||||||
|
// from recognising the heading. Clamp to the content-start offset.
|
||||||
|
const minOpen = blockContentStart(raw);
|
||||||
for (const s of spans) {
|
for (const s of spans) {
|
||||||
const lo = clampOffDelimiterRun(raw, Math.max(0, s.start - blockStart));
|
const lo = Math.max(minOpen, clampOffDelimiterRun(raw, Math.max(0, s.start - blockStart)));
|
||||||
const hi = clampOffDelimiterRun(raw, Math.min(raw.length, s.end - blockStart));
|
const hi = clampOffDelimiterRun(raw, Math.min(raw.length, s.end - blockStart));
|
||||||
if (hi <= lo) continue; // empty (or clamped to empty) span contributes nothing
|
if (hi <= lo) continue; // empty (or clamped to empty) span contributes nothing
|
||||||
inserts.push({ at: lo, marker: SENT[s.author].open });
|
inserts.push({ at: lo, marker: SENT[s.author].open });
|
||||||
@@ -632,7 +720,7 @@ const ALL_SENTINELS = new RegExp(
|
|||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* #33: token-aware replacement of the rendered author sentinels with `cw-by-*`
|
* #33: token-aware replacement of the rendered author sentinels with `cw-ins-*`
|
||||||
* spans. A naive global string-replace (the old approach) could emit a span that
|
* spans. A naive global string-replace (the old approach) could emit a span that
|
||||||
* CROSSES an element boundary — `<span><strong>bo</span>ld</strong>` — when a
|
* CROSSES an element boundary — `<span><strong>bo</span>ld</strong>` — when a
|
||||||
* boundary fell inside an emphasis run (CASE3). This walks the rendered HTML and
|
* boundary fell inside an emphasis run (CASE3). This walks the rendered HTML and
|
||||||
@@ -641,13 +729,13 @@ const ALL_SENTINELS = new RegExp(
|
|||||||
* (one `<span>` segment per text run). Tags are copied verbatim (with any stray
|
* (one `<span>` segment per text run). Tags are copied verbatim (with any stray
|
||||||
* sentinel stripped, so no Private-Use-Area char ever leaks). Pure, deterministic.
|
* sentinel stripped, so no Private-Use-Area char ever leaks). Pure, deterministic.
|
||||||
*/
|
*/
|
||||||
function sentinelsToSpans(html: string): string {
|
function sentinelsToSpans(html: string, kind: "by" | "ins" = "by"): string {
|
||||||
const out: string[] = [];
|
const out: string[] = [];
|
||||||
let current: AuthorKind | null = null; // which author region we're inside
|
let current: AuthorKind | null = null; // which author region we're inside
|
||||||
let spanOpen = false; // whether a <span> is currently open in `out`
|
let spanOpen = false; // whether a <span> is currently open in `out`
|
||||||
const openSpan = () => {
|
const openSpan = () => {
|
||||||
if (current && !spanOpen) {
|
if (current && !spanOpen) {
|
||||||
out.push(`<span class="cw-by-${current}">`);
|
out.push(`<span class="cw-${kind}-${current}">`);
|
||||||
spanOpen = true;
|
spanOpen = true;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -695,10 +783,11 @@ export function colorByAuthor(
|
|||||||
blockStart: number,
|
blockStart: number,
|
||||||
spans: AuthorSpan[],
|
spans: AuthorSpan[],
|
||||||
render: (src: string) => string,
|
render: (src: string) => string,
|
||||||
|
kind: "by" | "ins" = "by",
|
||||||
): string {
|
): string {
|
||||||
const overlapping = spans.filter((s) => s.end > blockStart && s.start < blockStart + raw.length);
|
const overlapping = spans.filter((s) => s.end > blockStart && s.start < blockStart + raw.length);
|
||||||
const injected = injectSentinels(raw, blockStart, overlapping);
|
const injected = injectSentinels(raw, blockStart, overlapping);
|
||||||
return sentinelsToSpans(render(injected));
|
return sentinelsToSpans(render(injected), kind);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -739,6 +828,8 @@ export interface ProposalView {
|
|||||||
replacement: string;
|
replacement: string;
|
||||||
/** F12/#64 (INV-48): the pre-apply original, set after optimistic apply. */
|
/** F12/#64 (INV-48): the pre-apply original, set after optimistic apply. */
|
||||||
original?: string;
|
original?: string;
|
||||||
|
/** Who made the proposal — colors the del/ins. Defaults to "claude". */
|
||||||
|
author?: AuthorKind;
|
||||||
}
|
}
|
||||||
|
|
||||||
function proposalBlockHtml(p: ProposalView, render: (src: string) => string): string {
|
function proposalBlockHtml(p: ProposalView, render: (src: string) => string): string {
|
||||||
@@ -750,8 +841,9 @@ function proposalBlockHtml(p: ProposalView, render: (src: string) => string): st
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
const unanchored = p.anchorStart === null ? " cw-proposal-unanchored" : "";
|
const unanchored = p.anchorStart === null ? " cw-proposal-unanchored" : "";
|
||||||
const before = p.replaced ? `<del class="cw-del">${safe(p.replaced)}</del>` : "";
|
const who = p.author ?? "claude";
|
||||||
const after = `<ins class="cw-add">${safe(p.replacement)}</ins>`;
|
const before = p.replaced ? `<del class="cw-del-${who}">${safe(p.replaced)}</del>` : "";
|
||||||
|
const after = `<ins class="cw-ins-${who}">${safe(p.replacement)}</ins>`;
|
||||||
const actions =
|
const actions =
|
||||||
`<span class="cw-actions">` +
|
`<span class="cw-actions">` +
|
||||||
`<span class="cw-btngroup">` +
|
`<span class="cw-btngroup">` +
|
||||||
@@ -769,20 +861,27 @@ function proposalBlockHtml(p: ProposalView, render: (src: string) => string): st
|
|||||||
function renderReviewOp(
|
function renderReviewOp(
|
||||||
op: BlockOp,
|
op: BlockOp,
|
||||||
render: (src: string) => string,
|
render: (src: string) => string,
|
||||||
colored: (raw: string) => string,
|
|
||||||
src: string,
|
src: string,
|
||||||
|
changedHtml?: string,
|
||||||
): string {
|
): string {
|
||||||
// removed blocks and any changed block (atomic fences diffed whole; non-atomic prose
|
// A non-atomic changed prose block is pre-rendered with author-colored ins/del
|
||||||
// word-merged) render via renderOp — no author sentinels (deletions neutral, spec §6.7).
|
// (changedHtml). Removed blocks and atomic changed fences stay neutral via renderOp
|
||||||
// `src` is "" for a removed block (no live source — INV-36).
|
// (standalone deletion → neutral, per the adjacency heuristic). `src` is "" for a
|
||||||
|
// removed block (no live source — INV-36).
|
||||||
|
if (op.kind === "changed" && !op.atomic && changedHtml !== undefined) {
|
||||||
|
return `<div class="cw-blk cw-changed"${src}>${changedHtml}</div>`;
|
||||||
|
}
|
||||||
if (op.kind === "removed" || op.kind === "changed") return renderOp(op, render, src);
|
if (op.kind === "removed" || op.kind === "changed") return renderOp(op, render, src);
|
||||||
return `<div class="cw-blk ${op.kind === "added" ? "cw-added" : "cw-unchanged"}"${src}>${colored(op.block.raw)}</div>`;
|
// "added": changedHtml is colorByAuthor sentinel output (markdown-safe, cw-ins-*).
|
||||||
|
// "unchanged": render(raw) — plain, no author coloring (color = "changed since baseline").
|
||||||
|
return `<div class="cw-blk ${op.kind === "added" ? "cw-added" : "cw-unchanged"}"${src}>${changedHtml ?? render(op.block.raw)}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* On-state body (INV-33): the F7 baseline diff — added/changed PROSE author-colored
|
* On-state body: the F7 baseline diff — added/changed PROSE author-colored via
|
||||||
* via colorByAuthor (F9 sentinels), deletions struck — overlaid with F4 pending
|
* wordDiffByAuthor (cw-ins-{author}/cw-del-{author} per token), unchanged blocks render plain,
|
||||||
* proposals as blue cw-proposal blocks (✓/✗). One pass, pure, vscode-free.
|
* deletions struck neutral — overlaid with F4 pending proposals as blue cw-proposal
|
||||||
|
* blocks (✓/✗). One pass, pure, vscode-free.
|
||||||
*
|
*
|
||||||
* A resolved proposal renders INLINE, right after the current-side block its anchor
|
* A resolved proposal renders INLINE, right after the current-side block its anchor
|
||||||
* falls in (#31) — so "what is Claude proposing, and where?" is answered by
|
* falls in (#31) — so "what is Claude proposing, and where?" is answered by
|
||||||
@@ -833,11 +932,9 @@ export function renderReview(
|
|||||||
const ops = diffBlocks(baselineText, landedText);
|
const ops = diffBlocks(baselineText, landedText);
|
||||||
// #48: right after a PIN (baseline reason "pinned") with no changes since, the
|
// #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
|
// 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
|
// coloring, so the pin reads as "this is my clean starting point". Suppress
|
||||||
// colorByAuthor for every block in this case; data-src mapping and any pending
|
// wordDiffByAuthor for every block in this case; data-src mapping and any pending
|
||||||
// proposals (review actions, not annotations) are kept below. A baseline advanced
|
// proposals (review actions, not annotations) are kept below.
|
||||||
// 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");
|
const clean = opts.pinned === true && ops.every((o) => o.kind === "unchanged");
|
||||||
|
|
||||||
// Map a currentText offset to its landedText offset (account for reverted spans
|
// Map a currentText offset to its landedText offset (account for reverted spans
|
||||||
@@ -889,9 +986,22 @@ export function renderReview(
|
|||||||
for (const op of ops) {
|
for (const op of ops) {
|
||||||
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 =>
|
// Only ADDED and CHANGED prose blocks are author-colored (they differ since baseline);
|
||||||
blk && !clean ? colorByAuthor(raw, blk.start, landedSpans, render) : render(raw);
|
// UNCHANGED blocks render plain (color means "changed since baseline").
|
||||||
bodyParts.push(renderReviewOp(op, render, colored, srcAttr(blk)));
|
// A non-atomic changed prose block: author-colored word diff (ins by author,
|
||||||
|
// del by adjacency). An added block: sentinel-safe colorByAuthor with kind="ins"
|
||||||
|
// (markdown-it parses the block first, so ## → <h2> before sentinels become spans;
|
||||||
|
// the old wordDiffByAuthor path wrapped raw in <ins> BEFORE parsing — structural bug).
|
||||||
|
// `blk.start` is the landed offset of the after-side text.
|
||||||
|
const changedHtml =
|
||||||
|
blk && !clean
|
||||||
|
? op.kind === "changed" && !op.atomic
|
||||||
|
? render(wordDiffByAuthor(op.before.raw, op.block.raw, blk.start, landedSpans))
|
||||||
|
: op.kind === "added"
|
||||||
|
? colorByAuthor(op.block.raw, blk.start, landedSpans, render, "ins")
|
||||||
|
: undefined
|
||||||
|
: undefined;
|
||||||
|
bodyParts.push(renderReviewOp(op, render, srcAttr(blk), changedHtml));
|
||||||
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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,15 +16,16 @@ async function getApi(): Promise<CowritingApi> {
|
|||||||
|
|
||||||
// F10 host E2E (no LLM): the rewrite of the obsolete F9 authorship-mode test.
|
// F10 host E2E (no LLM): the rewrite of the obsolete F9 authorship-mode test.
|
||||||
// F9's "authorship" mode / renderAuthorship is gone — the on-state renderReview
|
// F9's "authorship" mode / renderAuthorship is gone — the on-state renderReview
|
||||||
// now author-colors Claude's landed prose. This suite confirms a Claude-landed
|
// now author-colors Claude's PENDING proposal blocks as cw-ins-claude. After the
|
||||||
// span renders as a cw-by-claude span in the on-state preview HTML. Owns its own
|
// proposal is accepted the baseline advances (INV-18/F12) and the text becomes
|
||||||
// markdown doc, disjoint from the other suites' fixtures.
|
// "unchanged" → renders plain. The class to check is cw-ins-claude (proposal block),
|
||||||
suite("F10 review preview — Claude-authored prose is cw-by-claude in the on-state (host E2E, no LLM)", () => {
|
// NOT cw-by-claude (the old F9 authorship render). Owns its own markdown doc.
|
||||||
|
suite("F10 review preview — pending Claude proposal renders cw-ins-claude in the on-state (host E2E, no LLM)", () => {
|
||||||
const DOC_REL = "docs/f10claude.md";
|
const DOC_REL = "docs/f10claude.md";
|
||||||
const TARGET = "The sentence Claude will compose over.";
|
const TARGET = "The sentence Claude will compose over.";
|
||||||
const REPLACEMENT = "The sentence CLAUDE COMPOSED via the seam.";
|
const REPLACEMENT = "The sentence CLAUDE COMPOSED via the seam.";
|
||||||
|
|
||||||
test("an accepted Claude edit author-colors as cw-by-claude in the on-state render; mode defaults to on", async () => {
|
test("a pending Claude proposal carries cw-ins-claude; accepted proposal leaves attribution data intact; mode defaults to on", async () => {
|
||||||
const abs = path.join(WS, DOC_REL);
|
const abs = path.join(WS, DOC_REL);
|
||||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||||
fs.writeFileSync(abs, `# F10\n\n${TARGET}\n`, "utf8");
|
fs.writeFileSync(abs, `# F10\n\n${TARGET}\n`, "utf8");
|
||||||
@@ -41,7 +42,7 @@ suite("F10 review preview — Claude-authored prose is cw-by-claude in the on-st
|
|||||||
assert.ok(api.trackChangesPreviewController.isOpen(key), "preview open");
|
assert.ok(api.trackChangesPreviewController.isOpen(key), "preview open");
|
||||||
assert.strictEqual(api.trackChangesPreviewController.getMode(key), "on", "annotations default to on");
|
assert.strictEqual(api.trackChangesPreviewController.getMode(key), "on", "annotations default to on");
|
||||||
|
|
||||||
// Claude composes via the seam (propose → accept)
|
// Claude proposes via the seam (F12 optimistic-apply; proposal stays PENDING)
|
||||||
const start = doc.getText().indexOf(TARGET);
|
const start = doc.getText().indexOf(TARGET);
|
||||||
const id = await vscode.commands.executeCommand<string>("cowriting.proposeAgentEdit", {
|
const id = await vscode.commands.executeCommand<string>("cowriting.proposeAgentEdit", {
|
||||||
uri: key,
|
uri: key,
|
||||||
@@ -52,17 +53,26 @@ suite("F10 review preview — Claude-authored prose is cw-by-claude in the on-st
|
|||||||
sessionId: "e2e-f10",
|
sessionId: "e2e-f10",
|
||||||
turnId: "turn-f10",
|
turnId: "turn-f10",
|
||||||
});
|
});
|
||||||
|
assert.ok(id, "propose returns an id");
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
// PENDING proposal: the preview renders the proposal block with cw-ins-claude.
|
||||||
|
// After accepting, the baseline advances (INV-18) → the text becomes "unchanged"
|
||||||
|
// and renders plain — so we check BEFORE accepting.
|
||||||
|
const pendingHtml = api.trackChangesPreviewController.renderHtmlFor(key);
|
||||||
|
assert.ok(
|
||||||
|
pendingHtml.includes("cw-ins-claude"),
|
||||||
|
"pending Claude proposal block carries cw-ins-claude in the on-state render",
|
||||||
|
);
|
||||||
|
|
||||||
|
// accept via the seam — baseline advances (machine-landing, INV-18/F12)
|
||||||
assert.ok(await api.proposalController.acceptById(DOC_REL, id!), "accept applies via the seam");
|
assert.ok(await api.proposalController.acceptById(DOC_REL, id!), "accept applies via the seam");
|
||||||
await settle();
|
await settle();
|
||||||
|
|
||||||
// attribution recorded a Claude (agent) span (data layer intact)
|
// attribution recorded a Claude (agent) span (data layer intact)
|
||||||
const claudeSpan = api.attributionController.getSpans(DOC_REL).find((s) => s.authorKind === "agent");
|
const claudeSpan = api.attributionController.getSpans(DOC_REL).find((s) => s.authorKind === "agent");
|
||||||
assert.ok(claudeSpan, "Claude span recorded by F3");
|
assert.ok(claudeSpan, "Claude span recorded by F3 (data layer intact after accept)");
|
||||||
const spans = api.attributionController.spansFor(doc);
|
const spans = api.attributionController.spansFor(doc);
|
||||||
assert.ok(spans.some((s) => s.author === "claude"), "spansFor reports a Claude span for the preview");
|
assert.ok(spans.some((s) => s.author === "claude"), "spansFor reports a Claude span for the preview");
|
||||||
|
|
||||||
// the on-state render author-colors the landed Claude text as cw-by-claude
|
|
||||||
const html = api.trackChangesPreviewController.renderHtmlFor(key);
|
|
||||||
assert.match(html, /<span class="cw-by-claude">/, "landed Claude prose is author-colored in the on-state render");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ suite("F10 interactive review (host E2E — preview is the single review surface
|
|||||||
void doc;
|
void doc;
|
||||||
});
|
});
|
||||||
|
|
||||||
test("typing produces an added/changed block and a cw-by-human span in the on-state render (PUC-2)", async () => {
|
test("typing produces an added/changed block and a cw-ins-human span in the on-state render (PUC-2)", async () => {
|
||||||
const { key } = await reopen(DOC_REL);
|
const { key } = await reopen(DOC_REL);
|
||||||
const api = await getApi();
|
const api = await getApi();
|
||||||
const doc = byKey(key)!;
|
const doc = byKey(key)!;
|
||||||
@@ -85,13 +85,13 @@ suite("F10 interactive review (host E2E — preview is the single review surface
|
|||||||
const kinds = (api.trackChangesPreviewController.getLastModel(key) ?? []).map((o) => o.kind);
|
const kinds = (api.trackChangesPreviewController.getLastModel(key) ?? []).map((o) => o.kind);
|
||||||
assert.ok(kinds.some((k) => k === "added" || k === "changed"), "an added/changed block after typing");
|
assert.ok(kinds.some((k) => k === "added" || k === "changed"), "an added/changed block after typing");
|
||||||
// Attribution recorded the human span (data layer), and the on-state render
|
// Attribution recorded the human span (data layer), and the on-state render
|
||||||
// author-colors that prose as cw-by-human.
|
// author-colors that prose as cw-ins-human (added blocks use cw-ins-{author} since Task 3/44ef0a2).
|
||||||
assert.ok(
|
assert.ok(
|
||||||
api.attributionController.spansFor(doc).some((s) => s.author === "human"),
|
api.attributionController.spansFor(doc).some((s) => s.author === "human"),
|
||||||
"a human span was recorded for the typed text",
|
"a human span was recorded for the typed text",
|
||||||
);
|
);
|
||||||
const html = api.trackChangesPreviewController.renderHtmlFor(key);
|
const html = api.trackChangesPreviewController.renderHtmlFor(key);
|
||||||
assert.match(html, /<span class="cw-by-human">/, "typed text is author-colored human in the on-state");
|
assert.ok(html.includes("cw-ins-human"), "typed text is author-colored human in the on-state (cw-ins-human for added blocks)");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("propose → the preview surfaces it as a cw-proposal block with ✓/✗ actions (PUC-3)", async () => {
|
test("propose → the preview surfaces it as a cw-proposal block with ✓/✗ actions (PUC-3)", async () => {
|
||||||
@@ -175,7 +175,7 @@ suite("F10 interactive review (host E2E — preview is the single review surface
|
|||||||
// (INV-33). Assert the actual off-state body the controller posts has no cw-
|
// (INV-33). Assert the actual off-state body the controller posts has no cw-
|
||||||
// author/proposal marks — meaningful because the on-state above DID carry one.
|
// author/proposal marks — meaningful because the on-state above DID carry one.
|
||||||
const offBody = renderPlain(doc.getText());
|
const offBody = renderPlain(doc.getText());
|
||||||
assert.ok(!/cw-proposal|cw-by-claude|cw-by-human|cw-del/.test(offBody), "off-state render carries no cw- marks");
|
assert.ok(!/cw-proposal|cw-by-|cw-ins-|cw-del/.test(offBody), "off-state render carries no cw- marks");
|
||||||
|
|
||||||
// toggle back on → marks return.
|
// toggle back on → marks return.
|
||||||
api.trackChangesPreviewController.setMode(key, "on");
|
api.trackChangesPreviewController.setMode(key, "on");
|
||||||
@@ -232,6 +232,60 @@ suite("F10 interactive review (host E2E — preview is the single review surface
|
|||||||
assert.ok(all.includes("cowriting.pinDiffBaseline"), "pinDiffBaseline (baseline data layer) is kept");
|
assert.ok(all.includes("cowriting.pinDiffBaseline"), "pinDiffBaseline (baseline data layer) is kept");
|
||||||
void key;
|
void key;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("author-colored track changes: cw-ins-human + cw-ins-claude appear; unchanged blocks are plain (Tasks 1-4)", async () => {
|
||||||
|
// Fresh doc with three blocks: one that stays unchanged, one for Claude to target, one for human typing.
|
||||||
|
const { doc, key } = await freshDoc(
|
||||||
|
"docs/f10authorcolors.md",
|
||||||
|
"# Author Colors\n\nUnchanged paragraph that stays.\n\nClaude target block.\n",
|
||||||
|
);
|
||||||
|
const api = await getApi();
|
||||||
|
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
// Human types a new paragraph → an "added" block relative to the opened baseline.
|
||||||
|
const edit = new vscode.WorkspaceEdit();
|
||||||
|
edit.insert(doc.uri, doc.positionAt(doc.getText().length), "\n\nHuman typed paragraph.\n");
|
||||||
|
assert.ok(await vscode.workspace.applyEdit(edit), "human edit applied");
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
// Propose a Claude change (stays PENDING — F12 optimistic-apply; NOT accepted).
|
||||||
|
// The proposal block renders with cw-ins-claude.
|
||||||
|
const target = "Claude target block.";
|
||||||
|
const targetStart = doc.getText().indexOf(target);
|
||||||
|
assert.ok(targetStart >= 0, "target text present after human edit");
|
||||||
|
await vscode.commands.executeCommand<string>("cowriting.proposeAgentEdit", {
|
||||||
|
uri: key,
|
||||||
|
start: targetStart,
|
||||||
|
end: targetStart + target.length,
|
||||||
|
newText: "Claude replacement block.",
|
||||||
|
model: "sonnet",
|
||||||
|
sessionId: "e2e-f10-colors",
|
||||||
|
turnId: "turn-colors-1",
|
||||||
|
});
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
const html = api.trackChangesPreviewController.renderHtmlFor(key);
|
||||||
|
|
||||||
|
// The human-typed added block renders with cw-ins-human.
|
||||||
|
assert.ok(html.includes("cw-ins-human"), "human added block is colored cw-ins-human");
|
||||||
|
|
||||||
|
// The pending Claude proposal block renders with cw-ins-claude.
|
||||||
|
assert.ok(html.includes("cw-ins-claude"), "pending Claude proposal block carries cw-ins-claude");
|
||||||
|
|
||||||
|
// The unchanged block renders as cw-unchanged with no cw-ins-/cw-by- inside it
|
||||||
|
// (color = "changed since baseline"; unchanged text is never annotated).
|
||||||
|
const unchangedBlocks = [...html.matchAll(/<div class="cw-blk cw-unchanged"[^>]*>([\s\S]*?)<\/div>/g)];
|
||||||
|
assert.ok(unchangedBlocks.length > 0, "at least one unchanged block exists");
|
||||||
|
assert.ok(
|
||||||
|
unchangedBlocks.some((m) => m[1].includes("Unchanged paragraph that stays")),
|
||||||
|
"the unchanged paragraph is in a cw-unchanged block",
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
unchangedBlocks.every((m) => !/cw-ins-|cw-by-/.test(m[1])),
|
||||||
|
"unchanged blocks carry no cw-ins- or cw-by- classes",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- helpers reused across the order-dependent main-flow tests ----
|
// ---- helpers reused across the order-dependent main-flow tests ----
|
||||||
|
|||||||
@@ -41,15 +41,15 @@ suite("S48 — pin → fully clean review panel (host E2E, no LLM)", () => {
|
|||||||
edit.insert(doc.uri, doc.positionAt(doc.getText().length), "\n\nA freshly typed human paragraph.\n");
|
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");
|
assert.ok(await vscode.workspace.applyEdit(edit), "operator edit applied");
|
||||||
await settle();
|
await settle();
|
||||||
assert.match(ctl.renderHtmlFor(key), /cw-by-human/, "typed text is author-colored before the pin");
|
assert.match(ctl.renderHtmlFor(key), /cw-ins-human/, "typed text is author-colored cw-ins-human before the pin");
|
||||||
|
|
||||||
// Pin the baseline → zero diff → the panel must be fully clean.
|
// Pin the baseline → zero diff → the panel must be fully clean.
|
||||||
ctl.receiveMessage(key, { type: "pinBaseline" });
|
ctl.receiveMessage(key, { type: "pinBaseline" });
|
||||||
await settle();
|
await settle();
|
||||||
const pinned = ctl.renderHtmlFor(key);
|
const pinned = ctl.renderHtmlFor(key);
|
||||||
assert.ok(!pinned.includes("cw-by-human"), "no human authorship coloring after pin");
|
assert.ok(!pinned.includes("cw-ins-human"), "no human insertion marks after pin");
|
||||||
assert.ok(!pinned.includes("cw-by-claude"), "no Claude authorship coloring after pin");
|
assert.ok(!pinned.includes("cw-ins-claude"), "no Claude insertion marks after pin");
|
||||||
assert.ok(!pinned.includes("cw-add") && !pinned.includes("cw-del"), "no change marks after pin");
|
assert.ok(!pinned.includes("cw-ins-") && !pinned.includes("cw-del-"), "no insertion or deletion marks after pin");
|
||||||
assert.match(pinned, /data-src-start/, "blocks still carry data-src offsets (INV-36 mapping kept)");
|
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");
|
assert.ok(pinned.includes("freshly typed human paragraph"), "the body text is still rendered, just plain");
|
||||||
|
|
||||||
@@ -58,6 +58,6 @@ suite("S48 — pin → fully clean review panel (host E2E, no LLM)", () => {
|
|||||||
edit2.insert(doc.uri, doc.positionAt(doc.getText().length), "\n\nA second typed paragraph diverges again.\n");
|
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");
|
assert.ok(await vscode.workspace.applyEdit(edit2), "second operator edit applied");
|
||||||
await settle();
|
await settle();
|
||||||
assert.match(ctl.renderHtmlFor(key), /cw-by-human/, "authorship coloring returns once the doc diverges from the pin");
|
assert.match(ctl.renderHtmlFor(key), /cw-ins-human/, "cw-ins-human authorship coloring returns once the doc diverges from the pin");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -78,8 +78,9 @@ suite("F10 #38 — undo does not mis-attribute restored text (host E2E, no LLM)"
|
|||||||
);
|
);
|
||||||
|
|
||||||
// And the on-state render must not color 'bravo' as human-authored.
|
// And the on-state render must not color 'bravo' as human-authored.
|
||||||
|
// Added blocks use cw-ins-human (not cw-by-human) since Task 3/44ef0a2.
|
||||||
const html = api.trackChangesPreviewController.renderHtmlFor(key);
|
const html = api.trackChangesPreviewController.renderHtmlFor(key);
|
||||||
const bravoColoredHuman = /<span class="cw-by-human">[^<]*bravo/.test(html);
|
const bravoColoredHuman = /cw-ins-human[^<]*bravo|<[^>]+class="[^"]*cw-ins-human[^"]*"[^>]*>[^<]*bravo/.test(html);
|
||||||
assert.ok(!bravoColoredHuman, "restored 'bravo' is not colored cw-by-human in the preview");
|
assert.ok(!bravoColoredHuman, "restored 'bravo' is not colored cw-ins-human in the preview");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+178
-41
@@ -1,5 +1,7 @@
|
|||||||
import { describe, it, test, expect } from "vitest";
|
import { describe, it, test, expect } from "vitest";
|
||||||
import { splitBlocks, splitBlocksWithRanges, diffBlocks, diffToHunks, diffToBlockHunks, renderTrackChanges, colorByAuthor, type AuthorSpan } from "../src/trackChangesModel";
|
import MarkdownIt from "markdown-it";
|
||||||
|
import { splitBlocks, splitBlocksWithRanges, diffBlocks, diffToHunks, diffToBlockHunks, renderTrackChanges, colorByAuthor, authorAt, wordDiffByAuthor, type AuthorSpan } from "../src/trackChangesModel";
|
||||||
|
const md = new MarkdownIt({ html: true, linkify: false, breaks: false });
|
||||||
|
|
||||||
describe("splitBlocks", () => {
|
describe("splitBlocks", () => {
|
||||||
it("splits prose paragraphs on blank lines, dropping empties", () => {
|
it("splits prose paragraphs on blank lines, dropping empties", () => {
|
||||||
@@ -170,12 +172,11 @@ describe("colorByAuthor", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// #33: author-coloring must be sentinel-safe around markdown emphasis. These
|
// #33: author-coloring must be sentinel-safe around markdown emphasis. These
|
||||||
// exercise the REAL markdown-it renderer (via renderReview on an unchanged doc),
|
// exercise colorByAuthor directly with the real markdown-it renderer (unchanged
|
||||||
// since the failure is in markdown-it's inline parsing / element nesting.
|
// blocks now render plain in renderReview, so sentinel testing requires a direct call).
|
||||||
// CASE1 — a span boundary strictly inside a delimiter run must not split it.
|
// CASE1 — a span boundary strictly inside a delimiter run must not split it.
|
||||||
test("#33 CASE1: a span boundary inside ** does not break emphasis parsing", () => {
|
test("#33 CASE1: a span boundary inside ** does not break emphasis parsing", () => {
|
||||||
const doc = "a**b**c";
|
const html = colorByAuthor("a**b**c", 0, [{ start: 0, end: 2, author: "human" }], src => md.render(src));
|
||||||
const html = renderReview(doc, doc, [{ start: 0, end: 2, author: "human" }], []);
|
|
||||||
expect(html).toContain("<strong>b</strong>"); // emphasis still renders
|
expect(html).toContain("<strong>b</strong>"); // emphasis still renders
|
||||||
expect(html).not.toContain("**"); // no raw delimiters left
|
expect(html).not.toContain("**"); // no raw delimiters left
|
||||||
expect(html).not.toContain("<em></em>"); // no stray empty emphasis (the parse-break symptom)
|
expect(html).not.toContain("<em></em>"); // no stray empty emphasis (the parse-break symptom)
|
||||||
@@ -184,16 +185,14 @@ describe("colorByAuthor", () => {
|
|||||||
// CASE3 — a span boundary inside an emphasis run must color the text without
|
// CASE3 — a span boundary inside an emphasis run must color the text without
|
||||||
// misnesting span/element (the span is split at the element boundary).
|
// misnesting span/element (the span is split at the element boundary).
|
||||||
test("#33 CASE3: a span boundary inside **bold** colors the text without misnesting", () => {
|
test("#33 CASE3: a span boundary inside **bold** colors the text without misnesting", () => {
|
||||||
const doc = "**bold**";
|
const html = colorByAuthor("**bold**", 0, [{ start: 0, end: 4, author: "human" }], src => md.render(src)); // covers "**bo"
|
||||||
const html = renderReview(doc, doc, [{ start: 0, end: 4, author: "human" }], []); // covers "**bo"
|
|
||||||
expect(html).toContain("<strong>");
|
expect(html).toContain("<strong>");
|
||||||
expect(html).toContain('<span class="cw-by-human">bo</span>ld</strong>'); // span INSIDE strong, closed before "ld"
|
expect(html).toContain('<span class="cw-by-human">bo</span>ld</strong>'); // span INSIDE strong, closed before "ld"
|
||||||
expect(html).not.toContain('cw-by-human"><strong>'); // NOT the old misnest (span wrapping the <strong> open)
|
expect(html).not.toContain('cw-by-human"><strong>'); // NOT the old misnest (span wrapping the <strong> open)
|
||||||
});
|
});
|
||||||
// CASE2 — a span covering a whole emphasis run stays correct (regression).
|
// CASE2 — a span covering a whole emphasis run stays correct (regression).
|
||||||
test("#33 CASE2: a span over the whole **bold** colors it correctly", () => {
|
test("#33 CASE2: a span over the whole **bold** colors it correctly", () => {
|
||||||
const doc = "**bold**";
|
const html = colorByAuthor("**bold**", 0, [{ start: 0, end: 8, author: "human" }], src => md.render(src));
|
||||||
const html = renderReview(doc, doc, [{ start: 0, end: 8, author: "human" }], []);
|
|
||||||
expect(html).toContain("<strong>");
|
expect(html).toContain("<strong>");
|
||||||
expect((html.match(/cw-by-human/g) ?? []).length).toBe(1);
|
expect((html.match(/cw-by-human/g) ?? []).length).toBe(1);
|
||||||
expect(html).toContain("bold");
|
expect(html).toContain("bold");
|
||||||
@@ -209,7 +208,98 @@ describe("colorByAuthor", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
import { renderPlain } from "../src/trackChangesModel";
|
describe("authorAt", () => {
|
||||||
|
const spans: AuthorSpan[] = [
|
||||||
|
{ start: 0, end: 5, author: "human" },
|
||||||
|
{ start: 5, end: 10, author: "claude" },
|
||||||
|
];
|
||||||
|
test("returns the covering span's author (half-open)", () => {
|
||||||
|
expect(authorAt(0, spans)).toBe("human");
|
||||||
|
expect(authorAt(4, spans)).toBe("human");
|
||||||
|
expect(authorAt(5, spans)).toBe("claude");
|
||||||
|
expect(authorAt(9, spans)).toBe("claude");
|
||||||
|
});
|
||||||
|
test("returns null outside any span", () => {
|
||||||
|
expect(authorAt(10, spans)).toBeNull();
|
||||||
|
expect(authorAt(-1, spans)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("wordDiffByAuthor", () => {
|
||||||
|
test("an inserted run is colored by the author covering its landed offset", () => {
|
||||||
|
// before "hello " ; after "hello brave " ; "brave " inserted at landed offset 6..12
|
||||||
|
const spans: AuthorSpan[] = [{ start: 6, end: 12, author: "claude" }];
|
||||||
|
const html = wordDiffByAuthor("hello ", "hello brave ", 0, spans);
|
||||||
|
expect(html).toContain('<ins class="cw-ins-claude">brave </ins>');
|
||||||
|
expect(html).toContain("hello ");
|
||||||
|
});
|
||||||
|
test("a paired deletion inherits the paired insertion's author (adjacency)", () => {
|
||||||
|
// "light" -> "dark", both in one cluster; "dark" is claude-inserted
|
||||||
|
const spans: AuthorSpan[] = [{ start: 0, end: 4, author: "claude" }];
|
||||||
|
const html = wordDiffByAuthor("light", "dark", 0, spans);
|
||||||
|
expect(html).toContain('<del class="cw-del-claude">light</del>');
|
||||||
|
expect(html).toContain('<ins class="cw-ins-claude">dark</ins>');
|
||||||
|
});
|
||||||
|
test("a standalone deletion (no paired insertion) is neutral", () => {
|
||||||
|
const html = wordDiffByAuthor("keep this word", "keep word", 0, []);
|
||||||
|
expect(html).toContain('<del class="cw-del-none">this </del>');
|
||||||
|
expect(html).not.toContain("cw-del-human");
|
||||||
|
expect(html).not.toContain("cw-del-claude");
|
||||||
|
});
|
||||||
|
test("emits SIBLING ins/del elements, never nested — CSS overlap rule in preview.css is a defensive forward guarantee", () => {
|
||||||
|
// The engine assigns the cluster's insertion author to all deletions in the same
|
||||||
|
// cluster (adjacency heuristic), so <del> and <ins> always carry the SAME author
|
||||||
|
// as siblings. Cross-author nesting (<del class="cw-del-X"><ins class="cw-ins-Y">)
|
||||||
|
// is never produced today. The CSS rule `.cw-del-claude .cw-ins-human { text-decoration: inherit }`
|
||||||
|
// guards against a future regression where such nesting appears.
|
||||||
|
const spans: AuthorSpan[] = [{ start: 0, end: 100, author: "human" }];
|
||||||
|
const html = wordDiffByAuthor("old text", "new text", 0, spans);
|
||||||
|
expect(html).toContain('<del class="cw-del-human">');
|
||||||
|
expect(html).toContain('<ins class="cw-ins-human">');
|
||||||
|
// Structural check: no <del> immediately wraps an <ins> (sibling, not nested).
|
||||||
|
// /<del[^>]*>[^<]*<ins/ would match a nested case but not a sibling case
|
||||||
|
// because [^<]* stops before the closing </del> tag.
|
||||||
|
expect(html).not.toMatch(/<del[^>]*>[^<]*<ins/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
import { renderPlain, wordEditHunks, decorationPlan } from "../src/trackChangesModel";
|
||||||
|
|
||||||
|
test("committed change: wordEditHunks(current, baseline) → start/end index current; replacement is baseline text", () => {
|
||||||
|
const baseline = "the light mode";
|
||||||
|
const current = "the dark mode";
|
||||||
|
const [h] = wordEditHunks(current, baseline);
|
||||||
|
expect(current.slice(h.start, h.end)).toContain("dark"); // applied (current) side
|
||||||
|
expect(h.replacement).toContain("light"); // baseline (deleted) side
|
||||||
|
const plan = decorationPlan(h.start, h.replacement, current.slice(h.start, h.end));
|
||||||
|
expect(plan.insertions.length).toBeGreaterThan(0);
|
||||||
|
expect(plan.deletions.some((d) => d.text.includes("light"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("committed change with NO shared prefix: current offsets + deleted text both correct", () => {
|
||||||
|
const baseline = "alpha tail";
|
||||||
|
const current = "beta tail";
|
||||||
|
const [h] = wordEditHunks(current, baseline);
|
||||||
|
expect(h.start).toBe(0);
|
||||||
|
expect(current.slice(h.start, h.end)).toContain("beta"); // applied
|
||||||
|
const plan = decorationPlan(h.start, h.replacement, current.slice(h.start, h.end));
|
||||||
|
expect(plan.deletions.some((d) => d.text.includes("alpha"))).toBe(true); // deletion not lost
|
||||||
|
});
|
||||||
|
|
||||||
|
test("standalone committed deletion → plan has no insertions (controller routes del to neutral)", () => {
|
||||||
|
// current = "keep word" (baseline had "this " between "keep " and "word" — standalone deletion)
|
||||||
|
const [h] = wordEditHunks("keep word", "keep this word");
|
||||||
|
const plan = decorationPlan(h.start, h.replacement, "keep word".slice(h.start, h.end));
|
||||||
|
expect(plan.insertions.length).toBe(0);
|
||||||
|
expect(plan.deletions.some((d) => d.text.includes("this"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("paired committed replace → plan has insertions (deletion inherits author, not neutral)", () => {
|
||||||
|
// current = "the dark mode", baseline = "the light mode" — replacement, so paired
|
||||||
|
const [h] = wordEditHunks("the dark mode", "the light mode");
|
||||||
|
const plan = decorationPlan(h.start, h.replacement, "the dark mode".slice(h.start, h.end));
|
||||||
|
expect(plan.insertions.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
describe("renderPlain", () => {
|
describe("renderPlain", () => {
|
||||||
test("renderPlain renders current buffer as plain markdown (no marks)", () => {
|
test("renderPlain renders current buffer as plain markdown (no marks)", () => {
|
||||||
@@ -223,13 +313,15 @@ describe("renderPlain", () => {
|
|||||||
import { renderReview, type ProposalView } from "../src/trackChangesModel";
|
import { renderReview, type ProposalView } from "../src/trackChangesModel";
|
||||||
|
|
||||||
describe("renderReview", () => {
|
describe("renderReview", () => {
|
||||||
test("renderReview: human addition since baseline renders green ins / cw-by-human", () => {
|
test("renderReview: human addition since baseline renders author-colored ins", () => {
|
||||||
const html = renderReview("hello", "hello world", [{ start: 6, end: 11, author: "human" }], []);
|
// " world" is one diff token starting at offset 5 (the space), so the author span
|
||||||
expect(html).toMatch(/<ins>[^<]*world[^<]*<\/ins>|cw-by-human/);
|
// must start at 5 or earlier to cover the token's start offset.
|
||||||
|
const html = renderReview("hello", "hello world", [{ start: 5, end: 11, author: "human" }], []);
|
||||||
|
expect(html).toContain("cw-ins-human");
|
||||||
});
|
});
|
||||||
test("renderReview: deletion since baseline renders struck del/cw-del", () => {
|
test("renderReview: a standalone deletion since baseline renders neutral struck del", () => {
|
||||||
const html = renderReview("hello world", "hello", [], []);
|
const html = renderReview("hello world", "hello", [], []);
|
||||||
expect(html).toMatch(/<del>|cw-del/);
|
expect(html).toMatch(/cw-del-none|cw-removed/);
|
||||||
});
|
});
|
||||||
test("renderReview: a pending proposal renders a blue block with data-proposal-id and Accept/Reject actions", () => {
|
test("renderReview: a pending proposal renders a blue block with data-proposal-id and Accept/Reject actions", () => {
|
||||||
const proposals: ProposalView[] = [{ id: "p1", anchorStart: 0, anchorEnd: 5, replaced: "hello", replacement: "goodbye" }];
|
const proposals: ProposalView[] = [{ id: "p1", anchorStart: 0, anchorEnd: 5, replaced: "hello", replacement: "goodbye" }];
|
||||||
@@ -261,13 +353,15 @@ describe("renderReview", () => {
|
|||||||
test("renderReview: author-colors the correct block when two paragraphs are identical", () => {
|
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
|
// 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.
|
// added block authored by human. Its span must color THAT block, not the first.
|
||||||
|
// (Unchanged first block renders plain; added second block uses wordDiffByAuthor.)
|
||||||
const baseline = "Hello world";
|
const baseline = "Hello world";
|
||||||
const current = "Hello world\n\nHello world";
|
const current = "Hello world\n\nHello world";
|
||||||
// second "Hello world" starts at offset 13; "world" at 19..24
|
// second "Hello world" starts at offset 13 (past the "\n\n"); span covers the block.
|
||||||
const spans = [{ start: 19, end: 24, author: "human" as const }];
|
// wordDiffByAuthor emits the whole block as one added token at offset 13.
|
||||||
|
const spans = [{ start: 13, end: 24, author: "human" as const }];
|
||||||
const html = renderReview(baseline, current, spans, []);
|
const html = renderReview(baseline, current, spans, []);
|
||||||
// exactly one cw-by-human span (the added second block's "world"), not zero, not on the first.
|
// exactly one cw-ins-human (the added second block), not zero, not on the first.
|
||||||
const count = (html.match(/cw-by-human/g) ?? []).length;
|
const count = (html.match(/cw-ins-human/g) ?? []).length;
|
||||||
expect(count).toBe(1);
|
expect(count).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -346,22 +440,24 @@ describe("renderReview", () => {
|
|||||||
expect(html).toContain('data-proposal-id="p1"'); // proposal still shows (it is an action)
|
expect(html).toContain('data-proposal-id="p1"'); // proposal still shows (it is an action)
|
||||||
expect(html).toContain("Person");
|
expect(html).toContain("Person");
|
||||||
});
|
});
|
||||||
test("renderReview: zero diff WITHOUT a pin (e.g. machine-landing) keeps authorship coloring (INV-33)", () => {
|
test("renderReview: zero diff (e.g. machine-landing accept) renders unchanged blocks plain (Task 2 behavior)", () => {
|
||||||
// accepting a Claude edit advances the baseline (zero diff) but is NOT a pin —
|
// Task 2: unchanged blocks always render plain — color means "changed since baseline".
|
||||||
// the landed author coloring must remain.
|
// After accepting a Claude edit the text is the new baseline; no diff → no coloring.
|
||||||
const doc = "Human wrote this.\n\nClaude wrote that.";
|
const doc = "Human wrote this.\n\nClaude wrote that.";
|
||||||
const spans: AuthorSpan[] = [{ start: 19, end: doc.length, author: "claude" }];
|
const spans: AuthorSpan[] = [{ start: 19, end: doc.length, author: "claude" }];
|
||||||
const html = renderReview(doc, doc, spans, []); // no pinned flag
|
const html = renderReview(doc, doc, spans, []); // no pinned flag
|
||||||
expect(html).toContain("cw-by-claude");
|
expect(html).not.toContain("cw-by-claude");
|
||||||
|
expect(html).toContain("Claude wrote that."); // text still present
|
||||||
});
|
});
|
||||||
test("renderReview: with REAL changes since a pin, author coloring returns", () => {
|
test("renderReview: with REAL changes since a pin, author coloring returns on added blocks", () => {
|
||||||
// a genuine added block is still author-colored even when pinned (only the
|
// a genuine added block is still author-colored even when pinned (only the
|
||||||
// zero-diff-after-pin state is clean).
|
// zero-diff-after-pin state is clean). Task 2: coloring is via cw-ins-*.
|
||||||
const baseline = "Hello world";
|
const baseline = "Hello world";
|
||||||
const current = "Hello world\n\nHello world";
|
const current = "Hello world\n\nHello world";
|
||||||
const spans: AuthorSpan[] = [{ start: 19, end: 24, author: "human" }];
|
// span covers the added block from its start (offset 13) so the whole token is colored.
|
||||||
|
const spans: AuthorSpan[] = [{ start: 13, end: 24, author: "human" }];
|
||||||
const html = renderReview(baseline, current, spans, [], { pinned: true });
|
const html = renderReview(baseline, current, spans, [], { pinned: true });
|
||||||
expect((html.match(/cw-by-human/g) ?? []).length).toBe(1);
|
expect((html.match(/cw-ins-human/g) ?? []).length).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("renderReview is deterministic with mixed anchored/unanchored proposals", () => {
|
test("renderReview is deterministic with mixed anchored/unanchored proposals", () => {
|
||||||
@@ -393,24 +489,26 @@ describe("renderReview", () => {
|
|||||||
expect(html).not.toContain("<del>brown</del>"); // no double-render of the change as a baseline diff
|
expect(html).not.toContain("<del>brown</del>"); // 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)", () => {
|
test("renderReview keeps author coloring aligned on an ADDED block AFTER a length-changing pending proposal (INV-49/50)", () => {
|
||||||
// block 1 has a pending proposal whose applied text is MUCH LONGER than its
|
// block 1 has a pending proposal (MUCH LONGER than original); block 2 is a NEW
|
||||||
// original; block 2 carries a Claude authorship span (in currentText coords).
|
// paragraph with a Claude authorship span (in currentText coords). The toLanded
|
||||||
const baseline = "one short.\n\ntwo stable here.\n";
|
// mapping must shift the span's coords so the added block is correctly colored.
|
||||||
const current = "one MUCH LONGER REPLACED TEXT.\n\ntwo stable here.\n";
|
const baseline = "old.";
|
||||||
|
const current = "LONGER REPLACEMENT.\n\nAdded para.";
|
||||||
const proposals: ProposalView[] = [{
|
const proposals: ProposalView[] = [{
|
||||||
id: "pr_1",
|
id: "pr_1",
|
||||||
anchorStart: current.indexOf("one MUCH LONGER REPLACED TEXT."),
|
anchorStart: 0,
|
||||||
anchorEnd: current.indexOf("one MUCH LONGER REPLACED TEXT.") + "one MUCH LONGER REPLACED TEXT.".length,
|
anchorEnd: "LONGER REPLACEMENT.".length,
|
||||||
replaced: "one short.",
|
replaced: "old.",
|
||||||
replacement: "one MUCH LONGER REPLACED TEXT.",
|
replacement: "LONGER REPLACEMENT.",
|
||||||
original: "one short.",
|
original: "old.",
|
||||||
}];
|
}];
|
||||||
const sStart = current.indexOf("stable");
|
// "Added" in currentText starts at: "LONGER REPLACEMENT.\n\n".length = 21
|
||||||
const authorSpans = [{ start: sStart, end: sStart + "stable".length, author: "claude" as const }];
|
const aStart = current.indexOf("Added");
|
||||||
|
const authorSpans = [{ start: aStart, end: aStart + "Added".length, author: "claude" as const }];
|
||||||
const html = renderReview(baseline, current, authorSpans, proposals);
|
const html = renderReview(baseline, current, authorSpans, proposals);
|
||||||
// the Claude coloring wraps "stable" exactly — not shifted by the proposal's length delta.
|
// "Added" in the landedText block should be claude-colored despite the coordinate shift.
|
||||||
expect(html).toMatch(/<span class="cw-by-claude">stable<\/span>/);
|
expect(html).toContain("cw-ins-claude");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("proposalBlockHtml renders Accept/Reject controls with dropdown carets (#64)", () => {
|
test("proposalBlockHtml renders Accept/Reject controls with dropdown carets (#64)", () => {
|
||||||
@@ -424,6 +522,45 @@ describe("renderReview", () => {
|
|||||||
expect(html).toMatch(/Accept/);
|
expect(html).toMatch(/Accept/);
|
||||||
expect(html).toMatch(/Reject/);
|
expect(html).toMatch(/Reject/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("renderReview: a changed prose block colors ins/del by author (claude replace)", () => {
|
||||||
|
// baseline "light" -> current "dark"; "dark" attributed to claude at 0..4
|
||||||
|
const html = renderReview("light", "dark", [{ start: 0, end: 4, author: "claude" }], []);
|
||||||
|
expect(html).toContain('cw-ins-claude');
|
||||||
|
expect(html).toContain('cw-del-claude'); // adjacency: struck "light" inherits claude
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renderReview: an unchanged block renders plain (no author coloring)", () => {
|
||||||
|
const doc = "Alpha stays.";
|
||||||
|
const html = renderReview(doc, doc, [{ start: 0, end: 12, author: "human" }], []);
|
||||||
|
expect(html).not.toContain("cw-by-");
|
||||||
|
expect(html).not.toContain("cw-ins-");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renderReview: an added block is author-colored as an insertion", () => {
|
||||||
|
// baseline empty-ish -> add a new paragraph authored by human
|
||||||
|
const html = renderReview("Keep.", "Keep.\n\nFresh line.", [{ start: 6, end: 17, author: "human" }], []);
|
||||||
|
expect(html).toContain("cw-ins-human");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renderReview: an added heading block renders as a heading AND is author-colored as an insertion", () => {
|
||||||
|
// "Intro.\n\n" = 8 chars; "## New Section" = 14 chars → offset 8..22 in currentText.
|
||||||
|
// Before fix: wordDiffByAuthor wrapped raw in <ins> BEFORE markdown-it parsed it,
|
||||||
|
// so "## New Section" rendered as literal text (structural bug). After fix:
|
||||||
|
// colorByAuthor uses the sentinel technique — markdown-it parses the block first,
|
||||||
|
// so ## becomes a real <h2>, THEN sentinels are replaced with cw-ins-* spans.
|
||||||
|
const html = renderReview("Intro.", "Intro.\n\n## New Section", [{ start: 8, end: 22, author: "human" }], []);
|
||||||
|
expect(html).toMatch(/<h[12][^>]*>/); // real heading element, not literal "## "
|
||||||
|
expect(html).toContain("cw-ins-human"); // author-colored insertion
|
||||||
|
expect(html).not.toContain(">>"); // sanity: markers not escaped as text
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renderReview: a proposal block colors its del/ins by author (claude default)", () => {
|
||||||
|
const proposals: ProposalView[] = [{ id: "p1", anchorStart: 0, anchorEnd: 5, replaced: "hello", replacement: "goodbye" }];
|
||||||
|
const html = renderReview("hello", "hello", [], proposals);
|
||||||
|
expect(html).toContain('cw-del-claude');
|
||||||
|
expect(html).toContain('cw-ins-claude');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
import { renderTrackChanges as rtc2 } from "../src/trackChangesModel";
|
import { renderTrackChanges as rtc2 } from "../src/trackChangesModel";
|
||||||
|
|||||||
Reference in New Issue
Block a user