21 Commits

Author SHA1 Message Date
BenStullsBets ef61b141b6 plan(0064): native-surfaces migration — ONE plan from coauthoring-native-surfaces v0.2.1 (D17/§6.10/§7.1 rung 3+4), 9 tasks, sunsets gated on green replacements
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 22:40:40 -07:00
BenStullsBets 55fe663bd0 claim vscode-cowriting-plugin session 0064 (placeholder) + sessions.json entry 2026-07-01 22:19:33 -07:00
BenStullsBets 7b767a759d update sessions/0063/SESSION-0063.0-TRANSCRIPT-2026-07-01T18-49--INPROGRESS.md 2026-07-01 19:13:17 -07:00
BenStullsBets 2a1d7623f5 claim vscode-cowriting-plugin session 0063 (placeholder) + sessions.json entry 2026-07-01 18:49:59 -07:00
BenStullsBets fdab92ae6d claim vscode-cowriting-plugin session 0062 (placeholder) + sessions.json entry 2026-06-27 17:03:29 -07:00
BenStullsBets c9050c3e08 Merge origin/main (session 0061 claim) into main 2026-06-27 05:18:51 -07:00
BenStullsBets 15e6f02bdf Merge: author-colored track changes — Phase 2 (editor pane)
Brings author-colored track changes inline into the main editor (reverses
INV-32): four per-author decoration types, decorateCommitted decorating all
committed changes-since-baseline by author (human green / Claude blue + struck
hints), standalone deletions neutral, proposals recolored Claude, overlap
stacking. Subagent-driven execution of Tasks 6-9; opus whole-branch review +
fix. typecheck + 265 unit + build + E2E green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 18:54:10 -07:00
BenStullsBets c3cecdfaa2 fix: editor renders standalone committed deletions neutral; doc/comment cleanups (final review)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 18:10:25 -07:00
BenStullsBets 02fbe9c77e chore: sweep retired semantic-diff markup; full suite green
- Delete dead `authorBadge` function (no callers; grep-verified)
- Merge adjacent duplicate imports from trackChangesModel in test file

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 17:57:16 -07:00
BenStullsBets ae19df78fb feat: overlap renders stacked author marks (insert + delete) in both panes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 17:50:39 -07:00
BenStullsBets 7b59c324d5 feat: Task 7 — decorateCommitted fills committed author-colored changes in the editor (reverses INV-32)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 17:40:15 -07:00
BenStullsBets dcdd2ffc1c feat: per-author editor decoration types; proposals decorate in Claude blue/purple 2026-06-26 17:31:13 -07:00
BenStullsBets 1d284d2868 Merge: author-colored track changes — Phase 1 (preview pane)
Preview pane renders document changes as author-colored track changes:
underline=insert / strikethrough=remove, color=author (human green/red,
Claude blue/purple). Adjacency heuristic for deletion authorship; unchanged
text plain; pin->clean preserved. Phase 2 (editor pane) to follow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 16:38:19 -07:00
BenStullsBets 05ca497900 chore: drop dead cw-by-*/cw-mixed preview CSS; fix added-block comment to cw-ins-*
Final-review follow-up: the live preview colors added blocks via colorByAuthor(kind="ins")
emitting cw-ins-*; the cw-by-*/cw-mixed rules (only authorBadge, which has no callers)
were styled-but-never-emitted. Cosmetic only; build + 260 unit green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 14:47:05 -07:00
BenStullsBets c579f67a43 test(e2e): assert author-colored track changes render in the preview
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 14:37:29 -07:00
BenStullsBets 5d1000096a feat: author-colored track-changes CSS for the preview (human green/red, Claude blue/purple) 2026-06-26 14:22:12 -07:00
BenStullsBets 2955e8d929 feat: color pending proposal blocks by author (Claude blue/purple)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 14:17:40 -07:00
BenStullsBets 44ef0a21db fix: render added blocks via markdown-safe sentinel path emitting cw-ins-* (heading/list structure preserved)
Defect: an ADDED block was colored via `render(wordDiffByAuthor("", raw, ...))`, which
wrapped the whole raw markdown in an inline `<ins>` BEFORE markdown-it parsed it — so
`## X` rendered as literal text, not `<h2>`. Same structural bug for list, blockquote,
and thematic-break blocks.

Fix (three-part, pure / vscode-free):
1. `blockContentStart(raw)`: new helper returning the offset past block-level markers
   (ATX headings `## `, unordered/ordered list markers, blockquotes). Open sentinels must
   not precede these markers or markdown-it cannot recognise the block construct.
2. `injectSentinels`: clamp the open-sentinel lower-bound to `blockContentStart(raw)`,
   so for `## New Section` the sentinel lands AFTER `## ` (at position 3) instead of at
   position 0, letting the heading parse correctly.
3. `sentinelsToSpans` / `colorByAuthor`: add optional `kind: "by" | "ins" = "by"`
   parameter so the sentinel path can emit `cw-ins-{author}` for added blocks while all
   existing `colorByAuthor`/`cw-by-*` call-sites are unchanged.
4. `renderReview` ADDED block path: `render(wordDiffByAuthor("", raw, ...))` →
   `colorByAuthor(raw, blk.start, landedSpans, render, "ins")`. The CHANGED non-atomic
   prose path (`wordDiffByAuthor(before, after, ...)`) is untouched — it correctly keeps
   the block markers at column 0 of the unchanged prefix.
5. `renderReviewOp`: removed the now-vestigial `colored` parameter (was always `render`
   after the added-block path moved to `changedHtml`); unchanged blocks now call
   `render(op.block.raw)` directly.

Regression test added: "renderReview: an added heading block renders as a heading AND
is author-colored as an insertion" — RED on old code, GREEN after fix. All 259 tests
pass; typecheck clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 14:09:19 -07:00
BenStullsBets 08557827da feat: author-colored changed/added blocks in review; unchanged blocks render plain 2026-06-26 13:54:24 -07:00
BenStullsBets 0ad040711f feat: authorAt + wordDiffByAuthor pure helpers (author-colored word diff w/ adjacency del) 2026-06-26 13:34:12 -07:00
BenStullsBets 94b1a9b0c2 docs: author-colored track-changes design + implementation plan
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 13:32:34 -07:00
18 changed files with 2924 additions and 145 deletions
+3
View File
@@ -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.)
File diff suppressed because it is too large Load Diff
@@ -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
View File
@@ -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; }
@@ -0,0 +1,23 @@
# Session 0062.0 — Transcript
> App: vscode-cowriting-plugin
> Start: 2026-06-27T17-02 (PST)
> Type: brainstorming
> Posture: careful
> Claude-Session: 2c832f9a-a70c-4d71-ab13-c4f39d6eb595
> Checkout: /Users/benstull/git/benstull.org/benstull/vscode-cowriting-plugin
> Status: **PLACEHOLDER — claimed at session start; finalized at session end.**
>
> This file reserves session ID 0062 for vscode-cowriting-plugin. The driver replaces this
> body with the full transcript and renames the file to its final
> SESSION-0062.0-TRANSCRIPT-2026-06-27T17-02--<end>.md form at session end.
## Launch prompt
_(launch prompt not captured at claim time)_
## Deferred decisions
_Autonomous-mode low-confidence calls the driver made and would have
liked operator input on. Appended as the session runs; surfaced at
finalize. Empty if none._
@@ -0,0 +1,65 @@
# Session 0063.0 — Transcript
> App: vscode-cowriting-plugin
> Start: 2026-07-01T18-49 (PST)
> Type: brainstorming
> Posture: careful
> Claude-Session: 1a8fc96b-6f36-4102-be7b-decb53251b2b
> Checkout: /Users/benstull/git/benstull.org/benstull/vscode-cowriting-plugin-s0063
> Status: **PLACEHOLDER — claimed at session start; finalized at session end.**
>
> This file reserves session ID 0063 for vscode-cowriting-plugin. The driver replaces this
> body with the full transcript and renames the file to its final
> SESSION-0063.0-TRANSCRIPT-2026-07-01T18-49--<end>.md form at session end.
## Launch prompt
```
Examine the existing vs plugin, the prototype, and the half-baked spec that I used Opus to create and make a recommendation for a VS Code experience that allows humans and machines to collaborate on a document, with the machine making suggestions based on human input and the human able to approve, reject, or modify those suggestions. This should use as many VS Code UX paradigms as possible, use as many existing VS Code components or extensions, but create custom components/UX when needed. You can assume this is just Claude as the 'machine' to start
```
## Plan
> Anchor: design-only north-star recommendation (no tracker anchor; lineage =
> Epic #1 / F1F12 + session 0062's `coauthoring-native-surfaces.md` draft —
> same anchor treatment 0062 recorded)
1. Examine the three inputs: the shipped plugin (F1F12 code on `main`), the
prototype, and the half-baked Opus-authored spec (untracked `specs/` in the
primary checkout).
2. Identify/confirm the anchoring issue (§4.3 R1 gate) — capture one if none fits.
3. Walk the consequential UX decisions with the operator (AskUserQuestion
checkpoints), grounded in native VS Code paradigms vs custom components.
4. Draft the recommendation as a Solution-Design spec in the content repo's
`specs/`; whole-document review at the Gitea render URL.
## Session record (running)
- Examined the three inputs: shipped plugin (F1F12 inventory), prototype
(`vscode-cowriting-prototype` = rung-2 spike: E1/E3/E5 PASS — key finding:
CodeLens does not render in a diff editor; E2/E4 unbuilt), and the 0062 draft
`coauthoring-native-surfaces.md` v0.1.0 + companion spike plan.
- Recommendation delivered + four forks decided with the operator
(AskUserQuestion checkpoints, all recommended options chosen):
1. Review model = **in-buffer pending changes + per-hunk CodeLens** (D18;
supersedes multi-diff Keep/Undo; amends INV-5/INV-12; resolves Q2/Q7).
2. Packaging (Q1) = **evolve the shipped extension in place** (D17; §6.10
rewritten from "greenfield").
3. Ask surface = **comments-first** (D19; split-pane input webview sunsets).
4. Artifact = **amend the 0062 spec to v0.2.0** on content-repo branch
`session-0063` (0062's working tree untouched). Also D20 (tweaks × per-hunk
decisions), D21 (D11 → shipped reality).
- Spec v0.2.0 + spike-spec status committed (`431ebeb`) and pushed to
`session-0063`; whole-document review handed to the operator at the Gitea
render URL (terminal gate).
## Deferred decisions
- Proceeded past the concurrent-session warning (0061/0062 both INPROGRESS,
placeholder-only claims from 2026-06-27) because the operator explicitly
launched this session; isolated in worktree `session-0063` per #134/§5.4.
_Autonomous-mode low-confidence calls the driver made and would have
liked operator input on. Appended as the session runs; surfaced at
finalize. Empty if none._
@@ -0,0 +1,23 @@
# Session 0064.0 — Transcript
> App: vscode-cowriting-plugin
> Start: 2026-07-01T22-19 (PST)
> Type: writing-plans
> Posture: careful
> Claude-Session: 7c03a44e-1f92-4445-afd9-928efd55afba
> Checkout: /Users/benstull/git/benstull.org/benstull/vscode-cowriting-plugin-wp
> Status: **PLACEHOLDER — claimed at session start; finalized at session end.**
>
> This file reserves session ID 0064 for vscode-cowriting-plugin. The driver replaces this
> body with the full transcript and renames the file to its final
> SESSION-0064.0-TRANSCRIPT-2026-07-01T22-19--<end>.md form at session end.
## Launch prompt
_(launch prompt not captured at claim time)_
## Deferred decisions
_Autonomous-mode low-confidence calls the driver made and would have
liked operator input on. Appended as the session runs; surfaced at
finalize. Empty if none._
+9
View File
@@ -181,5 +181,14 @@
}, },
"0061": { "0061": {
"title": "" "title": ""
},
"0062": {
"title": ""
},
"0063": {
"title": ""
},
"0064": {
"title": ""
} }
} }
+128 -29
View File
@@ -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
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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));
} }
+21 -11
View File
@@ -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");
}); });
}); });
+58 -4
View File
@@ -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 ----
+5 -5
View File
@@ -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");
}); });
}); });
+3 -2
View File
@@ -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
View File
@@ -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("&gt;&gt;"); // 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";