19 Commits

Author SHA1 Message Date
BenStullsBets 8b98a36960 add plan ./plans/2026-07-02-71-surface-polish.md 2026-07-03 07:39:01 -07:00
BenStullsBets 0019d58e37 add plan ./plans/2026-07-02-70-inv5-reject-after-interior-edit.md 2026-07-02 18:53:51 -07:00
BenStullsBets b0687b6a26 update plan ./plans/2026-07-01-native-surfaces-migration.md 2026-07-02 16:11:14 -07:00
BenStullsBets ddaf3b0ece add plan ./plans/2026-07-01-native-surfaces-migration.md 2026-07-02 05:32:10 -07:00
BenStullsBets 6183247a7e Merge remote-tracking branch 'origin/main' into session-0064 2026-07-02 05:31:44 -07:00
BenStullsBets 88ba6ffd69 spec(0064): graduate coauthoring-native-surfaces v0.2.1 (operator approved)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 22:29:03 -07:00
BenStullsBets c10f70bdc8 spec(0064): v0.2.1 — rung-2 spike complete: E2 PASS, E4 PASS-with-fallback; §6.4 API seams (commenting-range refresh, addComment focus, commentThread when-key, offer affordances; baseline nested-repo note); §7.1 rung 2 closed; §7.3 risks updated
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 22:24:21 -07:00
BenStullsBets 431ebeb979 spec(0063): v0.2.0 — in-buffer pending review (D18, resolves Q2/Q7), evolve-in-place packaging (D17, resolves Q1), comments-first ask (D19), D11→shipped reality (D21), rung-2 spike findings recorded
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 19:12:40 -07:00
BenStullsBets 43913dcbb9 update spec ./specs/coauthoring-inline-editor-diff.md (status: graduated) 2026-06-26 08:30:29 -07:00
BenStullsBets 8c14f54ff4 add plan ./plans/2026-06-26-inline-editor-diff.md 2026-06-26 08:30:26 -07:00
BenStullsBets 80d5d1ad77 add spec ./specs/coauthoring-inline-editor-diff.md (status: graduated) 2026-06-26 05:57:40 -07:00
BenStullsBets 22ec57ea68 add plan ./plans/2026-06-26-live-turn-progress.md 2026-06-26 04:53:43 -07:00
BenStullsBets 3bd1ae5513 add spec ./specs/coauthoring-live-progress.md (status: graduated) 2026-06-26 04:22:50 -07:00
Ben Stull 9d016e8f39 add spec ./specs/coauthoring-document-edit-flow.md (status: graduated) 2026-06-13 07:05:14 -07:00
Ben Stull 62706371a2 add plan ./plans/2026-06-12-f11-preview-toolbar.md 2026-06-12 14:20:03 -07:00
Ben Stull 855acec09b add spec ./specs/2026-06-12-f11-preview-toolbar-interaction-surface.md (status: graduated) 2026-06-12 12:17:35 -07:00
Ben Stull 7a45af9abe add plan ./plans/2026-06-11-f10-interactive-review.md 2026-06-12 00:41:06 -07:00
Ben Stull 118bc293ad add spec ./specs/coauthoring-interactive-review.md (status: graduated) 2026-06-11 21:52:37 -07:00
Ben Stull 6b14de9d8e add plan ./plans/2026-06-11-f7-intra-diagram-mermaid-diff.md 2026-06-11 20:32:57 -07:00
15 changed files with 9366 additions and 163 deletions
+995
View File
@@ -0,0 +1,995 @@
# F10 — Interactive Track-Changes Review in the Markdown Preview — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make the rendered markdown preview the single interactive review surface — a clean (zero-annotation) editor, an annotations on/off toggle, one combined per-author + strikethrough render, and ✓/✗ accept/reject of Claude's pending F4 proposals from inside the preview.
**Architecture:** Mostly assembly of shipped features. F3 attribution (`spansFor`), F4 propose/accept (`acceptById`/`rejectById`), and the F6 baseline (`DiffViewController`) stay as **data layers**; their in-editor visuals are removed. One new pure, vscode-free function `renderReview(baselineText, currentText, authorSpans, proposals)` overlays the F7 block/word diff (author-colored via the salvaged F9 PUA-sentinel technique, struck deletions) **and** F4 pending proposals (blue blocks with ✓/✗). The `TrackChangesPreviewController` collapses F9's two modes into one `"on"|"off"` toggle, gains a `ProposalController` dependency, and routes ✓/✗ webview messages through the F4 seam. The webview never mutates the document (INV-20/21/34).
**Tech Stack:** TypeScript, VS Code extension API, `markdown-it` + `diff` (jsdiff) + `mermaid` (webview-only), esbuild (webview bundle), vitest (vscode-free unit), `@vscode/test-electron` (host E2E).
**Spec:** `specs/coauthoring-interactive-review.md` (F10, `#29`). Parent invariants INV-1..31 carry over except where F10 supersedes (F10 adds INV-32/33/34; **reverses F9 INV-26**).
---
## File Structure
**Modify:**
- `src/attributionController.ts``render()` stops applying editor decorations (keep `spansFor`); retire decoration types.
- `src/proposalController.ts` — stop creating in-editor comment threads + pending decorations; refactor bookkeeping to plain maps; add `listProposals()` + `onDidChangeProposals`.
- `src/trackChangesModel.ts` — add `renderReview` + `renderPlain` + `colorByAuthor` + `ProposalView`; remove public `renderAuthorship`.
- `src/trackChangesPreview.ts` — collapse mode to `"on"|"off"`; take `ProposalController`; subscribe to `onDidChangeProposals`; new render path; inbound `accept`/`reject`/`setMode`; own a status-bar item.
- `src/extension.ts` — reorder construction (proposals before preview); inject `ProposalController` into the preview.
- `media/preview.ts` — replace segmented control with on/off switch; add ✓/✗ click → `postMessage`.
- `media/preview.css` — proposal-block + ✓/✗ button styles; on/off switch style.
- `package.json` — hide `toggleDiffView`/`ctrl+alt+d`, `toggleAttribution`, in-editor `acceptProposal`/`rejectProposal` (`when:false`); retitle `showTrackChangesPreview`.
**Create:**
- `docs/MANUAL-SMOKE-F10.md` — the live webview smoke checklist.
**Test:**
- `test/trackChangesModel.test.ts` — extend (vitest) for `renderReview`/`renderPlain`/`colorByAuthor`.
- `test/e2e/suite/trackChangesPreview.test.ts` and/or a new `f10Review.test.ts` — host E2E.
---
## Conventions for this plan
- **Unit tests** (the pure `trackChangesModel` engine) are strict TDD: failing test first, run it, minimal impl, run green, commit. Command: `npm test` (vitest) — to run one file: `npx vitest run test/trackChangesModel.test.ts`.
- **vscode-layer changes** (controllers, webview, package.json) cannot be unit-tested vscode-free; they are verified by **host E2E** (`npm run test:e2e`) and the manual smoke. For those tasks the "test" step is the E2E assertion added in SLICE-4 plus a `npm run build` typecheck.
- Build/typecheck after vscode-layer edits: `npm run build` (esbuild + bundles `media/preview.ts`). E2E precompiles via `npm run pretest:e2e`.
- Commit after each task.
---
## SLICE-1 — Clean editor (INV-32)
Strip F3 attribution decorations and F4 in-editor proposal threads/decorations; hide the F6 two-pane diff command/keybinding and the attribution toggle. Keep `spansFor`, accept/reject logic, and the baseline store. *Mostly deletion.*
### Task 1: Strip F3 attribution editor decorations
**Files:**
- Modify: `src/attributionController.ts:62-63` (decoration types), `:354-373` (`render()`), `:82` (dispose list)
- [ ] **Step 1: Read the current `render()` and decoration setup.** Confirm `render()` computes `agentRanges`/`humanRanges` and calls `editor.setDecorations(this.agentType, …)` / `(this.humanType, …)` at lines 366-367, and that `spansFor` (≈line 410) is independent of these.
- [ ] **Step 2: Remove the editor-painting side effect from `render()`.** Edit `render()` so it no longer calls `setDecorations` and no longer builds `agentRanges`/`humanRanges`. F10 keeps `render()` as a method (callers exist) but it becomes a no-op for decorations — leave any non-decoration bookkeeping intact. Concretely, delete the two `editor.setDecorations(...)` calls (lines 366-367) and the range-building that feeds only them.
- [ ] **Step 3: Retire the decoration types.** Delete the `agentType`/`humanType` `createTextEditorDecorationType(...)` fields (lines 62-63), the `AGENT_DECO`/`HUMAN_DECO` constants (≈lines 47-56), and remove `this.agentType, this.humanType` from the dispose push at line 82. Keep `this.statusItem, this.output, this.applyEmitter` in the dispose list.
- [ ] **Step 4: Typecheck.**
Run: `npm run build`
Expected: no TypeScript errors; no remaining references to `agentType`/`humanType`/`AGENT_DECO`/`HUMAN_DECO`.
- [ ] **Step 5: Confirm `spansFor` is untouched.** Grep that `spansFor(` still exists and returns `AuthorSpan[]`.
Run: `grep -n 'spansFor' src/attributionController.ts`
Expected: the method signature is present and unchanged.
- [ ] **Step 6: Commit.**
```bash
git add src/attributionController.ts
git commit -m "F10 SLICE-1: strip F3 attribution editor decorations (keep spansFor) (#29)"
```
### Task 2: Strip F4 in-editor proposal threads + decorations; refactor bookkeeping
The editor must carry **no** F4 visuals. Remove the `CommentController`, the per-proposal comment threads, and the amber `pendingType` decoration — but keep resolving each proposal's anchor into `state.live`/`state.unresolved` so `getRendered` (existing tests) and SLICE-3's `listProposals` still work. The in-editor `acceptThread`/`rejectThread` paths become dead and are removed; `acceptById`/`rejectById` (the public seams) are kept.
**Files:**
- Modify: `src/proposalController.ts``DocState` (`:32-41`), constructor (`:49-71`), `renderAll` (`:187-210`), `renderProposal` (`:243-274`), `renderDecorations` (`:276-285`), `byThread`/`acceptThread`/`rejectThread` (`:137-144`, `:304-314`), `getRendered` (`:323-339`).
- [ ] **Step 1: Drop the CommentController + pending decoration + thread bookkeeping.**
- Remove `private readonly controller = vscode.comments.createCommentController(...)` (≈line 64) and `private readonly pendingType = ...createTextEditorDecorationType(PENDING_DECO)` (line 53) and the `PENDING_DECO` constant (lines 43-47).
- Remove `this.controller` and `this.pendingType` from the dispose push (line 65) — keep `this.statusItem`.
- Remove the `acceptProposal`/`rejectProposal` command registrations (lines 67-68) — these are the in-editor thread-menu commands; the seams move to the preview in SLICE-3.
- In `DocState` remove `vsThreads: Map<string, vscode.CommentThread>` (line 36); keep `live` and `unresolved`.
- [ ] **Step 2: Replace `renderProposal` (thread+UI) with `recordProposal` (bookkeeping only).** New method records resolved offsets without any vscode UI:
```ts
/** Record a proposal's resolved offsets (no editor UI — F10 INV-32). */
private recordProposal(proposal: Proposal, offsets: OffsetRange, pending: boolean): void {
this.docs.get(this.keyOf2(proposal))?.live; // placeholder removed below
}
```
…actually keep it simple and stateful via the passed `state`:
```ts
private recordProposal(state: DocState, proposal: Proposal, offsets: OffsetRange, pending: boolean): void {
state.live.set(proposal.id, offsets);
if (!pending) state.unresolved.add(proposal.id);
}
```
- [ ] **Step 3: Rewrite `renderAll` to use `recordProposal` and drop thread/decoration disposal.** Keep the anchor resolve-or-flag loop; remove `for (const vsThread of state.vsThreads.values()) vsThread.dispose();` and `state.vsThreads.clear();` and the `this.renderDecorations(...)` call:
```ts
renderAll(document: vscode.TextDocument): void {
if (!this.isTracked(document)) return;
const docPath = this.keyOf(document);
const state = this.ensureState(document);
state.artifact = this.store.load(docPath) ?? emptyArtifact(docPath);
state.live.clear();
state.unresolved.clear();
const text = document.getText();
for (const proposal of state.artifact.proposals) {
const fp = state.artifact.anchors[proposal.anchorId]?.fingerprint;
const resolved = fp ? resolve(text, fp) : "orphaned";
if (resolved === "orphaned") {
const line = fp ? Math.min(fp.lineHint, Math.max(0, document.lineCount - 1)) : 0;
const off = document.offsetAt(new vscode.Position(line, 0));
this.recordProposal(state, proposal, { start: off, end: off }, false);
} else {
this.recordProposal(state, proposal, resolved, true);
}
}
this.renderStatus(state);
}
```
- [ ] **Step 4: Remove `renderDecorations` and the thread-following branch in `onDidChange`.** Delete `renderDecorations` (lines 276-285). In `onDidChange` (lines 222-239) keep the `state.live` shift loop but remove the `vsThread.range = ...` update and the trailing `this.renderDecorations(...)` call:
```ts
private onDidChange(e: vscode.TextDocumentChangeEvent): void {
const state = this.docs.get(this.keyOf(e.document));
if (!state || state.live.size === 0) return;
for (const change of e.contentChanges) {
const edit = { start: change.rangeOffset, end: change.rangeOffset + change.rangeLength, newLength: change.text.length };
for (const [id, range] of state.live) state.live.set(id, shift(range, edit));
}
}
```
- [ ] **Step 5: Remove `acceptThread`/`rejectThread`/`byThread`.** Delete those three methods (lines 137-144, 304-314). `acceptById`/`rejectById`/`byId` stay. `accept`/`reject` keep their `renderAll(document)` re-render calls (now thread-free).
- [ ] **Step 6: Repoint `getRendered` at `live`/`unresolved`.** It read from `vsThreads`; read from `live` instead:
```ts
getRendered(docPath: string): RenderedProposal[] {
const state = this.docs.get(docPath);
if (!state) return [];
const out: RenderedProposal[] = [];
for (const [id, off] of state.live) {
const p = state.artifact.proposals.find((x) => x.id === id)!;
out.push({
id,
pending: !state.unresolved.has(id),
canReply: false,
turnId: p.turnId,
range: { start: off.start, end: off.end },
});
}
return out;
}
```
- [ ] **Step 7: Typecheck.**
Run: `npm run build`
Expected: no errors; no remaining references to `vsThreads`, `pendingType`, `PENDING_DECO`, `this.controller`, `acceptThread`, `rejectThread`, `byThread`, `renderDecorations`, `renderProposal`.
- [ ] **Step 8: Run the existing F4 unit tests (proposalModel — vscode-free) to confirm no regression.**
Run: `npx vitest run test/proposalModel.test.ts`
Expected: PASS (proposalModel is untouched; this is a guard).
- [ ] **Step 9: Commit.**
```bash
git add src/proposalController.ts
git commit -m "F10 SLICE-1: remove F4 in-editor proposal threads/decorations; bookkeeping via live/unresolved (#29)"
```
> NOTE: the existing host E2E `test/e2e/suite/proposals.test.ts` may assert in-editor thread/`getRendered` behavior. Do **not** fix it here — SLICE-4 updates the E2E suite. If `npm run test:e2e` is run before SLICE-4, expect known proposal-thread failures.
### Task 3: Hide F6 two-pane diff + attribution toggle in package.json
**Files:**
- Modify: `package.json` — keybindings (`:154-165`), `contributes.menus.commandPalette` (`:93-152`), command titles (`:48-91`)
- [ ] **Step 1: Hide the `ctrl+alt+d` keybinding.** In `contributes.keybindings`, set the `toggleDiffView` entry's `when` to `false`:
```json
{ "command": "cowriting.toggleDiffView", "key": "ctrl+alt+d", "when": "false" }
```
- [ ] **Step 2: Hide `toggleDiffView`, `pinDiffBaseline`, and `toggleAttribution` from the command palette.** In `contributes.menus.commandPalette`, add (or set) `when: "false"` entries:
```json
{ "command": "cowriting.toggleDiffView", "when": "false" },
{ "command": "cowriting.pinDiffBaseline", "when": "false" },
{ "command": "cowriting.toggleAttribution", "when": "false" }
```
- [ ] **Step 3: Confirm the in-editor `acceptProposal`/`rejectProposal` palette entries are already `when:false`** (lines 104-110) and **remove their `editor/context`/`comments/commentThread` menu contributions** that referenced the now-deleted comment controller (the `commentController == cowriting.proposals` menu items, lines ≈143-151), since SLICE-1 deleted that controller.
- [ ] **Step 4: Retitle `showTrackChangesPreview` and rebind to the review verb.** Set its title to `"Cowriting: Open Review Preview"` (line 89) and keep `ctrl+alt+r` / `when: editorLangId == markdown` (lines 161-164).
- [ ] **Step 5: Validate JSON + typecheck.**
Run: `node -e "JSON.parse(require('fs').readFileSync('package.json','utf8')); console.log('ok')" && npm run build`
Expected: `ok` then a clean build.
- [ ] **Step 6: Commit.**
```bash
git add package.json
git commit -m "F10 SLICE-1: hide F6 diff command/keybinding + attribution toggle; retitle preview to 'Open Review Preview' (#29)"
```
---
## SLICE-2 — Combined render engine (pure, vscode-free; INV-33)
Add `renderReview` (the on-state) + `renderPlain` (the off-state) to `trackChangesModel.ts`, extracting the F9 PUA-sentinel coloring into a reusable `colorByAuthor`, and removing the now-superseded public `renderAuthorship`. Strict TDD — this is the pure core.
### Task 4: Extract `colorByAuthor` from `renderAuthorship`'s inline sentinels
**Files:**
- Modify: `src/trackChangesModel.ts:326-352` (sentinel helpers), `:360-386` (`renderAuthorship`)
- Test: `test/trackChangesModel.test.ts`
- [ ] **Step 1: Write a failing unit test for `colorByAuthor`.** It should color a prose block's HTML by author spans (clipped to the block) using the existing sentinel technique.
```ts
import { colorByAuthor, type AuthorSpan } from "../src/trackChangesModel";
test("colorByAuthor wraps human-authored prose in cw-by-human spans", () => {
const raw = "hello world";
const spans: AuthorSpan[] = [{ start: 0, end: 5, author: "human" }];
const render = (src: string) => `<p>${src}</p>`;
const html = colorByAuthor(raw, 0, spans, render);
expect(html).toContain('<span class="cw-by-human">hello</span>');
expect(html).toContain("world");
});
```
- [ ] **Step 2: Run it — expect failure (`colorByAuthor` not exported).**
Run: `npx vitest run test/trackChangesModel.test.ts -t colorByAuthor`
Expected: FAIL — `colorByAuthor is not a function` / not exported.
- [ ] **Step 3: Implement `colorByAuthor` by lifting the sentinel logic.** Export a function that injects sentinels for the block's overlapping spans, runs the provided render, and maps sentinels → spans:
```ts
/**
* Color one prose block's HTML by F3 author spans (PUA-sentinel technique,
* salvaged from F9 renderAuthorship). `blockStart` is the block's char offset in
* the source so spans map correctly. Pure; deterministic.
*/
export function colorByAuthor(
raw: string,
blockStart: number,
spans: AuthorSpan[],
render: (src: string) => string,
): string {
const overlapping = spans.filter((s) => s.end > blockStart && s.start < blockStart + raw.length);
const injected = injectSentinels(raw, blockStart, overlapping);
return sentinelsToSpans(render(injected));
}
```
Keep `injectSentinels`, `sentinelsToSpans`, `SENT`, `isCloseSentinel`, `authorBadge` as the private helpers they already are.
- [ ] **Step 4: Run the test — expect PASS.**
Run: `npx vitest run test/trackChangesModel.test.ts -t colorByAuthor`
Expected: PASS.
- [ ] **Step 5: Refactor `renderAuthorship` to call `colorByAuthor` (no behavior change yet — it's removed in Task 7).** In its prose branch replace the inline `sentinelsToSpans(safe(injectSentinels(...)))` with `colorByAuthor(b.raw, b.start, overlapping, safe)`.
- [ ] **Step 6: Run the full model suite — expect PASS (no regression).**
Run: `npx vitest run test/trackChangesModel.test.ts`
Expected: PASS (all existing renderAuthorship/renderTrackChanges cases green).
- [ ] **Step 7: Commit.**
```bash
git add src/trackChangesModel.ts test/trackChangesModel.test.ts
git commit -m "F10 SLICE-2: extract colorByAuthor from renderAuthorship sentinels (#29)"
```
### Task 5: Add `renderPlain` (the off-state)
**Files:**
- Modify: `src/trackChangesModel.ts`
- Test: `test/trackChangesModel.test.ts`
- [ ] **Step 1: Write a failing test.**
```ts
import { renderPlain } from "../src/trackChangesModel";
test("renderPlain renders current buffer as plain markdown (no marks)", () => {
const html = renderPlain("# Title\n\nhello");
expect(html).toContain("<h1>Title</h1>");
expect(html).toContain("hello");
expect(html).not.toContain("cw-");
});
```
- [ ] **Step 2: Run it — expect FAIL (not exported).**
Run: `npx vitest run test/trackChangesModel.test.ts -t renderPlain`
Expected: FAIL.
- [ ] **Step 3: Implement.**
```ts
/** Off-state body: the current buffer as plain markdown, no annotations (INV-33). */
export function renderPlain(currentText: string, opts: RenderOptions = {}): string {
const render = opts.render ?? defaultRender;
try {
return render(currentText);
} catch (err) {
return chip(err instanceof Error ? err.message : String(err));
}
}
```
- [ ] **Step 4: Run — expect PASS.**
Run: `npx vitest run test/trackChangesModel.test.ts -t renderPlain`
Expected: PASS.
- [ ] **Step 5: Commit.**
```bash
git add src/trackChangesModel.ts test/trackChangesModel.test.ts
git commit -m "F10 SLICE-2: add renderPlain off-state (#29)"
```
### Task 6: Add `ProposalView` + `renderReview` (the on-state)
`renderReview` overlays two axes in one pass: (1) the F7 baseline block/word diff, with added/changed prose author-colored via `colorByAuthor` and deletions struck; (2) each pending proposal injected at its resolved anchor as a blue `cw-proposal` block carrying `data-proposal-id` and a ✓/✗ placeholder. Unanchored proposals render as trailing blocks (never dropped — INV-34).
**Files:**
- Modify: `src/trackChangesModel.ts`
- Test: `test/trackChangesModel.test.ts`
- [ ] **Step 1: Add the `ProposalView` type.** (Resolved offsets are computed by the controller; the pure engine receives them.)
```ts
export interface ProposalView {
id: string;
/** resolved offsets in currentText; null when the anchor did not resolve. */
anchorStart: number | null;
anchorEnd: number | null;
/** the text the proposal would replace (fp.text), for the struck "before". */
replaced: string;
/** the proposed replacement text. */
replacement: string;
}
```
- [ ] **Step 2: Write failing tests covering the on-state contract.**
```ts
import { renderReview, type ProposalView, type AuthorSpan } from "../src/trackChangesModel";
test("renderReview: human addition since baseline renders green <ins>", () => {
const html = renderReview("hello", "hello world",
[{ start: 6, end: 11, author: "human" }], []);
expect(html).toMatch(/<ins>[^<]*world[^<]*<\/ins>|cw-by-human/);
});
test("renderReview: deletion since baseline renders struck cw-del/<del>", () => {
const html = renderReview("hello world", "hello", [], []);
expect(html).toMatch(/<del>|cw-del/);
});
test("renderReview: a pending proposal renders a blue block with data-proposal-id and a ✓/✗ action placeholder", () => {
const proposals: ProposalView[] = [
{ id: "p1", anchorStart: 0, anchorEnd: 5, replaced: "hello", replacement: "goodbye" },
];
const html = renderReview("hello", "hello", [], proposals);
expect(html).toContain('class="cw-proposal"');
expect(html).toContain('data-proposal-id="p1"');
expect(html).toContain("cw-actions");
expect(html).toContain("goodbye");
expect(html).toMatch(/<del>[^<]*hello[^<]*<\/del>|cw-del/);
});
test("renderReview: an unresolved proposal renders as a trailing block (never dropped)", () => {
const proposals: ProposalView[] = [
{ id: "p2", anchorStart: null, anchorEnd: null, replaced: "x", replacement: "y" },
];
const html = renderReview("a", "a", [], proposals);
expect(html).toContain('data-proposal-id="p2"');
expect(html).toContain("cw-proposal-unanchored");
});
test("renderReview is deterministic (same inputs → identical HTML)", () => {
const a = renderReview("hello", "hello world", [{ start: 6, end: 11, author: "human" }], []);
const b = renderReview("hello", "hello world", [{ start: 6, end: 11, author: "human" }], []);
expect(a).toBe(b);
});
test("renderReview: an atomic mermaid change is diffed whole (no inner sentinels)", () => {
const base = "```mermaid\nflowchart LR\n A --> B\n```";
const cur = "```mermaid\nflowchart LR\n A --> C\n```";
const html = renderReview(base, cur, [], []);
expect(html).toContain("mermaid");
expect(html).not.toContain("cw-by-"); // fences stay atomic
});
```
- [ ] **Step 3: Run — expect FAIL (renderReview not exported).**
Run: `npx vitest run test/trackChangesModel.test.ts -t renderReview`
Expected: FAIL.
- [ ] **Step 4: Implement `renderReview`.** Reuse `diffBlocks` + `renderOp`, but author-color added/changed prose and append proposal blocks. The simplest correct composition: render the diff body via the existing `renderOp` path **augmented** so added/changed prose `<ins>` content is author-colored, then inject proposal blocks. Add an internal `renderReviewBlock` that colors prose and a `proposalBlockHtml` emitter:
```ts
function proposalBlockHtml(p: ProposalView, render: (src: string) => string): string {
const safe = (src: string): string => {
try { return render(src); } catch (err) { return chip(err instanceof Error ? err.message : String(err)); }
};
const unanchored = p.anchorStart === null ? " cw-proposal-unanchored" : "";
const before = p.replaced ? `<del class="cw-del">${safe(p.replaced)}</del>` : "";
const after = `<ins class="cw-add">${safe(p.replacement)}</ins>`;
const actions =
`<span class="cw-actions">` +
`<button class="cw-accept" data-action="accept">✓</button>` +
`<button class="cw-reject" data-action="reject">✗</button>` +
`</span>`;
return `<div class="cw-proposal${unanchored}" data-proposal-id="${p.id}">${actions}${before}${after}</div>`;
}
/**
* On-state body (INV-33): the F7 baseline diff — added/changed PROSE author-colored
* via colorByAuthor (F9 sentinels), deletions struck — overlaid with F4 pending
* proposals as blue cw-proposal blocks (✓/✗). One pass, pure, vscode-free.
* Proposals are injected at their resolved anchor's block; unresolved ones append
* as trailing cw-proposal-unanchored blocks (never dropped — INV-34).
*/
export function renderReview(
baselineText: string,
currentText: string,
authorSpans: AuthorSpan[],
proposals: ProposalView[],
opts: RenderOptions = {},
): string {
const render = opts.render ?? defaultRender;
// 1) the diff body, with prose author-coloring layered onto current-side blocks.
const ranges = splitBlocksWithRanges(currentText);
const ops = diffBlocks(baselineText, currentText);
// Map current-side blocks (added/unchanged/changed) to their source ranges for coloring.
const colored = (raw: string): string => {
const blk = ranges.find((r) => r.raw === raw);
if (!blk) return render(raw);
return colorByAuthor(raw, blk.start, authorSpans, render);
};
const bodyParts = ops.map((op) => renderReviewOp(op, render, colored));
// 2) proposal blocks. Resolved proposals are appended after the block containing
// their anchor; for v1 simplicity (and determinism) append all proposals in id
// order, anchored ones first, then unanchored — each carries its own marker.
const anchored = proposals.filter((p) => p.anchorStart !== null);
const unanchored = proposals.filter((p) => p.anchorStart === null);
const proposalParts = [...anchored, ...unanchored].map((p) => proposalBlockHtml(p, render));
return [...bodyParts, ...proposalParts].join("\n");
}
```
Add `renderReviewOp` — a thin wrapper over the existing `renderOp` that swaps the prose renderer for the author-coloring one on `added`/`changed`(non-atomic)/`unchanged` ops:
```ts
function renderReviewOp(
op: BlockOp,
render: (src: string) => string,
colored: (raw: string) => string,
): string {
// Atomic fences and deletions: identical to renderOp (no author sentinels).
if (op.kind === "removed") return renderOp(op, render);
if (op.kind === "changed" && op.atomic) return renderOp(op, render);
// Prose added/unchanged: author-color the block; changed prose: keep <ins>/<del>
// word merge (deletions struck), then we accept that word-merge is not per-author
// colored in v1 (covered by §6.7 "deletion coloring = neutral").
if (op.kind === "changed") return renderOp(op, render); // word-merged <ins>/<del>
// unchanged / added prose → author-colored.
return `<div class="cw-blk ${op.kind === "added" ? "cw-added" : "cw-unchanged"}">${colored(op.block.raw)}</div>`;
}
```
> Design note: per spec §6.7, deletion coloring is neutral and added-prose author-coloring is the primary signal; changed-prose keeps the F7 word-merge. This keeps the engine deterministic and the tests above green. If a later refinement wants author-colored `<ins>` inside changed prose, it extends `renderReviewOp`.
- [ ] **Step 5: Run the renderReview tests — expect PASS.**
Run: `npx vitest run test/trackChangesModel.test.ts -t renderReview`
Expected: PASS (all six).
- [ ] **Step 6: Run the full model suite.**
Run: `npx vitest run test/trackChangesModel.test.ts`
Expected: PASS.
- [ ] **Step 7: Commit.**
```bash
git add src/trackChangesModel.ts test/trackChangesModel.test.ts
git commit -m "F10 SLICE-2: add ProposalView + renderReview combined on-state render (#29)"
```
### Task 7: Remove the superseded public `renderAuthorship`
**Files:**
- Modify: `src/trackChangesModel.ts` (remove `renderAuthorship`), `src/trackChangesPreview.ts` (stops importing it — done in SLICE-3, so here just remove the export and fix the model)
- Test: `test/trackChangesModel.test.ts` (drop/replace renderAuthorship-only cases salvaged into colorByAuthor)
- [ ] **Step 1: Delete the exported `renderAuthorship` function** (lines 360-386). Keep `colorByAuthor`, `injectSentinels`, `sentinelsToSpans`, `authorBadge`, `SENT`.
- [ ] **Step 2: Remove or repoint any test that imports `renderAuthorship`.** Any remaining authorship-only assertions are now covered by the `colorByAuthor` test (Task 4); delete the obsolete `renderAuthorship` test cases.
- [ ] **Step 3: Typecheck — expect a known error in `trackChangesPreview.ts`** (it still imports `renderAuthorship`). That import is removed in SLICE-3 Task 9; note it and proceed (do not patch the preview here beyond removing the dangling import if convenient).
Run: `npm run build`
Expected: error only at `trackChangesPreview.ts` `renderAuthorship` import → resolved in Task 9. (If you prefer a clean build per-task, remove the import + authorship branch now as a head-start on Task 9.)
- [ ] **Step 4: Run the model suite — expect PASS.**
Run: `npx vitest run test/trackChangesModel.test.ts`
Expected: PASS.
- [ ] **Step 5: Commit.**
```bash
git add src/trackChangesModel.ts test/trackChangesModel.test.ts
git commit -m "F10 SLICE-2: remove superseded public renderAuthorship (salvaged into colorByAuthor) (#29)"
```
---
## SLICE-3 — Interactive controller + webview (INV-34)
Add `ProposalController.listProposals` + `onDidChangeProposals`; wire `ProposalController` into the preview; collapse mode to `"on"|"off"`; route ✓/✗/setMode messages; rebuild the webview asset; status-bar indicator.
### Task 8: `ProposalController.listProposals` + `onDidChangeProposals`
**Files:**
- Modify: `src/proposalController.ts`
- [ ] **Step 1: Add the change emitter + event.** Near the other fields:
```ts
private readonly onDidChangeProposalsEmitter = new vscode.EventEmitter<{ uri: string }>();
/** Fires on propose / accept / reject / external sidecar change (F10). */
readonly onDidChangeProposals = this.onDidChangeProposalsEmitter.event;
```
Push it into `disposables` and fire it: at the end of `propose` (after `renderAll`), `accept` (after `removeProposal`+`renderAll`), `reject` (after `renderAll`), and `handleExternalSidecarChange`/`renderAll`. Fire with the document's URI string:
```ts
private fireChanged(document: vscode.TextDocument): void {
this.onDidChangeProposalsEmitter.fire({ uri: document.uri.toString() });
}
```
Call `this.fireChanged(document)` at the end of `renderAll(document)` (it is the common funnel for propose/accept/reject/external change).
- [ ] **Step 2: Add `listProposals`.** Return resolved views for the preview's render. Resolve each proposal against the *current* document text (same as `renderAll`), exposing `fp.text` as `replaced`:
```ts
import type { ProposalView } from "./trackChangesModel";
/** Resolved proposal views for the F10 preview (anchorStart=null when unresolved). */
listProposals(document: vscode.TextDocument): ProposalView[] {
const docPath = this.keyOf(document);
const artifact = this.store.load(docPath) ?? emptyArtifact(docPath);
const text = document.getText();
return artifact.proposals.map((p) => {
const fp = artifact.anchors[p.anchorId]?.fingerprint;
const resolved = fp ? resolve(text, fp) : "orphaned";
return {
id: p.id,
anchorStart: resolved === "orphaned" ? null : resolved.start,
anchorEnd: resolved === "orphaned" ? null : resolved.end,
replaced: fp?.text ?? "",
replacement: p.replacement,
};
});
}
```
- [ ] **Step 3: Typecheck.**
Run: `npm run build`
Expected: clean (the `ProposalView` import resolves against Task 6's export).
- [ ] **Step 4: Commit.**
```bash
git add src/proposalController.ts
git commit -m "F10 SLICE-3: ProposalController.listProposals + onDidChangeProposals (#29)"
```
### Task 9: Collapse preview mode to on/off, take `ProposalController`, route messages
**Files:**
- Modify: `src/trackChangesPreview.ts` (constructor, mode map, `refresh`, message handler, shell HTML, test seams, imports)
- [ ] **Step 1: Update imports + mode type.** Replace the model import with the F10 surface and switch the mode union:
```ts
import { renderReview, renderPlain, diffBlocks, type BlockOp } from "./trackChangesModel";
import type { ProposalController } from "./proposalController";
```
```ts
/** F10: per-panel annotations toggle — on (combined render) or off (plain). */
private readonly mode = new Map<string, "on" | "off">();
```
- [ ] **Step 2: Add the `ProposalController` constructor dependency + subscribe to its change event.**
```ts
constructor(
private readonly diffView: DiffViewController,
private readonly extensionUri: vscode.Uri,
private readonly attribution: AttributionController,
private readonly proposals: ProposalController,
) {
this.disposables.push(
vscode.commands.registerCommand("cowriting.showTrackChangesPreview", () =>
this.show(vscode.window.activeTextEditor?.document),
),
vscode.workspace.onDidChangeTextDocument((e) => this.onEdit(e.document)),
this.diffView.onDidChangeBaseline(({ uri }) => this.refreshByUri(uri)),
this.proposals.onDidChangeProposals(({ uri }) => { this.refreshByUri(uri); this.updateStatus(uri); }),
);
}
```
- [ ] **Step 3: Replace the inbound message handler** (the `setMode` block) to also handle accept/reject:
```ts
panel.webview.onDidReceiveMessage(
(m: { type?: string; mode?: "on" | "off"; proposalId?: string }) => {
if (m?.type === "setMode" && (m.mode === "on" || m.mode === "off")) {
this.mode.set(key, m.mode);
this.refresh(document);
} else if (m?.type === "accept" && m.proposalId) {
void this.proposals
.acceptById(this.proposalKey(document), m.proposalId)
.then(() => this.refresh(document));
} else if (m?.type === "reject" && m.proposalId) {
this.proposals.rejectById(this.proposalKey(document), m.proposalId);
this.refresh(document);
}
},
null,
this.disposables,
);
```
Add a `proposalKey(document)` helper that returns the same key `ProposalController` uses (`this.store.keyOf(docIdentity(document))`). Since the preview doesn't hold the router, expose a public `keyFor(document): string` on `ProposalController` and call it:
In `proposalController.ts`:
```ts
/** The doc key F4 uses (F8 routing) — exposed for F10's preview. */
keyFor(document: vscode.TextDocument): string { return this.keyOf(document); }
```
In the preview, `private proposalKey(d: vscode.TextDocument) { return this.proposals.keyFor(d); }`.
- [ ] **Step 4: Rewrite `refresh` for on/off.** Replace the `"authorship"`/`"changes"` branches with one combined `renderReview` (on) / `renderPlain` (off):
```ts
refresh(document: vscode.TextDocument): void {
const key = document.uri.toString();
const panel = this.panels.get(key);
if (!panel) return;
const mode = this.mode.get(key) ?? "on";
const current = document.getText();
const baseline = this.diffView.getBaseline(key);
const baselineText = baseline?.text ?? current; // no baseline → no change-marks
const ops = diffBlocks(baselineText, current);
this.lastModel.set(key, ops);
if (mode === "off") {
void panel.webview.postMessage({ type: "render", mode, html: renderPlain(current) });
return;
}
const spans = this.attribution.spansFor(document);
const proposals = this.proposals.listProposals(document);
const summary = {
added: ops.filter((o) => o.kind === "added").length,
removed: ops.filter((o) => o.kind === "removed").length,
proposals: proposals.length,
};
void panel.webview.postMessage({
type: "render",
mode,
html: renderReview(baselineText, current, spans, proposals),
epoch: this.epochLabel(baseline),
summary,
});
}
```
- [ ] **Step 5: Update the shell HTML header** — replace the segmented `[Track changes | Authorship]` with an on/off switch:
```ts
<div id="cw-header">
<label id="cw-toggle"><input type="checkbox" id="cw-annotations" checked /> Annotations</label>
<span id="cw-epoch">Review</span>
<span id="cw-summary"></span>
<span id="cw-legend"></span>
</div>
```
- [ ] **Step 6: Update the test seams** for the new mode type:
```ts
getMode(uriString: string): "on" | "off" { return this.mode.get(uriString) ?? "on"; }
setMode(uriString: string, mode: "on" | "off"): void {
this.mode.set(uriString, mode);
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uriString);
if (doc) this.refresh(doc);
}
/** F10 test seam: the on-state review HTML the panel would post. */
renderHtmlFor(uriString: string): string {
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uriString);
if (!doc) return "";
const current = doc.getText();
const baseline = this.diffView.getBaseline(uriString);
return renderReview(baseline?.text ?? current, current, this.attribution.spansFor(doc), this.proposals.listProposals(doc));
}
```
- [ ] **Step 7: Add the status-bar item + `updateStatus`** (PUC-6) — see Task 10 (implement them here or in Task 10; cross-reference). For now stub `private updateStatus(_uri: string): void {}` so this task typechecks, and fill it in Task 10.
- [ ] **Step 8: Typecheck.**
Run: `npm run build`
Expected: clean — no `renderAuthorship`/`renderTrackChanges` references remain in the preview.
- [ ] **Step 9: Commit.**
```bash
git add src/trackChangesPreview.ts src/proposalController.ts
git commit -m "F10 SLICE-3: preview takes ProposalController; on/off mode; renderReview path; accept/reject routing (#29)"
```
### Task 10: Status-bar indicator (PUC-6)
A status-bar item shows the pending-proposal count when **no preview panel is open** for any doc with proposals; clicking it runs `cowriting.showTrackChangesPreview`. Hidden when count is 0 or a panel is open.
**Files:**
- Modify: `src/trackChangesPreview.ts`
- [ ] **Step 1: Create the status-bar item in the constructor.**
```ts
private readonly statusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 88);
```
In the constructor body: `this.statusItem.command = "cowriting.showTrackChangesPreview"; this.disposables.push(this.statusItem);`
- [ ] **Step 2: Implement `updateStatus`.** Count pending proposals for the doc whose proposals changed; show the item only if that doc has no open panel:
```ts
private updateStatus(uri: string): void {
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri);
if (!doc) { this.statusItem.hide(); return; }
const n = this.proposals.listProposals(doc).length;
if (n === 0 || this.panels.has(uri)) { this.statusItem.hide(); return; }
this.statusItem.text = `$(comment-discussion) ${n} Claude proposal${n === 1 ? "" : "s"}`;
this.statusItem.tooltip = "Cowriting: open the review preview to accept/reject Claude's proposals";
this.statusItem.show();
}
```
- [ ] **Step 3: Hide the status item when a panel opens.** In `show()` after `this.panels.set(key, panel)`, call `this.statusItem.hide()`. In `onDidDispose`, call `this.updateStatus(key)` so it reappears if proposals remain.
- [ ] **Step 4: Dispose the status item.** Already pushed to `disposables`; confirm `dispose()` clears it.
- [ ] **Step 5: Typecheck.**
Run: `npm run build`
Expected: clean.
- [ ] **Step 6: Commit.**
```bash
git add src/trackChangesPreview.ts
git commit -m "F10 SLICE-3: status-bar proposal indicator (PUC-6) (#29)"
```
### Task 11: Webview asset — on/off switch + ✓/✗ click handler + CSS
**Files:**
- Modify: `media/preview.ts`, `media/preview.css`
- [ ] **Step 1: Update the inbound `RenderMessage` type + handler in `media/preview.ts`.** The mode is now `"on"|"off"`; `summary` carries `{added, removed, proposals}`:
```ts
type RenderMessage = {
type: "render";
mode: "on" | "off";
html: string;
epoch?: string;
summary?: { added: number; removed: number; proposals: number };
};
```
On receive: set `body.innerHTML = msg.html`; reflect the checkbox (`annotationsEl.checked = msg.mode === "on"`); show/hide summary/legend; then `renderMermaid()`.
- [ ] **Step 2: Wire the on/off checkbox → `postMessage`.** Replace the `.cw-seg` segmented-control wiring:
```ts
const annotationsEl = document.getElementById("cw-annotations") as HTMLInputElement | null;
annotationsEl?.addEventListener("change", () => {
vscode.postMessage({ type: "setMode", mode: annotationsEl.checked ? "on" : "off" });
});
```
- [ ] **Step 3: Add the ✓/✗ delegated click handler.** Buttons are inside `.cw-proposal` blocks carrying `data-proposal-id`; read the id + `data-action` and post intent:
```ts
document.getElementById("cw-body")?.addEventListener("click", (e) => {
const btn = (e.target as HTMLElement)?.closest<HTMLElement>(".cw-actions button");
if (!btn) return;
const block = btn.closest<HTMLElement>(".cw-proposal");
const id = block?.dataset.proposalId;
const action = btn.dataset.action;
if (id && (action === "accept" || action === "reject")) {
vscode.postMessage({ type: action, proposalId: id });
}
});
```
- [ ] **Step 4: Add CSS in `media/preview.css`** for the proposal block, ✓/✗ buttons, and on/off toggle, theme-variable-driven:
```css
.cw-proposal {
position: relative;
border-left: 3px solid var(--vscode-charts-blue, #4daafc);
background: color-mix(in srgb, var(--vscode-charts-blue, #4daafc) 12%, transparent);
padding: 0.4em 0.6em;
margin: 0.4em 0;
border-radius: 3px;
}
.cw-proposal-unanchored { border-left-style: dashed; opacity: 0.85; }
.cw-actions { position: absolute; top: 0.2em; right: 0.4em; display: inline-flex; gap: 0.25em; }
.cw-actions button {
cursor: pointer; border: 1px solid var(--vscode-button-border, transparent);
border-radius: 3px; font-size: 0.9em; line-height: 1; padding: 0.1em 0.35em;
background: var(--vscode-button-secondaryBackground); color: var(--vscode-button-secondaryForeground);
}
.cw-accept:hover { background: var(--vscode-testing-iconPassed, #2ea043); color: #fff; }
.cw-reject:hover { background: var(--vscode-errorForeground, #f14c4c); color: #fff; }
#cw-toggle { display: inline-flex; align-items: center; gap: 0.35em; cursor: pointer; }
```
- [ ] **Step 5: Build the webview bundle + typecheck.**
Run: `npm run build`
Expected: clean; `out/media/preview.js` rebuilt.
- [ ] **Step 6: Commit.**
```bash
git add media/preview.ts media/preview.css
git commit -m "F10 SLICE-3: webview on/off switch + ✓/✗ click→postMessage + proposal CSS (#29)"
```
### Task 12: Wire it together in `extension.ts`
**Files:**
- Modify: `src/extension.ts:92-105` (reorder: proposals before preview; inject)
- [ ] **Step 1: Move `ProposalController` construction above `TrackChangesPreviewController`** and pass it in:
```ts
// --- F4: propose/accept (Feature #12) — constructed before the preview so F10
// can route ✓/✗ through it ---
const proposalController = new ProposalController(sidecarRouter, attributionController, root, versionGuard);
context.subscriptions.push(proposalController);
// --- F7/F10: the review preview is the single interactive review surface ---
const trackChangesPreviewController = new TrackChangesPreviewController(
diffViewController,
context.extensionUri,
attributionController,
proposalController,
);
context.subscriptions.push(trackChangesPreviewController);
```
Remove the now-duplicate later `proposalController` construction (old line 104).
- [ ] **Step 2: Typecheck + full unit suite.**
Run: `npm run build && npm test`
Expected: clean build; vitest green (model + proposalModel + all vscode-free suites).
- [ ] **Step 3: Commit.**
```bash
git add src/extension.ts
git commit -m "F10 SLICE-3: wire ProposalController into the review preview (#29)"
```
---
## SLICE-4 — Tests & docs
### Task 13: Host E2E for the interactive review flow
**Files:**
- Modify: `test/e2e/suite/trackChangesPreview.test.ts` (or create `test/e2e/suite/f10Review.test.ts`), `test/e2e/suite/proposals.test.ts` (drop in-editor-thread assertions), `test/e2e/suite/authorship.test.ts` (remove/repoint F9 authorship-mode cases)
- [ ] **Step 1: Repoint the obsolete F9 authorship E2E.** `authorship.test.ts` exercised `setMode("authorship")` + `renderAuthorship`. Either delete the file or rewrite its cases against `setMode(uri, "on")` and `renderReview` output (author-colored spans present). Confirm the suite index still imports valid files.
- [ ] **Step 2: Fix `proposals.test.ts`.** Remove assertions about in-editor comment threads / `getRendered.canReply` semantics that no longer hold; keep `acceptById`/`rejectById` behavior assertions (those seams are unchanged).
- [ ] **Step 3: Add the F10 host-E2E cases** (extend `trackChangesPreview.test.ts`). Use the existing harness patterns (open a markdown fixture, run `cowriting.showTrackChangesPreview`, drive the controller via its test seams and the `applyAgentEdit`/`proposeAgentEdit` commands). Assert:
- open markdown → panel open; `getLastModel` on a fresh baseline shows no change-marks.
- type text → an `added` BlockOp; the review HTML (`renderHtmlFor`) contains a `cw-by-human` span.
- `proposeAgentEdit` seam → `listProposals` returns one view with an id; `renderHtmlFor` contains `data-proposal-id` + `cw-actions`.
- simulate accept: call `proposalController.acceptById(key, id)` → proposal gone from `listProposals`, baseline advanced, the block now ordinary; document text reflects the replacement.
- simulate reject on a second proposal → gone from `listProposals`; document text unchanged.
- **clean editor:** assert no attribution decoration types are applied — e.g. assert `vscode.window.activeTextEditor` has no cowriting decorations (or that `AttributionController.render` no longer creates them; a structural assertion that the decoration-type fields were removed).
- `toggleDiffView` is hidden: assert its keybinding `when` is `false` (read from `package.json`) or that invoking it is a no-op user-facing.
- non-markdown doc → `show` warns, no panel (`isOpen` false).
- status-bar: after a propose with no panel open for that doc, the controller's status path reports a pending count (assert via a small seam if needed, e.g. expose `statusText()` for tests).
- [ ] **Step 4: Add a tiny test seam if needed.** If asserting the status bar is awkward, add `statusText(): string | undefined` returning `this.statusItem.text` when visible.
- [ ] **Step 5: Run the host E2E suite.**
Run: `npm run test:e2e`
Expected: PASS (all suites, including the updated proposals/authorship and new F10 cases).
- [ ] **Step 6: Run the full unit suite too.**
Run: `npm test`
Expected: PASS.
- [ ] **Step 7: Commit.**
```bash
git add test
git commit -m "F10 SLICE-4: host E2E for interactive review (open/toggle/propose→accept→reject/clean-editor/hidden-F6/status) (#29)"
```
### Task 14: Manual smoke doc + README
**Files:**
- Create: `docs/MANUAL-SMOKE-F10.md`
- Modify: `README.md` (add an F10 section; note F6 two-pane + F9 authorship-mode are superseded as user surfaces)
- [ ] **Step 1: Write `docs/MANUAL-SMOKE-F10.md`** following the F7/F9 smoke format. Checklist: open a markdown doc → editor is clean (no tint/threads); edit prose → green `<ins>` / struck `<del>` in the preview; ask Claude to edit a selection → a blue proposal block with ✓/✗ appears; click ✓ (lands, mark clears, editor updated) and ✗ on another (block vanishes, doc unchanged); toggle Annotations off (clean render) / on; with no preview open, the status-bar indicator shows the pending count and opens the preview when clicked; verify light/dark/high-contrast theming; verify `git status` shows nothing persisted.
- [ ] **Step 2: Update `README.md`.** Add an F10 entry to the feature list describing "write left / review right," the annotations on/off toggle, and ✓/✗ in the preview; add a one-line note that F6's two-pane diff and F9's authorship mode are retained as data layers but no longer separate user surfaces; mention `ctrl+alt+r` opens the review preview.
- [ ] **Step 3: Commit.**
```bash
git add docs/MANUAL-SMOKE-F10.md README.md
git commit -m "F10 SLICE-4: manual smoke checklist + README F10 section (#29)"
```
---
## Final verification (before PR)
- [ ] `npm test` — vitest green (model incl. renderReview/renderPlain/colorByAuthor; proposalModel; all vscode-free suites).
- [ ] `npm run test:e2e` — host E2E green (open/toggle/propose→accept→reject/clean-editor/hidden-F6/status/non-markdown).
- [ ] `npm run build` — clean typecheck + bundles.
- [ ] Manual smoke per `docs/MANUAL-SMOKE-F10.md` performed once (the webview visual + real button clicks the E2E can't cover).
- [ ] `git status` clean (nothing persisted by the preview — INV-20).
- [ ] Open PR to `main`, citing `#29` and `specs/coauthoring-interactive-review.md`; acceptance per spec §7.3.
---
## Self-Review notes (spec coverage)
- INV-32 (clean editor) → SLICE-1 (Tasks 1-3). INV-33 (combined pure render) → SLICE-2 (Tasks 4-7). INV-34 (✓/✗ via F4 seam) → SLICE-3 (Tasks 8-12).
- PUC-1 clean editor → Task 1/2; PUC-2 toggle → Task 9/11; PUC-3 who-changed-what → Task 6; PUC-4/5 accept/reject → Task 9 (routing) + Task 6 (blocks); PUC-6 status-bar → Task 10; PUC-7 edges (non-markdown warn, no-baseline note, unanchored proposal) → Task 6 (unanchored), Task 9 (no-baseline), `show()` (non-markdown, existing).
- F9 INV-26 reversal (authorship combined with diff; remove segmented control/`renderAuthorship`) → Tasks 7, 9, 13(step1).
- Supersession: F6 two-pane hidden → Task 3; F9 INV-27/28 absorbed into renderReview → Task 6.
- Reconciliation captured: public seams `acceptById`/`rejectById` (Task 9), `Proposal.anchorId` resolved via `resolve()` in `listProposals` (Task 8), new `onDidChangeProposals` (Task 8), `keyFor` exposure for the preview's doc key (Task 9).
File diff suppressed because it is too large Load Diff
+123
View File
@@ -0,0 +1,123 @@
# Implementation Plan: F11 — Preview Toolbar as the Primary Interaction Surface (#43)
**Spec:** `docs/superpowers/specs/2026-06-12-f11-preview-toolbar-interaction-surface.md`
**Anchor:** Feature `benstull/vscode-cowriting-plugin#43` (F11, `type/feature`, `priority/P1`)
**Session:** vscode-cowriting-plugin-0037
This plan transcribes the spec's §7.2 slicing plan into concrete, file-level
tasks. Each slice is independently green (unit + host E2E) before the next. Host
E2E is this app's required tier (no browser/deploy stage — a VS Code extension);
no LLM in CI (edit turns stubbed). The webview's visual rendering, the adaptive
label, and the selection→source DOM lookup are manual-smoke only.
---
## SLICE-1 — Pin baseline button + reachability *(the immediate win)*
Homes the orphaned `cowriting.pinDiffBaseline` command and gives the writer a
reachable Pin control in the preview toolbar.
**Tasks**
1. **Host message routing** (`src/trackChangesPreview.ts`): extract the inline
`onDidReceiveMessage` body into a private `handleWebviewMessage(document, m)`
method; add a `pinBaseline` branch that calls `this.diffView.pin(document)`
(the *previewed* document — not `activeTextEditor`). The existing
`onDidChangeBaseline` subscription already re-renders with cleared marks.
2. **Test seam**: add `receiveMessage(uriString, m)` that resolves the doc and
calls `handleWebviewMessage`, so host E2E can simulate the raw webview message
and exercise the real routing.
3. **Webview** (`media/preview.ts` + `.css`): add a `⌖ Pin baseline` button in
`#cw-header`; click → `postMessage({ type: "pinBaseline" })`; theme-aware CSS.
4. **Shell HTML** (`shellHtml`): add the `<button id="cw-pin">` to the header row.
5. **Reachability** (`package.json`): flip `cowriting.pinDiffBaseline`'s
`commandPalette` `when` from `false` to `editorLangId == markdown`.
**Host E2E** (`test/e2e/suite/trackChangesPreview.test.ts` or new f11 suite):
open a markdown fixture with a divergent baseline → some block marked; simulate
`{type:"pinBaseline"}` via `receiveMessage``getLastModel` shows every block
`unchanged` (marks cleared) and `getBaseline(key).reason === "pinned"`.
**Unit:** none new (pure layer untouched this slice).
---
## SLICE-2 — Block-offset emission *(INV-36 data layer)*
Shared pure helper wrapping each rendered block with `data-src-start`/`-end`
(source char offsets from `BlockWithRange`) in **both** `renderReview` and
`renderPlain`. vscode-free, DOM-free, deterministic (extends INV-22/33). No UI.
**Tasks**
1. `src/trackChangesModel.ts`: a shared internal helper that prepends
`data-src-start="N" data-src-end="M"` to each block's wrapping element, routed
from both render paths using the existing `splitBlocksWithRanges` offsets.
2. Skip blocks with no live-source range (deletion-only / proposal blocks).
**Unit** (`test/trackChangesModel.test.ts`): `data-src-start/end` present and
correct on every block for both modes (offsets equal `BlockWithRange` ranges);
determinism (same inputs → identical HTML).
---
## SLICE-3 — Edit Document button + hunk path *(INV-37 document half)*
**Tasks**
1. `src/trackChangesModel.ts`: pure `diffToHunks(currentText, rewrittenText):
Array<{ start; end; replacement }>` — vscode-free, deterministic.
2. `src/trackChangesPreview.ts`: `runEditAndPropose(document, target, instruction)`
private routine; `askClaude`/`document` branch → host `showInputBox` →
`runEditTurn` over full text → `diffToHunks` → one F4 `propose()` per hunk.
3. `package.json`: register `cowriting.editDocument` (document-scoped), routed
through `runEditAndPropose({kind:"document"})`; for `#42` reuse.
4. Webview: `✦ Ask Claude to Edit Document` button (no-selection state) →
`postMessage({ type: "askClaude", scope: "document" })`.
**Unit:** `diffToHunks` over fixtures (single hunk → one range; multi-hunk →
disjoint ranges + correct replacements; unchanged → zero hunks; whole-doc
replacement → one full-range hunk).
**Host E2E:** simulate `{askClaude, scope:"document"}` with a stubbed multi-hunk
rewrite → **N** proposals matching the hunks.
---
## SLICE-4 — Adaptive Edit Selection *(INV-37 selection half; INV-36 consumer)*
**Tasks**
1. Webview: `selectionchange` listener flips the Ask-Claude label (Edit Selection
⇆ Edit Document); selection→nearest-`data-src` ancestor resolution →
`postMessage({ type:"askClaude", scope:"selection", start, end })`.
2. Host: `askClaude`/`selection` branch → `runEditAndPropose({kind:"range",
start, end})` → one `runEditTurn` → one F4 `propose()` over the block-union.
3. Edge: selection resolving to no live block → fall back to document scope.
**Host E2E:** simulate `{askClaude, scope:"selection", start, end}` with a stubbed
edit turn → exactly **one** proposal over the resolved range, anchored inline.
---
## SLICE-5 — Gateway, edges, tests & docs
**Tasks**
1. `package.json`: add `cowriting.showTrackChangesPreview` to `editor/title` with
`when: editorLangId == markdown` (minimal right-click gateway).
2. Non-authorable disabling: Pin + Ask-Claude controls render disabled when
`!isAuthorable(document)`; annotations toggle stays active.
3. Host E2E: gateway command opens the panel; controls inert on non-authorable.
4. `docs/MANUAL-SMOKE-F11.md` (live smoke script per spec §6.8).
5. README F11 section.
---
## Done = #43 acceptance (spec §7.3)
Preview toolbar hosts annotations checkbox + Pin baseline + single adaptive
Ask-Claude (Edit Selection ⇆ Edit Document) routing through existing F4/F3/F6;
edits surface as proposals (one for a selection, per-hunk for a document
rewrite); a right-click entry opens the preview; the pin command is no longer
orphaned; unit + host E2E green; live smoke performed once.
File diff suppressed because it is too large Load Diff
+910
View File
@@ -0,0 +1,910 @@
# Live Turn Progress (#60) 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:** Surface Claude's live output/progress (activity + token count + streamed text + cancel) during the otherwise-opaque "asking Claude…" status, for both edit entry points.
**Architecture:** A pure reducer (`turnProgress.ts`) folds `@cline/sdk` Agent runtime events into a small `TurnProgressSnapshot`; `runEditTurn` subscribes to the agent and emits snapshots via an `onProgress` callback while accepting an `AbortSignal` for cancellation — staying `vscode`-free. The two call sites relay snapshots through a shared host `liveProgressUi` module: the existing `withProgress` notification shows a live activity line, a dedicated OutputChannel streams the full assistant text, and the notification becomes cancellable. The final result path (proposals) is untouched.
**Tech Stack:** TypeScript (strict, `moduleResolution: Bundler`), VS Code extension API, `@cline/sdk` (ESM, dynamic-import-only, never bundled), Vitest (unit), `@vscode/test-electron` (host E2E), esbuild.
**Spec:** `coauthoring-live-progress.md` (graduated, content repo). Invariants INV-43..47.
---
## File Structure
- **Create `src/turnProgress.ts`** — pure reducer + line formatter. No `vscode`, no SDK runtime import (`AgentRuntimeEvent` type-only). Owns ALL event→state logic (INV-46).
- **Create `test/turnProgress.test.ts`** — unit tests for the reducer + `formatProgressLine`.
- **Modify `src/liveTurn.ts`** — `runEditTurn` gains `RunEditTurnOptions { modelId?, onProgress?, signal? }`; subscribes to the agent, folds events through the reducer, wires `signal``agent.abort()`. Result shape unchanged (INV-44). Stays `vscode`-free (INV-43).
- **Create `src/liveProgressUi.ts`** — host module (vscode-only): owns the shared `"Cowriting: Claude"` OutputChannel and a `begin(instruction, progress, token)` helper returning `{ signal, onProgress }` for one `withProgress` run. Reads `cowriting.liveProgress.revealOutput`.
- **Modify `src/extension.ts`** — construct `liveProgressUi` once at activate; the `editSelection` command uses it (cancellable notification + onProgress + signal); pass it into the preview controller; expose it on `CowritingApi` for E2E.
- **Modify `src/trackChangesPreview.ts`** — widen the `EditTurn` seam to accept `opts?`; thread `opts` through `askClaude``runEditAndPropose``editTurn`; use the injected `liveProgressUi` in `askClaude`.
- **Modify `package.json`** — add `contributes.configuration` with `cowriting.liveProgress.revealOutput` (boolean, default `true`).
- **Modify `test/e2e/suite/f11Toolbar.test.ts`** (or a new `test/e2e/suite/liveProgress.test.ts`) — extend the preview `editTurn` stub to emit synthetic progress and assert (a) identical proposals (INV-44) and (b) cancel→zero proposals (INV-47).
**Build note:** the type-only `import type { AgentRuntimeEvent } from "@cline/sdk"` is erased at compile and `@cline/*` stays external in esbuild — no bundling of the ESM SDK (preserves the POC constraint).
---
## Task 1: Pure progress reducer (`turnProgress.ts`)
**Files:**
- Create: `src/turnProgress.ts`
- Test: `test/turnProgress.test.ts`
- [ ] **Step 1: Write the failing test**
Create `test/turnProgress.test.ts`:
```ts
import { describe, it, expect } from "vitest";
import {
createTurnProgressState,
reduceTurnProgress,
formatProgressLine,
formatTokens,
type TurnProgressSnapshot,
} from "../src/turnProgress";
// Minimal event factories — structurally match the @cline/sdk AgentRuntimeEvent
// members the reducer reads. `as any` because we only supply the fields used.
const ev = (e: any) => e as any;
const textDelta = (text: string, accumulatedText: string) =>
ev({ type: "assistant-text-delta", text, accumulatedText });
const toolStarted = (toolName: string) => ev({ type: "tool-started", toolCall: { toolName } });
const toolFinished = (toolName: string) => ev({ type: "tool-finished", toolCall: { toolName } });
const usage = (inputTokens: number, outputTokens: number) =>
ev({ type: "usage-updated", usage: { inputTokens, outputTokens, cacheReadTokens: 0, cacheWriteTokens: 0 } });
// Drive a sequence of events, returning every emitted snapshot.
function run(events: any[]): TurnProgressSnapshot[] {
let state = createTurnProgressState();
const out: TurnProgressSnapshot[] = [];
for (const e of events) {
const r = reduceTurnProgress(state, e);
state = r.state;
if (r.snapshot) out.push(r.snapshot);
}
return out;
}
describe("reduceTurnProgress", () => {
it("starts in thinking", () => {
const s = run([ev({ type: "run-started" })]);
expect(s.at(-1)!.phase).toBe("thinking");
expect(s.at(-1)!.chars).toBe(0);
expect(s.at(-1)!.tokens).toBeUndefined();
});
it("text deltas move to writing and accumulate chars + carry the delta", () => {
const s = run([textDelta("Hel", "Hel"), textDelta("lo", "Hello")]);
expect(s.map((x) => x.phase)).toEqual(["writing", "writing"]);
expect(s.at(-1)!.chars).toBe(5);
expect(s.map((x) => x.textDelta)).toEqual(["Hel", "lo"]);
});
it("usage sets a running token total (input+output)", () => {
const s = run([textDelta("Hi", "Hi"), usage(1000, 234)]);
expect(s.at(-1)!.tokens).toBe(1234);
});
it("tool start shows the tool name; tool finish reverts to writing once text was seen", () => {
const s = run([textDelta("x", "x"), toolStarted("read_file"), toolFinished("read_file")]);
expect(s[1].phase).toBe("tool");
expect(s[1].tool).toBe("read_file");
expect(s.at(-1)!.phase).toBe("writing");
});
it("tool finish reverts to thinking when no text was seen", () => {
const s = run([toolStarted("grep"), toolFinished("grep")]);
expect(s.at(-1)!.phase).toBe("thinking");
});
it("overlapping tools resolve in order", () => {
const s = run([toolStarted("a"), toolStarted("b"), toolFinished("b"), toolFinished("a")]);
expect(s.map((x) => x.phase)).toEqual(["tool", "tool", "tool", "thinking"]);
expect(s[1].tool).toBe("b");
expect(s[2].tool).toBe("a");
});
it("reasoning deltas stay thinking and surface no text", () => {
const s = run([ev({ type: "assistant-reasoning-delta", text: "secret", accumulatedText: "secret" })]);
expect(s.at(-1)!.phase).toBe("thinking");
expect(s.at(-1)!.textDelta).toBeUndefined();
});
it("ignores lifecycle/finish events (no snapshot)", () => {
const s = run([ev({ type: "turn-finished" }), ev({ type: "run-finished" })]);
expect(s).toEqual([]);
});
});
describe("formatProgressLine / formatTokens", () => {
it("thinking", () => {
expect(formatProgressLine({ phase: "thinking", chars: 0 })).toBe("thinking…");
});
it("writing with chars", () => {
expect(formatProgressLine({ phase: "writing", chars: 412 })).toBe("writing… (412 chars)");
});
it("writing with chars + tokens", () => {
expect(formatProgressLine({ phase: "writing", chars: 412, tokens: 1234 })).toBe(
"writing… (412 chars) · 1.2k tokens",
);
});
it("tool with name", () => {
expect(formatProgressLine({ phase: "tool", tool: "read_file", chars: 0 })).toBe("running read_file…");
});
it("formats token magnitudes", () => {
expect(formatTokens(950)).toBe("950");
expect(formatTokens(1234)).toBe("1.2k");
});
});
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `npx vitest run test/turnProgress.test.ts`
Expected: FAIL — `Cannot find module "../src/turnProgress"`.
- [ ] **Step 3: Write the reducer**
Create `src/turnProgress.ts`:
```ts
/**
* turnProgress.ts — pure reduction of @cline/sdk Agent runtime events into a
* small UI-facing progress snapshot (#60, spec coauthoring-live-progress.md §3.2).
*
* INV-43: vscode-free. INV-46: a pure function — no vscode, no SDK runtime
* dependency (`AgentRuntimeEvent` is imported TYPE-only, so it is erased at
* compile and never pulls the ESM SDK into the bundle). All event→state logic
* lives here so it is unit-tested in isolation; the UI call sites only format and
* relay snapshots.
*/
import type { AgentRuntimeEvent } from "@cline/sdk";
export type TurnPhase = "thinking" | "writing" | "tool";
export interface TurnProgressSnapshot {
phase: TurnPhase;
/** present iff phase === "tool" — the running tool's name. */
tool?: string;
/** accumulated assistant-text length so far. */
chars: number;
/** running total tokens (input+output); undefined until the first usage event. */
tokens?: number;
/** the new assistant-text chunk since the last snapshot (for the OutputChannel). */
textDelta?: string;
}
export interface TurnProgressState {
phase: TurnPhase;
chars: number;
tokens?: number;
/** stack of tool names currently running (depth-tracked for overlap). */
activeTools: string[];
/** true once any assistant text has streamed (tool-finish then reverts to writing). */
sawText: boolean;
}
export function createTurnProgressState(): TurnProgressState {
return { phase: "thinking", chars: 0, tokens: undefined, activeTools: [], sawText: false };
}
function restingPhase(state: TurnProgressState): TurnPhase {
if (state.activeTools.length) return "tool";
return state.sawText ? "writing" : "thinking";
}
function toSnapshot(state: TurnProgressState, textDelta?: string): TurnProgressSnapshot {
return {
phase: state.phase,
tool: state.phase === "tool" ? state.activeTools[state.activeTools.length - 1] : undefined,
chars: state.chars,
tokens: state.tokens,
textDelta,
};
}
/**
* Fold one SDK event into the state, returning the next state and the snapshot to
* emit (snapshot undefined for events that don't change the surface).
*/
export function reduceTurnProgress(
state: TurnProgressState,
event: AgentRuntimeEvent,
): { state: TurnProgressState; snapshot?: TurnProgressSnapshot } {
switch (event.type) {
case "run-started":
case "turn-started": {
const next: TurnProgressState = { ...state, phase: restingPhase(state) };
return { state: next, snapshot: toSnapshot(next) };
}
case "assistant-text-delta": {
const next: TurnProgressState = {
...state,
phase: state.activeTools.length ? "tool" : "writing",
chars: event.accumulatedText.length,
sawText: true,
};
return { state: next, snapshot: toSnapshot(next, event.text) };
}
case "assistant-reasoning-delta": {
// Reasoning TEXT is not surfaced (operator fork); collapse to motion only.
const next: TurnProgressState = { ...state, phase: state.activeTools.length ? "tool" : "thinking" };
return { state: next, snapshot: toSnapshot(next) };
}
case "tool-started": {
const activeTools = [...state.activeTools, event.toolCall.toolName];
const next: TurnProgressState = { ...state, phase: "tool", activeTools };
return { state: next, snapshot: toSnapshot(next) };
}
case "tool-updated": {
const next: TurnProgressState = { ...state, phase: "tool" };
return { state: next, snapshot: toSnapshot(next) };
}
case "tool-finished": {
const name = event.toolCall.toolName;
const idx = state.activeTools.lastIndexOf(name);
const activeTools = idx >= 0 ? state.activeTools.filter((_, i) => i !== idx) : state.activeTools.slice(0, -1);
const next: TurnProgressState = { ...state, activeTools, phase: "thinking" };
next.phase = restingPhase(next);
return { state: next, snapshot: toSnapshot(next) };
}
case "usage-updated": {
const tokens = event.usage.inputTokens + event.usage.outputTokens || undefined;
const next: TurnProgressState = { ...state, tokens };
return { state: next, snapshot: toSnapshot(next) };
}
default:
return { state };
}
}
/** Render the notification activity line from a snapshot (pure; spec §2.1). */
export function formatProgressLine(s: TurnProgressSnapshot): string {
let head: string;
if (s.phase === "tool") head = `running ${s.tool ?? "tool"}`;
else if (s.phase === "writing") head = `writing… (${s.chars} chars)`;
else head = "thinking…";
return s.tokens ? `${head} · ${formatTokens(s.tokens)} tokens` : head;
}
export function formatTokens(n: number): string {
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
}
```
- [ ] **Step 4: Run the test to verify it passes**
Run: `npx vitest run test/turnProgress.test.ts`
Expected: PASS (all cases).
- [ ] **Step 5: Typecheck**
Run: `npm run typecheck`
Expected: no errors (confirms the type-only `AgentRuntimeEvent` import resolves under `moduleResolution: Bundler` and the discriminated-union narrowing is sound).
- [ ] **Step 6: Commit**
```bash
git add src/turnProgress.ts test/turnProgress.test.ts
git commit -m "feat(#60): pure turn-progress reducer (INV-46)"
```
---
## Task 2: Extend `runEditTurn` with progress + cancellation
**Files:**
- Modify: `src/liveTurn.ts`
- Test: `test/liveTurn.test.ts` (add a mocked-agent case)
- [ ] **Step 1: Write the failing test**
Append to `test/liveTurn.test.ts` (keep the existing `extractReplacement` block). This mocks `@cline/sdk` via Vitest so `runEditTurn` runs without a real LLM, exercising subscribe + abort wiring:
```ts
import { vi } from "vitest";
import { runEditTurn } from "../src/liveTurn";
// A fake Agent that replays a scripted event list to subscribers, supports abort,
// and resolves agent.run() with a completed result.
function fakeSdk(events: any[], opts?: { abortAware?: boolean }) {
return {
Agent: class {
private listeners: ((e: any) => void)[] = [];
private aborted = false;
constructor(_cfg: unknown) {}
subscribe(fn: (e: any) => void) {
this.listeners.push(fn);
return () => {
this.listeners = this.listeners.filter((l) => l !== fn);
};
}
abort() {
this.aborted = true;
}
async run(_input: string) {
for (const e of events) for (const l of this.listeners) l(e);
if (opts?.abortAware && this.aborted) {
return { status: "aborted", outputText: "", runId: "r1", error: { message: "aborted" } };
}
return { status: "completed", outputText: "EDITED", runId: "r1" };
}
},
};
}
describe("runEditTurn progress + cancel", () => {
it("emits progress snapshots and returns the replacement unchanged (INV-44)", async () => {
vi.doMock("@cline/sdk", () => fakeSdk([
{ type: "assistant-text-delta", text: "ED", accumulatedText: "ED" },
{ type: "usage-updated", usage: { inputTokens: 10, outputTokens: 5, cacheReadTokens: 0, cacheWriteTokens: 0 } },
]));
const seen: string[] = [];
const turn = await runEditTurn("do it", "old", { onProgress: (s) => seen.push(s.phase) });
expect(turn.replacement).toBe("EDITED");
expect(seen).toContain("writing");
vi.doUnmock("@cline/sdk");
});
it("a fired AbortSignal aborts the agent and the turn throws (INV-47)", async () => {
vi.doMock("@cline/sdk", () => fakeSdk([], { abortAware: true }));
const ac = new AbortController();
ac.abort();
await expect(runEditTurn("do it", "old", { signal: ac.signal })).rejects.toThrow(/aborted/);
vi.doUnmock("@cline/sdk");
});
});
```
Note: dynamic `import("@cline/sdk")` inside `runEditTurn` picks up `vi.doMock`. If the existing test file lacks `vi` import, the added `import { vi }` covers it.
- [ ] **Step 2: Run to verify it fails**
Run: `npx vitest run test/liveTurn.test.ts`
Expected: FAIL — `runEditTurn` doesn't accept `onProgress`/`signal` yet (type error) or doesn't subscribe.
- [ ] **Step 3: Implement the extension**
In `src/liveTurn.ts`, add the import and options type, and rewrite the body of `runEditTurn` (keep `SYSTEM_PROMPT`, `extractReplacement`, `EditTurnResult` as-is):
```ts
import type { TurnProgressSnapshot } from "./turnProgress";
import { createTurnProgressState, reduceTurnProgress } from "./turnProgress";
export interface RunEditTurnOptions {
modelId?: string;
/** out: called with a reduced progress snapshot as the SDK streams (INV-44 additive). */
onProgress?: (snapshot: TurnProgressSnapshot) => void;
/** in: aborting this signal cancels the turn via agent.abort() (INV-47). */
signal?: AbortSignal;
}
export async function runEditTurn(
instruction: string,
selectedText: string,
opts?: RunEditTurnOptions,
): Promise<EditTurnResult> {
const sdk = await import("@cline/sdk");
const modelId = opts?.modelId ?? "sonnet";
const agent = new sdk.Agent({ providerId: "claude-code", modelId, systemPrompt: SYSTEM_PROMPT });
let state = createTurnProgressState();
const unsubscribe = opts?.onProgress
? agent.subscribe((event) => {
const next = reduceTurnProgress(state, event);
state = next.state;
if (next.snapshot) opts.onProgress!(next.snapshot);
})
: undefined;
const onAbort = () => agent.abort();
opts?.signal?.addEventListener("abort", onAbort);
try {
const result = await agent.run(
`<instruction>\n${instruction}\n</instruction>\n<text>\n${selectedText}\n</text>`,
);
if (result.status !== "completed") {
throw new Error(
`claude-code turn ${result.status}: ${result.error?.message ?? "unknown error"} ` +
"(is Claude Code installed and signed in?)",
);
}
return { replacement: extractReplacement(result.outputText, selectedText), model: modelId, sessionId: result.runId };
} finally {
unsubscribe?.();
opts?.signal?.removeEventListener("abort", onAbort);
}
}
```
(`agent.subscribe` returns its unsubscribe fn per `@cline/agents` `AgentRuntime.subscribe(listener): () => void`. `agent.abort()` exists on the same class. `AbortSignal`/`addEventListener` are web standards — no `vscode` import is added, preserving INV-43.)
- [ ] **Step 4: Run the test to verify it passes**
Run: `npx vitest run test/liveTurn.test.ts`
Expected: PASS (both new cases + the existing `extractReplacement` cases).
- [ ] **Step 5: Typecheck + full unit run**
Run: `npm run typecheck && npx vitest run`
Expected: typecheck clean; all unit tests pass (was 222 + Task 1 + these).
- [ ] **Step 6: Commit**
```bash
git add src/liveTurn.ts test/liveTurn.test.ts
git commit -m "feat(#60): runEditTurn streams progress + accepts AbortSignal (INV-43/44/47)"
```
---
## Task 3: Host `liveProgressUi` module + setting
**Files:**
- Create: `src/liveProgressUi.ts`
- Modify: `package.json` (add `contributes.configuration`)
- [ ] **Step 1: Add the configuration to `package.json`**
In `package.json`, under the top-level `"contributes"` object, add a `configuration` block (there is no existing one). Insert it as a sibling of the existing `commands`/`menus` keys:
```json
"configuration": {
"title": "Cowriting",
"properties": {
"cowriting.liveProgress.revealOutput": {
"type": "boolean",
"default": true,
"description": "When Claude is editing, reveal the \"Cowriting: Claude\" output channel (without stealing focus) as soon as Claude starts producing text, so you can read the output as it streams."
}
}
}
```
- [ ] **Step 2: Write the module**
Create `src/liveProgressUi.ts`:
```ts
/**
* liveProgressUi.ts — host-side relay of TurnProgress snapshots to VS Code UI
* (#60, spec coauthoring-live-progress.md §3.4). The ONLY surfaces are the
* existing withProgress notification (an activity line) and a dedicated
* OutputChannel streaming the full assistant text — no new network/webview
* surface (INV-45). vscode-only; the pure logic lives in turnProgress.ts.
*/
import * as vscode from "vscode";
import { formatProgressLine, type TurnProgressSnapshot } from "./turnProgress";
const CHANNEL_NAME = "Cowriting: Claude";
export interface TurnUi {
/** Pass to runEditTurn's opts.onProgress. */
onProgress: (snapshot: TurnProgressSnapshot) => void;
/** Pass to runEditTurn's opts.signal — fired when the user cancels the notification. */
signal: AbortSignal;
}
export class LiveProgressUi {
readonly channel: vscode.OutputChannel;
constructor() {
this.channel = vscode.window.createOutputChannel(CHANNEL_NAME);
}
/**
* Begin one turn's UI. Writes the per-turn header to the channel, returns the
* onProgress relay + an AbortSignal linked to the notification's cancel token.
*/
begin(
instruction: string,
progress: vscode.Progress<{ message?: string }>,
token: vscode.CancellationToken,
): TurnUi {
const controller = new AbortController();
token.onCancellationRequested(() => controller.abort());
this.channel.appendLine(`── asking: ${instruction} ──`);
let revealed = false;
const reveal = () => {
if (revealed) return;
revealed = true;
const cfg = vscode.workspace.getConfiguration("cowriting");
if (cfg.get<boolean>("liveProgress.revealOutput", true)) this.channel.show(true);
};
const onProgress = (s: TurnProgressSnapshot) => {
progress.report({ message: formatProgressLine(s) });
if (s.textDelta) {
this.channel.append(s.textDelta);
reveal();
}
};
return { onProgress, signal: controller.signal };
}
dispose(): void {
this.channel.dispose();
}
}
```
- [ ] **Step 3: Typecheck**
Run: `npm run typecheck`
Expected: no errors.
- [ ] **Step 4: Validate package.json**
Run: `node -e "JSON.parse(require('fs').readFileSync('package.json','utf8')); console.log('ok')"`
Expected: `ok` (the configuration block is valid JSON).
- [ ] **Step 5: Commit**
```bash
git add src/liveProgressUi.ts package.json
git commit -m "feat(#60): liveProgressUi host relay + revealOutput setting (INV-45)"
```
---
## Task 4: Wire the `editSelection` call site
**Files:**
- Modify: `src/extension.ts` (construct LiveProgressUi at activate; use it in the `editSelection` command; add to `CowritingApi`)
- [ ] **Step 1: Construct `LiveProgressUi` once and pass it into the preview controller**
Near the top of `activate(...)`, after the existing POC `output` channel is created, add:
```ts
const liveProgressUi = new LiveProgressUi();
context.subscriptions.push(liveProgressUi);
```
Add the import at the top of `src/extension.ts`:
```ts
import { LiveProgressUi } from "./liveProgressUi";
```
Then thread it into the preview controller construction (Task 5 widens that constructor):
```ts
const trackChangesPreviewController = new TrackChangesPreviewController(
diffViewController,
context.extensionUri,
attributionController,
proposalController,
liveProgressUi,
);
```
- [ ] **Step 2: Rewrite the `editSelection` `withProgress` block**
Replace the `editSelection` command's `withProgress(...)` call (currently `src/extension.ts:232-268`) so it is cancellable and relays progress. The turn body is otherwise unchanged:
```ts
try {
await vscode.window.withProgress(
{
location: vscode.ProgressLocation.Notification,
title: "Cowriting: asking Claude…",
cancellable: true,
},
async (progress, token) => {
const { runEditTurn } = await import("./liveTurn");
const ui = liveProgressUi.begin(instruction, progress, token);
const turn = await runEditTurn(instruction, selectedText, {
onProgress: ui.onProgress,
signal: ui.signal,
});
if (turn.replacement === "") {
void vscode.window.showWarningMessage(
"Cowriting: Claude returned an empty replacement — nothing was proposed.",
);
return;
}
if (turn.replacement === selectedText) {
void vscode.window.showInformationMessage("Cowriting: Claude proposed no change to the selection.");
return;
}
const id = await proposalController.propose(
document,
fp,
turn.replacement,
{ kind: "agent", id: "claude", agent: { sdk: "@cline/sdk", model: turn.model, sessionId: turn.sessionId } },
{ turnId, instruction },
);
if (id) {
void vscode.window.showInformationMessage(
"Cowriting: Claude proposed an edit — review the diff at the highlighted range (✓ accept / ✗ reject).",
);
}
},
);
} catch (err) {
if (token_was_cancelled(err)) return; // replaced below
const message = err instanceof Error ? err.message : String(err);
void vscode.window.showErrorMessage(`Cowriting: Claude edit failed — ${message}`);
}
```
Because `token` is scoped inside the `withProgress` callback, surface cancellation by catching it there instead of the outer `catch`. Final form of the `catch` and cancel handling: wrap the `runEditTurn` call in a try/catch **inside** the callback that reflects cancel, and let other errors propagate to the outer `catch`:
```ts
// inside the withProgress async (progress, token) callback, around runEditTurn:
let turn;
try {
turn = await runEditTurn(instruction, selectedText, { onProgress: ui.onProgress, signal: ui.signal });
} catch (err) {
if (token.isCancellationRequested) {
void vscode.window.showInformationMessage("Cowriting: Claude edit cancelled.");
return;
}
throw err;
}
```
(Keep the single outer `try/catch` for the non-cancel error path exactly as it is today.)
- [ ] **Step 3: Add `liveProgressUi` to `CowritingApi` and the return**
In the `CowritingApi` interface (`src/extension.ts:18`) add:
```ts
liveProgressUi: LiveProgressUi;
```
And in the `return { ... }` block (`src/extension.ts:287`) add `liveProgressUi,`.
- [ ] **Step 4: Typecheck**
Run: `npm run typecheck`
Expected: no errors (Task 5 must land for the widened constructor to typecheck — if doing tasks strictly in order, expect one error here about the 5th constructor arg; that resolves in Task 5. To keep this task self-contained, do Task 5 Step 1 (constructor signature) before re-running. Otherwise proceed to Task 5 and typecheck there.)
- [ ] **Step 5: Build**
Run: `npm run build`
Expected: esbuild succeeds; `@cline/*` stays external.
- [ ] **Step 6: Commit**
```bash
git add src/extension.ts
git commit -m "feat(#60): editSelection shows live progress + cancel"
```
---
## Task 5: Thread progress through the preview path
**Files:**
- Modify: `src/trackChangesPreview.ts` (widen `EditTurn`; accept `LiveProgressUi`; thread opts through `askClaude``runEditAndPropose`)
- [ ] **Step 1: Widen the `EditTurn` seam type and the constructor**
In `src/trackChangesPreview.ts`, change the `EditTurn` type (line 22) to accept optional turn options (back-compat: existing E2E stubs ignore the new arg):
```ts
import type { EditTurnResult, RunEditTurnOptions } from "./liveTurn";
import type { LiveProgressUi } from "./liveProgressUi";
type EditTurn = (instruction: string, text: string, opts?: RunEditTurnOptions) => Promise<EditTurnResult>;
```
Update the default `editTurn` (line 56) to forward opts:
```ts
private editTurn: EditTurn = async (instruction, text, opts) => {
const { runEditTurn } = await import("./liveTurn");
return runEditTurn(instruction, text, opts);
};
```
Add `liveProgressUi` as a constructor parameter (after `proposals`):
```ts
constructor(
private readonly diffView: DiffViewController,
private readonly extensionUri: vscode.Uri,
private readonly attribution: AttributionController,
private readonly proposals: ProposalController,
private readonly liveProgressUi: LiveProgressUi,
) {
```
- [ ] **Step 2: Make `askClaude` cancellable + progress-relaying**
Rewrite the `withProgress` block in `askClaude` (`src/trackChangesPreview.ts:232-235`) to mirror Task 4:
```ts
try {
const ids = await vscode.window.withProgress(
{
location: vscode.ProgressLocation.Notification,
title: "Cowriting: asking Claude…",
cancellable: true,
},
async (progress, token) => {
const ui = this.liveProgressUi.begin(instruction, progress, token);
try {
return await this.runEditAndPropose(document, target, instruction, { onProgress: ui.onProgress, signal: ui.signal });
} catch (err) {
if (token.isCancellationRequested) return [] as string[];
throw err;
}
},
);
if (ids.length === 0) {
void vscode.window.showInformationMessage("Cowriting: Claude proposed no changes.");
} else {
void vscode.window.showInformationMessage(
`Cowriting: Claude proposed ${ids.length} edit${ids.length === 1 ? "" : "s"} — review ✓/✗ in the preview.`,
);
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
void vscode.window.showErrorMessage(`Cowriting: Claude edit failed — ${message}`);
}
```
(On cancel the inner catch returns `[]`, which shows the benign "proposed no changes" — no proposal is created, satisfying INV-47. A distinct "cancelled" toast is optional; the editSelection path shows one. Keep parity simple: returning `[]` is the no-proposal outcome.)
- [ ] **Step 3: Thread `opts` through `runEditAndPropose`**
Change the signature (`src/trackChangesPreview.ts:257`) and both `editTurn(...)` calls (lines 270, 276) to pass opts:
```ts
async runEditAndPropose(
document: vscode.TextDocument,
target: EditTarget,
instruction: string,
opts?: RunEditTurnOptions,
): Promise<string[]> {
// ...
// range branch:
const turn = await this.editTurn(instruction, selected, opts);
// ...
// document branch:
const turn = await this.editTurn(instruction, full, opts);
```
- [ ] **Step 4: Typecheck + full unit run**
Run: `npm run typecheck && npx vitest run`
Expected: typecheck clean (the widened constructor now matches Task 4's call site); all unit tests pass.
- [ ] **Step 5: Build**
Run: `npm run build`
Expected: success.
- [ ] **Step 6: Commit**
```bash
git add src/trackChangesPreview.ts
git commit -m "feat(#60): preview Ask-Claude streams live progress + cancel"
```
---
## Task 6: Host E2E — additive proof + cancel
**Files:**
- Create: `test/e2e/suite/liveProgress.test.ts`
The E2E harness can't read notification subtitles, so it asserts the *contract*: progress is additive (proposals unchanged with progress events) and cancel yields zero proposals. The streamed-UI itself is covered by Task 1 units + the manual smoke.
- [ ] **Step 1: Write the test**
Create `test/e2e/suite/liveProgress.test.ts`, modeled on `f11Toolbar.test.ts` (same `getExtension``activate()``api.trackChangesPreviewController` pattern, same `setEditTurnForTest`):
```ts
import * as assert from "assert";
import * as vscode from "vscode";
import type { CowritingApi } from "../../../src/extension";
suite("#60 live progress (additive + cancel)", () => {
async function setup(): Promise<{ api: CowritingApi; doc: vscode.TextDocument }> {
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
const api = (await ext.activate()) as CowritingApi;
const doc = await vscode.workspace.openTextDocument({ language: "markdown", content: "# Title\n\nOld paragraph.\n" });
await vscode.window.showTextDocument(doc);
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
return { api, doc };
}
test("a stub that emits progress still produces the same proposals (INV-44)", async () => {
const { api, doc } = await setup();
const ctl = api.trackChangesPreviewController;
// The stub honors opts.onProgress (emitting synthetic snapshots) but returns
// the same rewrite — proposals must be unaffected by progress.
ctl.setEditTurnForTest(async (_i, _text, opts) => {
opts?.onProgress?.({ phase: "writing", chars: 5 });
opts?.onProgress?.({ phase: "writing", chars: 13, tokens: 1234, textDelta: "New paragraph." });
return { replacement: "# Title\n\nNew paragraph.\n", model: "sonnet", sessionId: "e2e-live" };
});
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "rewrite");
assert.ok(ids.length >= 1, "expected at least one proposal regardless of progress events");
});
test("an aborted turn proposes nothing (INV-47)", async () => {
const { api, doc } = await setup();
const ctl = api.trackChangesPreviewController;
// Simulate the SDK honoring the abort signal: throw as runEditTurn would on
// a non-"completed" (aborted) status.
ctl.setEditTurnForTest(async (_i, _text, opts) => {
if (opts?.signal?.aborted) throw new Error("claude-code turn aborted");
throw new Error("claude-code turn aborted"); // pre-aborted in this test
});
const ac = new AbortController();
ac.abort();
let ids: string[] = [];
try {
ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "rewrite", { signal: ac.signal });
} catch {
ids = [];
}
assert.strictEqual(ids.length, 0, "aborted turn must create no proposals");
});
});
```
- [ ] **Step 2: Compile + run the E2E suite**
Run: `npm run pretest:e2e && npm run test:e2e`
Expected: the new suite passes alongside the existing E2E suites. (If the sandboxed `.vscode-test` has the known `executeCommand("undo")` breakage, the #38 undo suite may skip/fail independently — that is pre-existing per session 0048/0049 and unrelated to #60; confirm only the live-progress suite is green and the rest are unchanged from baseline.)
- [ ] **Step 3: Commit**
```bash
git add test/e2e/suite/liveProgress.test.ts
git commit -m "test(#60): host E2E — progress is additive; abort proposes nothing (INV-44/47)"
```
---
## Task 7: Manual smoke + final verification
**Files:** none (verification only)
- [ ] **Step 1: Full unit + typecheck + build**
Run: `npm run typecheck && npx vitest run && npm run build`
Expected: typecheck clean; all unit tests pass; build succeeds.
- [ ] **Step 2: Manual smoke (real LLM)**
Per #60 acceptance (E2E can't assert streamed UI). With Claude Code signed in, run the extension (F5 / Extension Development Host), open a markdown doc, and Ask-Claude a non-trivial edit. Confirm:
- the notification line advances (`thinking…``writing… (N chars)` → token count rising; `running <tool>…` if a tool fires);
- the "Cowriting: Claude" OutputChannel reveals (without stealing focus) and streams the text;
- the `✕` cancel aborts cleanly (status resolves, no proposal);
- toggling `cowriting.liveProgress.revealOutput` off suppresses the auto-reveal;
- the final proposals are exactly as before (INV-44).
`scripts/smoke-live-turn.mjs` (`npm run smoke:live`) exercises the turn headlessly if a UI smoke isn't convenient; note it does not render the notification.
- [ ] **Step 3: Record the smoke result in the session transcript** (`## Deferred decisions` or a smoke note), since E2E doesn't cover the live UI.
---
## Self-Review (completed by plan author)
**Spec coverage:**
- §2.1 notification activity line + token count → Task 1 (`formatProgressLine`), Task 4/5 (`progress.report`). ✓
- §2.1 OutputChannel full-text stream + auto-reveal gate → Task 3 (`LiveProgressUi`, setting). ✓
- §2.1 cancellation → "cancelled", no proposal → Task 4 (editSelection), Task 5 (preview), Task 6 (E2E). ✓
- §2.2 reasoning not surfaced → Task 1 (reasoning-delta → thinking, no textDelta). ✓
- §3.2 pure reducer (INV-46) → Task 1. ✓
- §3.3 runEditTurn onProgress + AbortSignal, vscode-free (INV-43/44) → Task 2. ✓
- §3.4 both call sites relay; widened EditTurn seam; stub stays valid → Task 4, Task 5, Task 6. ✓
- §3.5 OutputChannel append + header → Task 3. ✓
- §3.6 setting → Task 3. ✓
- §3.7 INV-43..47 → asserted across Tasks 1/2/6. ✓
- §4 unit (reducer + format) + host E2E (additive + cancel) + manual smoke → Tasks 1, 6, 7. ✓
**Placeholder scan:** none (every code/command step is concrete). The Task 4 Step 2 `token_was_cancelled(err)` pseudo-line is immediately replaced by the concrete inner-try/catch in the same step.
**Type consistency:** `TurnProgressSnapshot`, `RunEditTurnOptions`, `TurnUi`, `LiveProgressUi`, `reduceTurnProgress`, `formatProgressLine`, `createTurnProgressState` used consistently across tasks; `editTurn` widened uniformly; `toolName`/`inputTokens+outputTokens` match the SDK `agent.d.ts`.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,526 @@
# #70 — INV-5 Reject-After-Interior-Edit Gap 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:** `✗ Reject` on an optimistically-applied proposal restores the retained original (INV-5) even after the writer typed inside the pending range — and when it genuinely cannot locate the applied span, it fails loudly instead of reporting success while leaving the proposed text behind.
**Architecture:** Issue #70's fix direction **(a)** with an honest **(b)** fallback. A new per-doc `appliedSpans: Map<proposalId, OffsetRange>` becomes the real F12 applied-range bookkeeping: written at optimistic-apply time, re-synced whenever the exact anchor resolves at `renderAll`, shifted through interior-safe buffer edits (`shift()`), and **deleted (distrusted)** when an edit straddles a span boundary (e.g. a whole-buffer external replace — a shifted range clamps to garbage there, and restoring at a guessed offset would corrupt the doc, INV-11's "never applied by guess"). `revertInPlace` then reverts via exact resolve → `appliedSpans` fallback → hard failure (warning, proposal kept, `false` returned). Direction (c) (fuzzy resolve) is rejected: it guesses. `rejectAll` orders by the same fallback and reports skips like `acceptAllProposals` does.
**Tech Stack:** TypeScript VS Code extension; `npm test` (vitest unit), `npm run test:e2e` (@vscode/test-electron host suite — `ProposalController` is vscode-coupled, so coverage is host E2E, matching every existing F12 test).
## Global Constraints
- INV-5: "Reject restores the retained original exactly" — the contract this fix enforces; `revertInPlace`'s doc comment already promises "reverts the whole block regardless of any in-place edits the human made".
- INV-11: anchors are immutable for a proposal's life; stale text is flagged, **never applied by guess** — the fallback must only use a span whose tracking is provably continuous (no fuzzy resolve, no boundary-straddled ranges).
- INV-12: accept/reject stay human-only gestures; no new mutation paths.
- Keep `✓ Keep` (`finalizeInPlace`) behavior byte-identical — its lookup-by-id bypass of resolution is correct by design (issue "Contrast" note).
- Existing test suites must stay green: 265+ unit, full host E2E (notably `proposals.test.ts` INV-11 stale-accept, `f12InlineDiff.test.ts`, `fullLoop.test.ts`).
- Wiggleverse hygiene: no inline trailing comments in CLI blocks; commits cite the issue and carry the `Co-Authored-By` trailer.
---
### Task 1: `appliedSpans` bookkeeping + `revertInPlace` tracked-range fallback (the INV-5 repro)
**Files:**
- Modify: `src/proposalController.ts` (DocState ~line 35, `ensureState` ~127, `optimisticApply` ~336, `finalizeInPlace` ~417, `revertInPlace` ~434, `renderAll` ~495, `onDidChange` ~529)
- Test: `test/e2e/suite/f12InlineDiff.test.ts` (new suite appended)
**Interfaces:**
- Consumes: `shift(range, edit)` / `resolve(text, fp)` from `src/anchorer.ts` (existing); `DocState.applied: Set<string>` (existing).
- Produces: `DocState.appliedSpans: Map<string, OffsetRange>` (private bookkeeping; Tasks 23 rely on its distrust + ordering semantics); `revertInPlace(docPath, proposalId, opts?: { silent?: boolean }): Promise<boolean>` — signature gains the optional `opts` used by Task 3.
- [ ] **Step 1: Write the failing E2E test — the issue's repro sketch**
Append to `test/e2e/suite/f12InlineDiff.test.ts`:
```typescript
// #70 (INV-5): rejecting a proposal AFTER typing inside its pending range must
// still restore the retained original. The exact-substring anchor is orphaned by
// the interior tweak (INV-1/INV-11) — the revert falls back to the F12
// applied-range bookkeeping (appliedSpans), which shift-tracks the span through
// interior edits.
suite("F12 inline diff — #70 reject after interior edit (INV-5)", () => {
test("revertInPlace restores the original after a tweak inside the pending range", async () => {
const ORIGINAL = "The quick brown fox jumps.";
const { doc } = await freshDoc("docs/s70-reject-tweaked.md", `# T\n\n${ORIGINAL}\n`);
const api = await getApi();
const p = api.proposalController;
const docKey = p.keyFor(doc);
const fp = { text: ORIGINAL, before: "", after: "", lineHint: 2 };
const id = await p.propose(doc, fp, "The rapid brown fox jumps.",
{ kind: "agent", id: "claude", agent: { sdk: "x", model: "m", sessionId: "s" } }, { granularity: "block" });
await p.optimisticApply(doc, id!);
await settle();
assert.ok(doc.getText().includes("The rapid brown fox jumps."), "optimistically applied");
// Human types INSIDE the pending range → orphans the exact anchor (INV-11).
const at = doc.getText().indexOf("rapid");
const we = new vscode.WorkspaceEdit();
we.replace(doc.uri, new vscode.Range(doc.positionAt(at), doc.positionAt(at + "rapid".length)), "RAPID-ish");
assert.ok(await vscode.workspace.applyEdit(we), "interior tweak applied");
await settle();
assert.strictEqual(p.listProposals(doc).find((v) => v.id === id)!.anchorStart, null, "anchor orphaned by the tweak");
// ✗ Reject: must restore the retained original EXACTLY (INV-5), not skip.
assert.ok(await p.revertInPlace(docKey, id!), "reject reports success");
await settle();
assert.strictEqual(doc.getText(), `# T\n\n${ORIGINAL}\n`, "original restored exactly (INV-5)");
assert.strictEqual(p.listProposals(doc).length, 0, "proposal cleared");
});
test("rejectByIdInPlace routes the tweaked-applied case the same way", async () => {
const ORIGINAL = "A steady closing line.";
const { doc } = await freshDoc("docs/s70-reject-route.md", `# T\n\n${ORIGINAL}\n`);
const api = await getApi();
const p = api.proposalController;
const docKey = p.keyFor(doc);
const fp = { text: ORIGINAL, before: "", after: "", lineHint: 2 };
const id = await p.propose(doc, fp, "A wobbly closing line.",
{ kind: "agent", id: "claude", agent: { sdk: "x", model: "m", sessionId: "s" } }, { granularity: "block" });
await p.optimisticApply(doc, id!);
await settle();
const at = doc.getText().indexOf("wobbly");
const we = new vscode.WorkspaceEdit();
we.replace(doc.uri, new vscode.Range(doc.positionAt(at), doc.positionAt(at)), "very-");
assert.ok(await vscode.workspace.applyEdit(we), "interior insertion applied");
await settle();
assert.ok(await p.rejectByIdInPlace(docKey, id!), "gesture-level reject succeeds");
await settle();
assert.strictEqual(doc.getText(), `# T\n\n${ORIGINAL}\n`, "original restored exactly (INV-5)");
});
});
```
- [ ] **Step 2: Run the new suite to verify it fails**
```bash
npm run test:e2e 2>&1 | grep -A 3 "#70"
```
Expected: both tests FAIL — first on `doc.getText()` still holding the tweaked replacement (`RAPID-ish`) after a `true` return (the silent-skip bug), i.e. the "original restored exactly" assertion.
- [ ] **Step 3: Implement `appliedSpans` + the fallback**
In `src/proposalController.ts`:
(1) `DocState` gains the map (after `applied`):
```typescript
/** ids already optimistically applied to the buffer (so the trigger doesn't re-apply). */
applied: Set<string>;
/**
* #70 (INV-5): the F12 applied-range bookkeeping — proposal id -> the applied
* span's CURRENT buffer range. Written at optimistic-apply, re-synced whenever
* the exact anchor resolves (renderAll), shifted through interior-safe edits,
* and DELETED (distrusted) when an edit straddles a span boundary — a clamped
* range is a guess, and stale text is never applied by guess (INV-11). Lets
* Reject restore the original after the human types INSIDE the pending range
* (which orphans the exact-substring anchor).
*/
appliedSpans: Map<string, OffsetRange>;
```
(2) `ensureState` initializes it: `appliedSpans: new Map(),` after `applied: new Set(),`.
(3) `optimisticApply` records the span where it already re-anchors (after `state.applied.add(proposalId);` and the `appliedStart`/`appliedEnd` computation — move the `add` down beside it or record right after the existing lines):
```typescript
state.applied.add(proposalId);
// Re-anchor to the applied text now in the buffer (its start is unchanged; its
// end shifts by the net length delta of the replacement).
const appliedStart = resolved.start;
const appliedEnd = appliedStart + proposal.replacement.length;
state.appliedSpans.set(proposalId, { start: appliedStart, end: appliedEnd });
```
(4) `renderAll`'s resolved branch re-syncs (covers the prior-session-applied reload, where the in-memory map starts empty):
```typescript
} else {
this.recordProposal(state, proposal, resolved, true);
if (state.applied.has(proposal.id)) state.appliedSpans.set(proposal.id, resolved);
}
```
(5) `onDidChange` maintains it — interior-safe edits shift, boundary-straddling edits distrust. Replace the method body:
```typescript
private onDidChange(e: vscode.TextDocumentChangeEvent): void {
const state = this.docs.get(this.keyOf(e.document));
if (!state || (state.live.size === 0 && state.appliedSpans.size === 0)) return;
for (const change of e.contentChanges) {
const edit = { start: change.rangeOffset, end: change.rangeOffset + change.rangeLength, newLength: change.text.length };
for (const [id, range] of state.live) state.live.set(id, shift(range, edit));
// #70: an edit fully inside a tracked applied span (or fully outside it)
// keeps the span meaningful; one straddling a boundary — e.g. a whole-buffer
// external replace — makes the shifted range a clamped guess, so the span
// is distrusted (deleted) rather than reverted-to by guess (INV-11).
for (const [id, range] of state.appliedSpans) {
const outside = edit.end <= range.start || edit.start >= range.end;
const inside = edit.start >= range.start && edit.end <= range.end;
if (outside || inside) state.appliedSpans.set(id, shift(range, edit));
else state.appliedSpans.delete(id);
}
}
}
```
(6) `revertInPlace` — the fix itself. Replace the method (keep its position; doc comment updated to describe the layered lookup; `opts` is consumed by Task 3):
```typescript
/**
* F12/#64 (INV-51): REJECT an optimistically-applied proposal — replace its live
* applied span with the stored `original`, then clear it. Reverts the whole block
* regardless of any in-place edits the human made to the inserted text: when an
* interior tweak has orphaned the exact anchor (INV-11), the revert falls back to
* the shift-tracked applied span (#70, INV-5). When neither locates the span
* (e.g. a boundary-straddling external rewrite distrusted it), the reject FAILS
* loudly — warning shown, proposal kept, `false` returned — instead of silently
* leaving the proposed text in the buffer. Undo (or ✓ Keep) remains the way out.
*/
async revertInPlace(docPath: string, proposalId: string, opts?: { silent?: boolean }): Promise<boolean> {
const hit = this.byId(docPath, proposalId);
if (!hit) return false;
const document = this.openDoc(hit.state);
if (!document) return false;
// Never optimistically applied → nothing of ours is in the buffer; clearing
// the record IS the reject (the legacy pending-only path).
if (hit.proposal.original === undefined) {
this.store.update(docPath, (a) => removeProposal(a, proposalId));
hit.state.applied.delete(proposalId);
hit.state.appliedSpans.delete(proposalId);
this.renderAll(document);
return true;
}
const fp = hit.state.artifact.anchors[hit.proposal.anchorId]?.fingerprint;
const resolved = fp ? resolve(document.getText(), fp) : "orphaned";
const span = resolved !== "orphaned" ? resolved : hit.state.appliedSpans.get(proposalId);
if (!span) {
if (!opts?.silent) {
void vscode.window.showWarningMessage(
"Cowriting: this proposal's applied text can't be located (it changed or moved) — undo your edits, or Keep it to leave the buffer as-is (it is never reverted by guess).",
);
}
return false;
}
const we = new vscode.WorkspaceEdit();
we.replace(
document.uri,
new vscode.Range(document.positionAt(span.start), document.positionAt(span.end)),
hit.proposal.original,
);
if (!(await vscode.workspace.applyEdit(we))) return false;
this.store.update(docPath, (a) => removeProposal(a, proposalId));
hit.state.applied.delete(proposalId);
hit.state.appliedSpans.delete(proposalId);
this.renderAll(document);
return true;
}
```
(7) `finalizeInPlace` cleans the map beside its existing `applied.delete`:
```typescript
hit.state.applied.delete(proposalId);
hit.state.appliedSpans.delete(proposalId);
```
- [ ] **Step 4: Typecheck + unit tests + the new E2E**
```bash
npm run typecheck && npm test
npm run test:e2e
```
Expected: typecheck clean; all unit tests PASS (no unit test touches `ProposalController`); host E2E PASS including the two new #70 tests and all pre-existing F12/proposals/fullLoop suites.
- [ ] **Step 5: Commit**
```bash
git add src/proposalController.ts test/e2e/suite/f12InlineDiff.test.ts
git commit -m "fix(#70): reject after an interior tweak restores the original via tracked applied span (INV-5)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```
---
### Task 2: The honest hard-failure path (fix direction (b) for the residual orphan)
**Files:**
- Modify: `src/proposalController.ts` (only if Step 2 exposes a gap — the Task 1 code already implements the path)
- Test: `test/e2e/suite/f12InlineDiff.test.ts` (extend the #70 suite)
**Interfaces:**
- Consumes: Task 1's `appliedSpans` distrust semantics and `revertInPlace` failure contract.
- Produces: nothing new — locks the contract with a regression test.
- [ ] **Step 1: Write the failing/locking E2E test**
Append inside the `#70` suite from Task 1:
> **Execution note:** the first cut of this test used a whole-buffer rewrite as the
> distrusting edit and FAILED — VS Code minimizes workspace edits before change
> events fire, so a rewrite sharing a prefix/suffix with the old text decomposes
> into interior hunks the span legitimately survives (and the revert then correctly
> restores the original in place). The distrusting edit must straddle a span
> boundary even after minimization — a deletion from outside the span into its
> interior, as below.
```typescript
// Fix direction (b): when the tracked span itself is distrusted (an edit
// straddled its boundary — here a deletion running from the heading into the
// span's interior), the reject FAILS honestly: warning path, proposal kept,
// buffer untouched. Silent-skip-and-report-success (the #70 bug) must not come
// back; reverting at a clamped guessed offset (INV-11) must not either.
// (A boundary-straddling DELETION is used because VS Code minimizes workspace
// edits before change events fire — a wholesale rewrite sharing a prefix/suffix
// decomposes into interior hunks the span legitimately survives.)
test("reject fails loudly — proposal kept, buffer untouched — when the span is distrusted", async () => {
const ORIGINAL = "Sentence one stands here.";
const { doc } = await freshDoc("docs/s70-reject-distrust.md", `# T\n\n${ORIGINAL}\n`);
const api = await getApi();
const p = api.proposalController;
const docKey = p.keyFor(doc);
const fp = { text: ORIGINAL, before: "", after: "", lineHint: 2 };
const id = await p.propose(doc, fp, "Sentence ONE stands here.",
{ kind: "agent", id: "claude", agent: { sdk: "x", model: "m", sessionId: "s" } }, { granularity: "block" });
await p.optimisticApply(doc, id!);
await settle();
// Delete from inside the heading through the middle of the applied span:
// the edit straddles the span's start boundary → the tracked span is
// distrusted AND the exact anchor no longer resolves.
const mid = doc.getText().indexOf("ONE");
const we = new vscode.WorkspaceEdit();
we.replace(doc.uri, new vscode.Range(doc.positionAt(2), doc.positionAt(mid + 1)), "");
assert.ok(await vscode.workspace.applyEdit(we), "boundary-straddling deletion applied");
await settle();
const before = doc.getText();
assert.strictEqual(await p.revertInPlace(docKey, id!), false, "reject reports failure (no silent success)");
await settle();
assert.strictEqual(doc.getText(), before, "buffer untouched — never reverted by guess (INV-11)");
assert.ok(p.listProposals(doc).some((v) => v.id === id), "proposal kept (not silently dropped)");
// Cleanup for later tests: the never-locatable husk is discarded via the
// plain record-only reject.
assert.strictEqual(p.rejectById(docKey, id!), true);
});
```
- [ ] **Step 2: Run it**
```bash
npm run test:e2e 2>&1 | grep -B 2 -A 5 "distrusted"
```
Expected: PASS if Task 1's implementation is complete (this is the locking regression); if it FAILS, the failure pinpoints which leg (distrust bookkeeping vs. failure return vs. proposal retention) is wrong — fix `src/proposalController.ts` accordingly, not the test.
- [ ] **Step 3: Commit**
```bash
git add test/e2e/suite/f12InlineDiff.test.ts src/proposalController.ts
git commit -m "test(#70): lock the honest hard-failure reject path (distrusted span → warn, keep, false)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```
---
### Task 3: `rejectAll` parity — tweaked proposals revert too, skips are reported
**Files:**
- Modify: `src/proposalController.ts` (`rejectAll` ~line 461), `src/extension.ts` (`cowriting.rejectAllProposals` handler ~line 220)
- Test: `test/e2e/suite/f12InlineDiff.test.ts` (extend the #70 suite)
**Interfaces:**
- Consumes: Task 1's `revertInPlace(docPath, id, opts?: { silent?: boolean })` and `appliedSpans`.
- Produces: `rejectAll(document): Promise<{ reverted: number; skipped: number }>` — additive `skipped` field (existing `{ reverted }` destructurings stay valid).
- [ ] **Step 1: Write the failing E2E test**
Append inside the `#70` suite:
```typescript
// rejectAll must revert a tweaked (orphaned-anchor) proposal via the same
// tracked-span fallback, ordered descending by that span so earlier reverts
// never shift later ones — and must count what it could not revert.
test("rejectAll reverts tweaked proposals via the tracked span and reports skips", async () => {
const { doc } = await freshDoc("docs/s70-rejall-tweak.md", "# T\n\nOne aaa.\n\nTwo bbb.\n");
const api = await getApi();
const p = api.proposalController;
const editFlow = api.editFlow;
editFlow.setEditTurnForTest(async () => ({ replacement: "# T\n\nOne AAA.\n\nTwo BBB.\n", model: "m", sessionId: "s" }));
const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "up");
await settle();
for (const id of ids) await p.optimisticApply(doc, id);
await settle();
// Tweak INSIDE the first applied block → its exact anchor orphans.
const at = doc.getText().indexOf("AAA");
const we = new vscode.WorkspaceEdit();
we.replace(doc.uri, new vscode.Range(doc.positionAt(at), doc.positionAt(at + 3)), "AAAZ");
assert.ok(await vscode.workspace.applyEdit(we), "interior tweak applied");
await settle();
const { reverted, skipped } = await p.rejectAll(doc);
await settle();
assert.strictEqual(reverted, ids.length, "ALL proposals reverted, tweaked one included");
assert.strictEqual(skipped, 0, "nothing skipped");
assert.strictEqual(doc.getText(), "# T\n\nOne aaa.\n\nTwo bbb.\n", "document fully restored (INV-5)");
assert.strictEqual(p.listProposals(doc).length, 0, "all cleared");
});
```
- [ ] **Step 2: Run it to verify the current gap**
```bash
npm run test:e2e 2>&1 | grep -B 2 -A 5 "rejectAll reverts tweaked"
```
Expected: FAIL — today the tweaked proposal sorts as an orphan (`start: -1`, reverted last), and after the first successful revert's `renderAll` the old code silently removes it without restoring (`document fully restored` assertion fails), and `skipped` does not exist on the return type (typecheck catches first — that is the same failure).
- [ ] **Step 3: Implement — order by the fallback span, count skips, report them**
Replace `rejectAll` in `src/proposalController.ts`:
```typescript
/**
* F12/#64 (INV-53): reject EVERY pending proposal on a document — revert each in
* DESCENDING span order (so an earlier revert never shifts a later one's
* offsets), symmetric with #46's accept-all. #70: a tweaked proposal whose exact
* anchor is orphaned orders (and reverts) by its shift-tracked applied span;
* one with no locatable span is SKIPPED — counted, never guessed (INV-11) —
* and the per-proposal warning is suppressed in favor of the batch report.
*/
async rejectAll(document: vscode.TextDocument): Promise<{ reverted: number; skipped: number }> {
if (!this.isTracked(document)) return { reverted: 0, skipped: 0 };
const docPath = this.keyOf(document);
const state = this.ensureState(document);
state.artifact = this.store.load(docPath) ?? emptyArtifact(docPath);
const text = document.getText();
const ordered = state.artifact.proposals
.map((p) => {
const fp = state.artifact.anchors[p.anchorId]?.fingerprint;
const r = fp ? resolve(text, fp) : "orphaned";
const start = r !== "orphaned" ? r.start : state.appliedSpans.get(p.id)?.start ?? -1;
return { id: p.id, start };
})
.sort((a, b) => b.start - a.start);
let reverted = 0;
let skipped = 0;
for (const it of ordered) {
if (await this.revertInPlace(docPath, it.id, { silent: true })) reverted++;
else skipped++;
}
return { reverted, skipped };
}
```
And in `src/extension.ts`, the `cowriting.rejectAllProposals` handler mirrors accept-all's skip note:
```typescript
const { reverted, skipped } = await proposalController.rejectAll(doc);
if (reverted === 0 && skipped === 0) return;
const skipNote = skipped > 0 ? `, ${skipped} skipped (applied text not locatable — undo your edits or Keep)` : "";
void vscode.window.showInformationMessage(
`Cowriting: rejected ${reverted} proposal${reverted === 1 ? "" : "s"}${skipNote}.`,
);
```
- [ ] **Step 4: Typecheck + full test run**
```bash
npm run typecheck && npm test
npm run test:e2e
```
Expected: all PASS — including the pre-existing `rejectAll` E2E tests (`f12InlineDiff` "rejectAll reverts every pending proposal", "control parity", `proposals.test.ts` cleanup calls), whose `{ reverted }` destructuring is unaffected by the additive field.
- [ ] **Step 5: Commit**
```bash
git add src/proposalController.ts src/extension.ts test/e2e/suite/f12InlineDiff.test.ts
git commit -m "fix(#70): rejectAll reverts tweaked proposals via the tracked span; skips are counted and reported
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```
---
### Task 4: Close the loop — fullLoop E2E rejects a tweaked proposal (the deliberate avoidance falls)
**Files:**
- Modify: `test/e2e/suite/fullLoop.test.ts` (the PUC-2 reject section, ~line 144)
**Interfaces:**
- Consumes: the shipped Task 1 behavior; existing fullLoop fixtures (`rejectId`, `REJECT_REPLACEMENT`, `ORIG_REJECT`).
- Produces: nothing — upgrades the flagship E2E so the gap can't silently regress.
- [ ] **Step 1: Tweak inside the reject proposal's range before rejecting**
In `test/e2e/suite/fullLoop.test.ts`, immediately before the existing line
`assert.ok(await api.proposalController.revertInPlace(docKey, rejectId), "reject reverts in place");`,
insert:
```typescript
// #70 (INV-5): the reject leg now ALSO takes an interior tweak first — the
// original deliberately rejected only an un-tweaked proposal because the
// pre-fix revert silently skipped an orphaned anchor. The tweak orphans the
// exact anchor; the revert must restore ORIG_REJECT via the tracked span.
const rejAt = doc.getText().indexOf(REJECT_REPLACEMENT);
assert.ok(rejAt >= 0, "reject proposal's applied text present");
const rejTweak = new vscode.WorkspaceEdit();
rejTweak.replace(doc.uri, new vscode.Range(doc.positionAt(rejAt + 2), doc.positionAt(rejAt + 2)), "x");
assert.ok(await vscode.workspace.applyEdit(rejTweak), "human tweak inside the reject proposal's range");
await settle();
assert.strictEqual(
api.proposalController.listProposals(doc).find((v) => v.id === rejectId)!.anchorStart,
null,
"tweak orphans the reject proposal's exact anchor (INV-11)",
);
```
(The existing assertions right after — proposal cleared, `ORIG_REJECT` restored, `REJECT_REPLACEMENT` gone — now verify the #70 path. If `REJECT_REPLACEMENT` is a multi-word string whose slice at `+2` splits a word the later `!doc.getText().includes(REJECT_REPLACEMENT)` assertion still holds; the `includes(ORIG_REJECT)` restore assertion is the INV-5 check.)
- [ ] **Step 2: Run the fullLoop suite**
```bash
npm run test:e2e 2>&1 | grep -B 2 -A 8 "fullLoop\|full-loop\|full loop"
```
Expected: PASS end-to-end (would FAIL on unfixed code: the buffer would keep the tweaked `REJECT_REPLACEMENT`, so `includes(ORIG_REJECT)` fails).
- [ ] **Step 3: Full verification sweep**
```bash
npm run typecheck && npm test && npm run test:e2e
```
Expected: everything green.
- [ ] **Step 4: Commit**
```bash
git add test/e2e/suite/fullLoop.test.ts
git commit -m "test(#70): fullLoop reject leg now tweaks inside the pending range first (INV-5 end-to-end)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```
---
## Post-execution review wave (not in the original tasks)
A high-effort multi-agent branch review after Task 4 confirmed 10 findings; all
but one were fixed in a follow-up commit on this branch ("harden the
tracked-span revert per branch review"): pure `shiftTracked` in `anchorer.ts`
(+11 unit tests; insertion-at-span-start lands before the span), tracked spans
cleared on document close, rebuild-only resync at `renderAll`, `guard.isReadOnly`
on `revertInPlace`/`finalizeInPlace`, a "Discard proposal (leave text)" action on
the hard-fail warning, CodeLens pair anchored at the tracked span (+1 E2E),
QuickPick batch menu routed through the reporting commands, and one
`clearProposal` helper (also fixes `reject()`'s stale `applied`/`appliedSpans`).
The remaining finding — `resolve()` accepts a single exact occurrence without a
context check, so a duplicated block can hijack any anchor consumer — pre-dates
this fix and is filed as its own issue.
## Self-review notes
- **Issue coverage:** direction (a) → Task 1 (`appliedSpans` fallback); direction (b) → Task 2 (hard failure, warn, keep); direction (c) explicitly rejected (guessing violates INV-11). The issue's repro sketch is Task 1's test verbatim; the "full-loop E2E deliberately rejects only an un-tweaked proposal" note is retired by Task 4; `rejectAll` (same `revertInPlace` seam) is covered by Task 3.
- **Type consistency:** `revertInPlace(docPath, proposalId, opts?: { silent?: boolean })` defined in Task 1, consumed in Task 3; `rejectAll` returns `{ reverted, skipped }` (additive); `appliedSpans: Map<string, OffsetRange>` used in Tasks 1 and 3.
- **Contrast guard:** `finalizeInPlace` untouched except symmetric `appliedSpans.delete` cleanup — the Keep bypass stays.
+342
View File
@@ -0,0 +1,342 @@
# #71 Surface Polish Batch 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:** Land the six batched Minor findings from the native-surfaces migration's final whole-branch review (issue #71) — misleading `pin()` warning, orphaned command declarations, a wrong when-clause key, README F3/banner drift, an ungated palette entry, and an uncleared debounce timer.
**Architecture:** Six independent point fixes across `src/diffViewController.ts`, `src/gitBaseline.ts`, `package.json`, and `README.md`. No new modules, no model/persistence changes, no new invariants. Verification is the existing suites (all six items are copy/declaration/cleanup changes with no unit-testable pure surface — the unit suite is vscode-free and these all live at the vscode boundary; E2E cannot observe warning-message text or palette visibility).
**Tech Stack:** VS Code extension (TypeScript, esbuild CJS bundle), vitest unit suite (`npm test`), `@vscode/test-electron` host E2E (`npm run test:e2e`).
## Global Constraints
- Work happens on branch `worktree-s71-surface-polish` in the worktree at `.claude/worktrees/s71-surface-polish` (canonical checkout is occupied by concurrent session 0062 — never touch it).
- Commits cite #71, read like surrounding history, and carry the `Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>` trailer.
- Warning copy for the not-coediting case is exactly `"Run ✦ Coedit this Document with Claude first."` — the string `src/threadController.ts:181` already uses. Do not coin a variant.
- No inline trailing comments on shell commands (wgl `no-inline-cli-comments`).
- Final gate before PR: `npm test` (unit), `npm run typecheck`, `npm run build`, `npm run test:e2e` all green.
---
### Task 1: `pin()` explicit not-coediting branch (item 1)
**Files:**
- Modify: `src/diffViewController.ts:145-155`
**Interfaces:**
- Consumes: `this.modes: Map<string, "head" | "snapshot">` (set only by `establish()`).
- Produces: nothing new — same `pin(document): void` signature; only the warning copy forks.
`pin()` currently emits "this document is git-tracked — commit to advance the baseline" for **any** non-snapshot mode, including `undefined` (a document that never established, i.e. is not being coedited) — a wrong message for that case.
- [ ] **Step 1: Fork the guard on `undefined`**
Replace lines 145155 (the whole `pin` method) with:
```ts
pin(document: vscode.TextDocument): void {
if (!this.isDiffable(document)) return;
const key = this.uriKey(document);
const mode = this.modes.get(key);
if (mode === undefined) {
// never established — the document is not being coedited (#71 item 1)
void vscode.window.showWarningMessage("Run ✦ Coedit this Document with Claude first.");
return;
}
if (mode !== "snapshot") {
void vscode.window.showWarningMessage(
"Cowriting: this document is git-tracked — commit to advance the baseline.",
);
return;
}
this.capture(document, "pinned");
}
```
Keep the existing doc comment above the method unchanged.
- [ ] **Step 2: Verify**
Run: `npm run typecheck`
Expected: clean exit.
- [ ] **Step 3: Commit**
```bash
git add src/diffViewController.ts
git commit -m "fix: markReviewed on a never-established doc says 'coedit first', not 'git-tracked' (#71 item 1)"
```
---
### Task 2: delete orphaned `acceptProposal` / `rejectProposal` declarations (item 2)
**Files:**
- Modify: `package.json` (two `contributes.commands` entries ~85-93, two `contributes.menus.commandPalette` entries ~180-187)
**Interfaces:** none — pure declaration removal. `test/e2e/suite-no-workspace/noWorkspace.test.ts:42-43` already asserts these commands are **not registered** at runtime; nothing registers them in `src/`.
- [ ] **Step 1: Delete the two command declarations**
In `contributes.commands`, delete both objects:
```json
{
"command": "cowriting.acceptProposal",
"title": "✓ Accept Proposal",
"category": "Cowriting"
},
{
"command": "cowriting.rejectProposal",
"title": "✗ Reject Proposal",
"category": "Cowriting"
},
```
- [ ] **Step 2: Delete their palette-hiding menu entries**
In `contributes.menus.commandPalette`, delete both objects (they exist only to hide the now-deleted declarations):
```json
{
"command": "cowriting.acceptProposal",
"when": "false"
},
{
"command": "cowriting.rejectProposal",
"when": "false"
},
```
- [ ] **Step 3: Verify no dangling references**
Run: `grep -rn "acceptProposal\|rejectProposal" package.json src/`
Expected: no matches (the only remaining references are the retirement assertions in `test/e2e/suite-no-workspace/noWorkspace.test.ts`).
Run: `node -e "JSON.parse(require('fs').readFileSync('package.json','utf8')); console.log('valid json')"`
Expected: `valid json`
- [ ] **Step 4: Commit**
```bash
git add package.json
git commit -m "chore: delete orphaned acceptProposal/rejectProposal declarations (#71 item 2)"
```
---
### Task 3: `openReviewPreview` editor/title when-key (item 3)
**Files:**
- Modify: `package.json:260` (the `editor/title` menu entry for `cowriting.openReviewPreview`)
**Interfaces:** none — one-token when-clause fix. Title menus key off the resource, so `resourceLangId` is correct (every sibling `editor/title` entry already uses it); `editorLangId` is the odd one out.
- [ ] **Step 1: Fix the key**
In `contributes.menus."editor/title"`, change:
```json
{
"command": "cowriting.openReviewPreview",
"when": "editorLangId == markdown",
"group": "navigation@9"
}
```
to:
```json
{
"command": "cowriting.openReviewPreview",
"when": "resourceLangId == markdown",
"group": "navigation@9"
}
```
(Only the `editor/title` entry. The `commandPalette` entries elsewhere legitimately use `editorLangId`; the `editor/title/context` and `explorer/context` entries already use `resourceLangId`.)
- [ ] **Step 2: Commit**
```bash
git add package.json
git commit -m "fix: openReviewPreview title-menu when uses resourceLangId like its siblings (#71 item 3)"
```
---
### Task 4: gate the `cowriting.createThread` palette entry (item 5)
**Files:**
- Modify: `package.json` (`contributes.menus.commandPalette`)
**Interfaces:** none — `createThreadOnSelection` (`src/threadController.ts:197-201`) already returns `undefined` silently without a selection / coediting / authorable scheme; the palette entry is currently unconditionally visible, so the command silently no-ops from the palette. Fix = gate the palette entry with the **same when-clause its `editor/context` entry uses** (`package.json:290-293`), so the palette only offers it when it can act — consistent with how `markReviewed`'s palette gate was tightened at merge.
- [ ] **Step 1: Add the palette gate**
In `contributes.menus.commandPalette`, insert after the `cowriting.proposeAgentEdit` entry:
```json
{
"command": "cowriting.createThread",
"when": "editorHasSelection && (resourceScheme == file || resourceScheme == untitled) && cowriting.isCoediting"
},
```
- [ ] **Step 2: Verify JSON**
Run: `node -e "JSON.parse(require('fs').readFileSync('package.json','utf8')); console.log('valid json')"`
Expected: `valid json`
- [ ] **Step 3: Commit**
```bash
git add package.json
git commit -m "fix: gate createThread palette entry on selection+coediting like its context-menu entry (#71 item 5)"
```
---
### Task 5: clear the `gitBaseline` reflog debounce timer in `dispose()` (item 6)
**Files:**
- Modify: `src/gitBaseline.ts:113-126` (`watchReflog`)
**Interfaces:** none — same disposable contract; the pushed disposable additionally clears the pending debounce timeout so no one-shot `repo.status()` fires post-dispose.
- [ ] **Step 1: Clear the timer in the pushed disposable**
In `watchReflog`, change:
```ts
const watcher = fs.watch(reflog, () => {
if (pending) clearTimeout(pending);
pending = setTimeout(() => void repo.status().catch(() => {}), 150);
});
this.disposables.push({ dispose: () => watcher.close() });
```
to:
```ts
const watcher = fs.watch(reflog, () => {
if (pending) clearTimeout(pending);
pending = setTimeout(() => void repo.status().catch(() => {}), 150);
});
this.disposables.push({
dispose: () => {
watcher.close();
if (pending) clearTimeout(pending);
},
});
```
- [ ] **Step 2: Verify**
Run: `npm run typecheck`
Expected: clean exit.
- [ ] **Step 3: Commit**
```bash
git add src/gitBaseline.ts
git commit -m "fix: clear reflog debounce timer on GitBaselineWatcher dispose (#71 item 6)"
```
---
### Task 6: README F3 drift + stale chord notes in superseded banners (item 4)
**Files:**
- Modify: `README.md` (F3 section ~106, F7 banner ~230-233, F10 banner ~313-317)
**Interfaces:** none — docs only. Current reality the banner must state: `Cowriting: Toggle Attribution` no longer exists (annotations toggle via **Toggle Annotations**, How it works §6); "Ask Claude to Edit Selection" is palette-hidden (`package.json:201-203``"when": "false"`), superseded by **Ask Claude to Edit** (`Ctrl+Alt+E` / `Cmd+Alt+E`) and the comments-first **Ask Claude** (§3). `Ctrl+Alt+R` today is **Review Changes** (native diff), not any preview.
- [ ] **Step 1: Add the F3 banner**
Immediately after the `## F3 — Live human/Claude attribution (Feature #6)` heading (before the "As you and Claude coauthor…" paragraph), insert:
```markdown
> **Commands superseded (native-surfaces migration).** The attribution **data
> layer** below is current, but the Commands block is historical:
> `Cowriting: Toggle Attribution` was retired — authorship/change coloring now
> toggles via **Toggle Annotations** on a coedited document (How it works §6) —
> and "Ask Claude to Edit Selection" is palette-hidden; use **Ask Claude to
> Edit** (`Ctrl+Alt+E` / `Cmd+Alt+E`) or the comments-first **Ask Claude** (How
> it works §3). Kept below as a historical record.
```
- [ ] **Step 2: Chord note in the F7 banner**
Extend the F7 superseded banner's last sentence. Change:
```markdown
> **Superseded (Task 8, native-surfaces migration).** The bespoke webview this
> section describes was deleted; its authorship/change coloring lives on
> **inside VS Code's own built-in Markdown preview** — see **How it works** §6
> above. Kept below as a historical record of the pre-migration design.
```
to:
```markdown
> **Superseded (Task 8, native-surfaces migration).** The bespoke webview this
> section describes was deleted; its authorship/change coloring lives on
> **inside VS Code's own built-in Markdown preview** — see **How it works** §6
> above. Kept below as a historical record of the pre-migration design. (The
> `Ctrl+Alt+R` chord below now opens the native **Review Changes** diff; the
> annotated preview opens via **Open Cowriting Review Preview**, no chord.)
```
- [ ] **Step 3: Chord note in the F10 banner**
Change the F10 banner's last sentence from:
```markdown
> **How it works** above. Kept below as a historical record.
```
to:
```markdown
> **How it works** above. Kept below as a historical record. (The `Ctrl+Alt+R`
> chord below now opens the native **Review Changes** diff, not this panel.)
```
- [ ] **Step 4: Commit**
```bash
git add README.md
git commit -m "docs: F3 superseded-commands banner + stale-chord notes in F7/F10 banners (#71 item 4)"
```
---
### Task 7: full verification + PR + merge
**Files:** none new.
- [ ] **Step 1: Full local gate**
Run each; all must be green:
```bash
npm test
npm run typecheck
npm run build
npm run test:e2e
```
Expected: unit suite passes (~250 tests), typecheck clean, build succeeds, host E2E passes (undo suites may self-skip via the #54 runtime probe — a loud warning, not a failure).
- [ ] **Step 2: Push branch + open PR**
```bash
git push -u origin worktree-s71-surface-polish
```
Open a PR against `main` on `benstull/vscode-cowriting-plugin` (Gitea, SSH remote) titled `Surface polish: batched Minor findings from the native-surfaces final review (#71)`; body lists the six items and cites the issue.
- [ ] **Step 3: Merge (squash) and confirm #71 auto-close/close**
Merge the PR; verify `main` carries the change and close #71 if the merge didn't.
@@ -0,0 +1,602 @@
---
status: graduated
---
# Solution Design: Preview Toolbar as the Primary Interaction Surface (F11)
| | |
| --- | --- |
| **Author(s)** | Ben Stull (with Claude) |
| **Reviewers / approvers** | Ben Stull |
| **Status** | `draft` |
| **Version** | v0.1.0 |
| **Source artifacts** | Feature `benstull/vscode-cowriting-plugin#43` (F11, `type/feature`, `priority/P1`) · Epic `#1` (closed) · Capture session `vscode-cowriting-plugin-0035` (2026-06-12) · Brainstorming session `vscode-cowriting-plugin-0036` · Builds on (all shipped): F3 `#6` (live attribution), F4 `#12` (propose/accept), F6 `#17`/`#19` (baseline + diff view), F7 `#21`/`#22` (rendered preview), F9 `#27` (authorship preview), F10 `#29` (interactive review preview), F10-followups `#31` (inline-at-anchor proposals) · Coexists with `#41` (right-click → Open Review Panel) and `#42` (right-click → Ask Claude to Edit), both blocked-by this · Parent specs (graduated): `coauthoring-inner-loop.md`, `coauthoring-attribution.md`, `coauthoring-propose-accept.md`, `coauthoring-diff-view.md`, `coauthoring-rendered-preview.md`, `coauthoring-interactive-review.md` · Lineage: `ben.stull/rfc-app#48` |
**Change log**
| Date | Version | Change | By |
| --- | --- | --- | --- |
| 2026-06-12 | v0.1.0 | Initial draft — brainstorming session 0036 (from the capture in session 0035). Three forks locked with the operator: block-level preview-selection→source mapping; document edit diffed into per-hunk F4 proposals; #43 lands a minimal right-click→open-preview gateway. | Ben Stull + Claude |
---
## 1. Business Context
### 1.1 Executive Summary
F10 made the rendered preview the **single review surface** — clean editor on the
left, annotated review on the right, with an annotations on/off toggle and
inline ✓/✗ on Claude's pending proposals. But the writer still cannot *act* from
the preview beyond accepting/rejecting proposals: to **ask Claude to edit**, they
jump back to the editor and use a selection-gated context-menu item; to **pin a
fresh review baseline** they have *no* reachable control at all (the
`cowriting.pinDiffBaseline` command is registered but `when:false`, orphaned
since `#34` removed its two-pane host); and whole-document editing doesn't exist.
F11 makes the **preview toolbar the primary interaction surface**. Beside the
existing annotations checkbox — the one control the writer already loves — the
toolbar gains a **Pin baseline** button and a **single adaptive "Ask Claude…"
button** that reads *Edit Selection* when text is selected in the preview and
*Edit Document* when nothing is. The writer reads, asks Claude to edit, and
resets the baseline all in one place, mouse-first, without leaving the rendered
document. A minimal right-click entry opens the preview, making it the surface
the `#41`/`#42` gateways will lead into.
### 1.2 Background
The inner loop shipped F2F5 (threads · attribution · propose/accept ·
cross-rung). F6 added the baseline + a native diff toggle; F7 the rendered
track-changes preview; F9 an authorship mode; F10 (`#29`) collapsed those into the
**single interactive review preview** (clean editor; annotations on/off; ✓/✗ on
F4 proposals surfaced in the rendered view); `#31` then placed proposals
**inline at their resolved anchor** in that preview.
Capture session 0035 filed `#43` (this feature) plus `#41` (right-click → Open
Review Panel) and `#42` (right-click → Ask Claude to Edit). The operator's ask:
*"Can we set it up so all interactions — Ask Claude to Edit Selection / Edit
Document, annotations off/on, Pin new baseline — are via the preview window? I
like the annotations checkbox up there; make the others buttons, with one 'Ask
Claude…' button that changes depending on whether some of the markdown preview is
selected."* That session also surfaced the stranded `pinDiffBaseline` command
(`when:false` since `#34`). This spec is the Solution Design for `#43`.
### 1.3 Business Actors / Roles
- **Coauthor (human)** — the markdown writer/engineer (PP-1); F11's sole user.
- **Coauthor (machine)** — Claude via `@cline/sdk`; not a user of F11, but the
target of the toolbar's "Ask Claude…" gesture and the author of the proposals
that result.
### 1.4 Problem Statement
The plugin's interactions are scattered across surfaces and inconsistently
reachable. "Ask Claude to Edit Selection" is only a selection-gated **editor**
context-menu item; **whole-document editing doesn't exist**; and **Pin Review
Baseline** is **unreachable from any UI**. The one control the writer loves — the
annotations on/off checkbox in the preview — proves the toolbar is the natural
home for these gestures, but it stands alone. A writer reviewing in the preview
has to leave it and hunt through editor menus / the palette to act.
### 1.5 Pain Points
- **No edit gesture in the preview** — to ask Claude to change anything, the
writer leaves the review surface for the editor's right-click menu.
- **Whole-document editing is missing** — there is no "edit the whole document"
path at all; only a selection-scoped editor command exists.
- **Pin baseline is stranded** — the command exists but no menu, keybinding, or
palette entry reaches it (`when:false` since `#34`).
- **Mouse-first review is broken mid-flow** — the preview is mouse-driven, but
acting forces a context-switch to keyboard/menus elsewhere.
### 1.6 Targeted Business Outcomes
The preview becomes a **self-contained cockpit** for the inner loop. From its
toolbar a writer can toggle annotations (today), **ask Claude to edit** (selection
or whole document, via one button that adapts to what's selected), and **pin a
fresh baseline** — no context-switching to the editor or command palette. The
interaction model consolidates around the surface the writer already prefers, and
the stranded pin command gets a real home.
### 1.7 Scope (business)
**In scope:** preview-webview toolbar controls — a **Pin baseline** button and a
**single adaptive "Ask Claude…" button** (Edit Selection ⇆ Edit Document) — beside
the existing annotations checkbox; wiring those controls to the **existing** F4
edit seam, F3 attribution, and F6 baseline command; **block-level**
preview-selection → source-range mapping (the central design risk); a **new
whole-document edit path** whose result is **diffed into per-hunk F4 proposals**;
a **minimal right-click → Open Review Preview** entry so the surface is reachable
end-to-end; resolving the pin-baseline reachability gap; unit + host-E2E coverage;
manual webview smoke.
**Out of scope (deferred, not forgotten):** **char-precise sub-block** selection
mapping (block granularity is the locked v1 — §6.7); the **richer `#41`/`#42`
menu sets** (this feature lands only the minimal gateway; `#41`/`#42` expand it);
preview→source **scroll-sync** (`#32`); multi-file / batch editing; the Explorer
tree affordance; any export / print / copy gesture.
**Non-goals (firm):** **no new edit / attribution / proposal *model*** — F11
reuses F3 `spansFor`, the F4 `propose`/`accept` single-range model, and the F6
baseline store; no change to the **sidecar**, the **cross-rung contract**, or
`SCHEMA_VERSION`; **no document mutation from the webview** (INV-20/21/34 hold —
the sealed webview posts intent only); no LLM/network/credential surface added to
the webview (INV-8 untouched — the edit turn runs host-side as today).
### 1.8 Assumptions · Constraints · Dependencies
- **Anchor:** Feature `#43` (F11). Builds directly on shipped work: the F7/F10
rendered preview + annotations toggle + host↔webview message bus, the F3
attribution + F4 propose/accept inner loop (including `#31`'s inline-at-anchor
proposal placement), and the F6 baseline store (`cowriting.pinDiffBaseline`,
currently unreachable — this feature gives it a home).
- **Central design risk (locked):** the preview is a **rendered** sealed webview
(markdown-it HTML, strict CSP — F7 INV-21), so "Edit Selection" must map a
selection in the rendered preview back to a **source markdown range**. The
rendered HTML carries **no** source positions today; only internal block
char-offsets exist (`splitBlocksWithRanges``BlockWithRange.start/end`). The
locked approach is **block-level** mapping: the pure render layer emits
`data-src-start`/`data-src-end` on each rendered block; a selection resolves to
the union of the live-source blocks it intersects (§6.7, fork 1).
- **Constraint (sealed webview):** interactive controls post messages to the
extension host, which applies edits/pins via the existing F4 / F6 paths; the
webview **never** edits the document, sidecar, or baseline directly. The edit
**turn** (LLM call) and the **instruction prompt** run host-side, keeping the
webview free of LLM/network/credential surface.
- **Coexistence:** the native editor context-menu "Ask Claude to Edit Selection"
(`cowriting.editSelection`) stays unchanged; `#41`/`#42` add right-click
gateways *into* the preview. F11 lands only a minimal gateway (§6.7, fork 3).
- No new persisted artifact; nothing in `.threads/`, the contract, or
`SCHEMA_VERSION` changes.
### 1.9 Business Use Cases
- **BUC-1 (edit from the preview)** Reviewing in the preview, the writer selects a
paragraph in the rendered document and clicks **"Ask Claude to Edit
Selection"**; Claude's proposed change appears as a blue ✓/✗ block at that
spot — without the writer ever leaving the preview.
- **BUC-2 (edit the whole document)** With nothing selected, the writer clicks
**"Ask Claude to Edit Document"**, types an instruction, and Claude's rewrite
surfaces as **several** independently-acceptable blue proposal blocks (one per
changed hunk) inline in the preview.
- **BUC-3 (pin a fresh baseline)** After accepting a batch of changes, the writer
clicks **"Pin baseline"**; the change-marks clear and "what changed" now counts
from this moment — the stranded command finally has a button.
---
## 2. Solution Proposal
F11 is a **thin increment** on F10's preview: it adds two controls to the
existing header toolbar and routes their intent through machinery that already
exists. No new model, no new persisted state.
**The pure render layer (`trackChangesModel.ts`) learns one new thing:** a shared
helper wraps each rendered block in `<div data-src-start="N" data-src-end="M">`
using the existing `splitBlocksWithRanges` offsets. It is applied to **both**
render paths — `renderReview` (annotations on) and `renderPlain` (annotations
off) — so selection→source mapping works in either mode. The helper is pure,
vscode-free, DOM-free, and deterministic (extends INV-22/33). The render layer
gains **no** selection or DOM logic.
**The webview (`media/preview.ts` + `.css`)** header becomes:
```
[ ☑ Annotations ] [ ⌖ Pin baseline ] [ ✦ Ask Claude to Edit Document ▾ ]
```
The **Ask-Claude button morphs its own label** on `selectionchange`: a non-empty
selection inside the rendered body → **"Ask Claude to Edit Selection"**; an empty
/ collapsed selection → **"Ask Claude to Edit Document"**. On click it walks the
selection's start and end nodes up to the nearest ancestor carrying
`data-src-start`/`data-src-end` and posts the resolved offsets. That
nearest-ancestor lookup is the webview's **only** mapping duty (manual-smoke
territory); everything downstream is host-side and testable. The webview stays
**sealed** (INV-21): nonce'd inline script, no network, no document mutation.
**New webview→host messages (intent only):**
```ts
type ToolbarMsg =
| { type: "pinBaseline" }
| { type: "askClaude"; scope: "selection"; start: number; end: number }
| { type: "askClaude"; scope: "document" };
```
**The host (`trackChangesPreview.ts`)** routes each intent through the existing
seams:
- **`pinBaseline`** → pins the **previewed** document (calls
`DiffViewController.pin(document)` directly — not the `activeTextEditor`-based
command, which may not point at the previewed doc) → `onDidChangeBaseline`
re-render with cleared marks.
- **`askClaude`** → host `showInputBox` for the instruction (keeps the LLM /
secret surface out of the sealed webview), then one shared host routine
`runEditAndPropose(document, target, instruction)`:
- **selection**`target` = the block-union range `[firstBlock.start …
lastBlock.end]`; one `runEditTurn` → one F4 `propose()` over that range (the
existing Edit-Selection shape).
- **document**`target` = whole document; one `runEditTurn` over the full
text → **diff Claude's result against the current text → one `propose()` per
changed hunk** (multiple proposals, each its own blue ✓/✗ block). Reuses the
F4 single-range model N times; no model change.
**The right-click gateway:** `cowriting.showTrackChangesPreview` is added to the
`editor/title` menu (markdown only) so right-clicking the tab opens the preview —
the minimal entry that makes the toolbar surface reachable end-to-end and lets
`#41`/`#42` expand the menu set later.
**Reachability cleanup:** `cowriting.pinDiffBaseline` gets a real palette `when`
(`editorLangId == markdown`), resolving the orphan from the command side too; a
new `cowriting.editDocument` command is registered (document-scoped edit) so
`#42`'s gateway can reuse it.
Everything downstream of *(intent) → (existing seam)* is the existing F4/F6/F3
machinery; the only genuinely new pure code is the block-offset wrapper and the
document-rewrite hunk-diff. Both are unit-testable with no vscode and no webview.
---
## 3. Product Personas
- **PP-1 Inner-loop coauthor** — the human markdown writer/engineer (as F2F10);
the only persona F11 serves.
## 4. Product Use Cases
- **PUC-1 (toolbar present)** Opening the review preview for a markdown document
shows the header with **three** controls: the annotations on/off checkbox
(existing), a **Pin baseline** button, and an adaptive **Ask Claude…** button.
Controls are inert (disabled) for non-authorable documents.
- **PUC-2 (adaptive label)** With a non-empty selection in the rendered preview
body, the Ask-Claude button reads **"Ask Claude to Edit Selection"**; with no
selection it reads **"Ask Claude to Edit Document"**. The label flips live as
the selection changes.
- **PUC-3 (edit selection)** The writer selects rendered text, clicks **Ask
Claude to Edit Selection**, and enters an instruction. The selection resolves to
the union of the source blocks it touches; Claude proposes a change over that
range; a single blue ✓/✗ proposal block appears inline at that anchor (`#31`).
- **PUC-4 (edit document)** With nothing selected, the writer clicks **Ask Claude
to Edit Document**, enters an instruction; Claude rewrites the whole document;
the rewrite is diffed into hunks and surfaces as **N** independent blue ✓/✗
proposal blocks inline. Accepting/rejecting each is the F10 path unchanged.
- **PUC-5 (pin baseline)** The writer clicks **Pin baseline**; the previewed
document's review baseline is pinned to now; the change-marks clear and the
`Since <epoch>` label updates. (No confirmation prompt — matches the existing
command's behavior; re-pinning is the recovery.)
- **PUC-6 (right-click into the preview)** Right-clicking a markdown editor tab
shows **Open Review Preview**; choosing it opens the preview (the gateway
`#41`/`#42` will build upon).
- **PUC-7 (graceful edges)** A selection confined to a deletion (struck) or
proposal block — which carries no live-source range — falls back to **document**
scope. An empty document or a selection that resolves to no live block → the
button stays in **Edit Document** mode. A non-authorable document → toolbar edit
controls are disabled. The LLM turn failing → the existing `runEditTurn`
error handling (no proposal created); the preview is unchanged.
---
## 5. UX Layout
The F10 preview is unchanged except for its **header bar**, which now hosts three
controls in a single row:
- **☑ Annotations** — the existing on/off checkbox (kept first; the operator's
preferred control).
- **⌖ Pin baseline** — a button; pins the previewed document's review baseline to
now and clears the change-marks.
- **✦ Ask Claude to Edit Document ▾** — a single button whose label and behavior
adapt to the preview's selection state (Edit **Selection** when text is
selected, Edit **Document** otherwise). Clicking it opens a host input box for
the instruction.
Buttons are styled with theme CSS variables (light / dark / high-contrast),
matching the existing toolbar chrome; they sit in the same `#cw-toggle` header
region as the annotations checkbox. When the previewed document is not authorable
(F8 `isAuthorable`), the **Pin baseline** and **Ask Claude…** controls render
**disabled** (the annotations toggle stays active — reading is always allowed).
The rendered body is unchanged from F10/`#31`: green human additions, blue
LLM-authored text, struck deletions, and pending Claude proposals as inline blue
blocks with ✓/✗ at their resolved anchors. Proposals produced via the new toolbar
edit gestures appear exactly as proposals do today.
---
## 6. Technical Design
### 6.1 Invariants
Parent invariants INV-1..INV-34 carry over unchanged. F11 adds:
- **INV-35 (toolbar gestures route through existing seams; webview never
mutates)** The Pin baseline and Ask-Claude toolbar controls post **intent**
messages to the host; **all** mutation goes through the existing machinery — pin
via the F6 baseline store (`DiffViewController.pin`), edits via the F4
`propose``accept`/`applyAgentEdit` (`WorkspaceEdit`) seam with F3 attribution.
No divergent edit or baseline path is introduced. The sealed webview never
edits the document, sidecar, or baseline directly (INV-20/21/34 hold); the LLM
turn and the instruction prompt run host-side (INV-8 untouched).
- **INV-36 (block-granular preview-selection → source mapping)** The pure render
layer emits `data-src-start`/`data-src-end` (source char offsets from
`BlockWithRange`) on **every** rendered block, in **both** the on (`renderReview`)
and off (`renderPlain`) modes. A preview selection resolves to the **union of
the live-source blocks it intersects** (`[min start … max end]`); blocks with no
live-source range (deletion-only / proposal blocks) are skipped, and a selection
that resolves to no live block falls back to **document** scope. The DOM
selection → nearest-`data-src` lookup is the webview's **sole** mapping duty;
the offsets and everything downstream (fingerprint, turn, propose) are host-side
and testable. The wrapping is deterministic — same inputs → identical HTML
(extends INV-22/33).
- **INV-37 (single adaptive Ask-Claude button; scope-aware)** One toolbar button
serves both scopes. A non-empty live-source selection → **Edit Selection**: one
F4 proposal over the block-union range. An empty selection → **Edit Document**:
one `runEditTurn` over the whole document, its result **diffed into hunks**, one
F4 `propose()` per changed hunk. Both scopes call the same host
`runEditAndPropose` routine and reuse the F4 **single-range** proposal model
(the document case issues multiple single-range proposals — **no new model**).
### 6.2 High-level architecture
```mermaid
flowchart LR
wv["webview header\n☑ Annotations · ⌖ Pin · ✦ Ask Claude (adaptive)"] -- "postMessage{pinBaseline | askClaude(scope,start?,end?)}" --> ctl["trackChangesPreview\n(vscode layer)"]
ctl -- "pin(document)" --> base["F6 DiffViewController\nbaseline store (INV-18)"]
ctl -- "showInputBox → runEditAndPropose" --> turn["runEditTurn\n(host-side LLM turn)"]
turn -- "selection: 1 replacement\ndocument: rewrite" --> ctl
ctl -- "selection → 1 propose()\ndocument → diff → N propose()" --> prop["F4 ProposalController\npropose() (single-range model)"]
prop -- "onDidChangeProposals" --> ctl
base -- "onDidChangeBaseline" --> ctl
ctl -- "(baseline, current, spans, proposals)" --> model["renderReview / renderPlain\n(pure)\n+ wrapBlocksWithSrc (NEW)"]
model -- "annotated HTML w/ data-src on blocks" --> ctl
ctl -- "postMessage{render}" --> wv
```
The dashed-in NEW pieces are: `wrapBlocksWithSrc` (pure), the
`runEditAndPropose` host routine with its document-rewrite hunk-diff, the three
inbound toolbar messages, and the `editor/title` gateway menu. Everything else is
the existing F6/F4/F3/F10 machinery.
### 6.3 Data model & ownership
**No new persisted artifact** (INV-20). F11 adds only transient on-the-wire
messages (the `ToolbarMsg` union in §2) and reuses F10's `RenderMsg`. Baseline is
owned by F6, proposals by F4 (sidecar), attribution by F3 — all untouched. The
block-offset `data-src` attributes are render-time only (not stored).
### 6.4 Interfaces & contracts
- **`trackChangesModel`** (vscode-free, pure): new
`wrapBlocksWithSrc(blocks: BlockWithRange[], renderedPerBlock: string[]):
string` (illustrative) — or, more precisely, both `renderReview` and
`renderPlain` route their per-block rendered HTML through a shared internal
helper that prepends `data-src-start`/`data-src-end` to each block's wrapping
element. Plus `diffToHunks(currentText: string, rewrittenText: string):
Array<{ start: number; end: number; replacement: string }>` — the pure
document-rewrite → per-hunk proposal-range list (vscode-free, deterministic).
- **`TrackChangesPreviewController`** (vscode layer): handles the three new
inbound messages; gains a `runEditAndPropose(document, target: { kind:
"range"; start; end } | { kind: "document" }, instruction)` private routine;
takes (or reaches) the `DiffViewController` to pin the previewed doc and the
edit-turn entry. New test seams as needed (`getLastModel` already exists for
asserting marks without webview DOM).
- **`DiffViewController`** (F6): `pin(document)` is reused as-is (the controller
already exposes pinning by document); `cowriting.pinDiffBaseline`'s
`package.json` `when` flips from `false` to `editorLangId == markdown`.
- **`ProposalController`** (F4): `propose(...)` reused unchanged (called once for
selection, N times for a diffed document). No signature change.
- **`AttributionController`** (F3): unchanged (`applyAgentEdit` reused on accept).
- **Commands / menus (`package.json`):**
- `cowriting.showTrackChangesPreview` — added to `editor/title` with
`when: editorLangId == markdown` (the minimal gateway). Existing palette +
`ctrl+alt+r` kept.
- `cowriting.pinDiffBaseline``when` flips to `editorLangId == markdown`
(no longer orphaned).
- `cowriting.editDocument` ("Ask Claude to Edit Document", document-scoped) —
new command registered, routed through `runEditAndPropose({kind:"document"})`;
available for `#42` to reuse. (The preview's selection-scoped edit is driven
by the `askClaude` message carrying webview-resolved offsets, not a command,
since the offsets originate in the webview.)
- **Webview asset** (`media/preview.ts` + `.css`): header gains the two buttons;
a `selectionchange` listener updates the Ask-Claude label; click handlers post
the `ToolbarMsg` intents; the selection→nearest-`data-src` lookup helper. Stays
sealed (nonce'd inline script, CSP unchanged).
### 6.5 PerProduct-Use-Case design
- **PUC-1 (toolbar present):** render the two buttons in the header next to the
annotations checkbox; disable Pin + Ask-Claude when `!isAuthorable(document)`.
- **PUC-2 (adaptive label):** webview `selectionchange` → if the selection is
non-empty and within the rendered body, label = "Edit Selection"; else "Edit
Document". Pure webview-local state.
- **PUC-3 (edit selection):** webview resolves selection → `{start, end}` from the
nearest `data-src` ancestors → `postMessage{askClaude, selection, start, end}`
host `showInputBox``runEditAndPropose({kind:"range", start, end})`
`runEditTurn` → one `propose()``onDidChangeProposals` → re-render (inline
blue block at the anchor, `#31`).
- **PUC-4 (edit document):** `postMessage{askClaude, document}` → host input box →
`runEditAndPropose({kind:"document"})``runEditTurn` over full text →
`diffToHunks(current, rewritten)` → one `propose()` per hunk → re-render (N blue
blocks).
- **PUC-5 (pin baseline):** `postMessage{pinBaseline}` → `DiffViewController.pin(
previewedDocument)` → `onDidChangeBaseline` → re-render (marks cleared).
- **PUC-6 (right-click gateway):** `editor/title` entry invokes
`cowriting.showTrackChangesPreview` for the tab's document.
- **PUC-7 (edges):** selection resolving to no live block → document scope;
non-authorable → controls disabled; `runEditTurn` failure → existing error path,
no proposal; empty doc → Edit Document over empty range (no-op-safe).
### 6.6 Non-functional requirements & cross-cutting concerns
The webview stays **sealed** (INV-21): local assets, strict CSP with a per-load
nonce, no network; the new inline handlers only read `data-src`/`data-proposal-id`
and post intent (no eval, no remote, no document mutation). The instruction prompt
and the LLM turn remain **host-side** — the webview gains **no** LLM, network, or
credential surface (INV-8 untouched). `diffToHunks` and the block wrapping are
O(document), run on a host gesture (not per-keystroke), fine at inner-loop scale.
No telemetry, nothing persisted.
### 6.7 Key decisions & alternatives considered
| Decision | Chosen | Alternatives rejected |
| --- | --- | --- |
| **Preview-selection → source mapping granularity** | **Block-level** — pure layer emits `data-src-start/end` from existing `BlockWithRange`; selection → union of intersected live-source blocks. Robust, reuses what exists, ships the full adaptive button now. *(Operator decision, session 0036.)* | **Char-precise** sub-block mapping — needs per-inline-token source offsets markdown-it doesn't reliably give; rendered text ≠ source (syntax stripped) → fragile, risks the whole feature on the hardest part. **Document-only first** — defers the headline adaptive button; punts the risk. |
| **Document-edit proposal granularity** | **Diff Claude's rewrite into hunks → one F4 proposal per changed hunk** — independent ✓/✗ per change; reuses the single-range model N times (no model change). *(Operator decision, session 0036.)* | **One whole-document proposal** — a single giant blue block, all-or-nothing accept/reject; poor UX for a real rewrite. |
| **`#43` vs `#41`/`#42` scope** | **`#43` lands a minimal right-click → Open Review Preview gateway** (`editor/title`), so the toolbar surface is reachable end-to-end and its E2E is real; `#41`/`#42` expand the menu set. *(Operator decision, session 0036.)* | **Toolbar only; all menus in `#41`/`#42`**`#43`'s "a right-click entry opens the preview" acceptance/E2E couldn't be satisfied within `#43`. |
| **Instruction prompt location** | **Host `showInputBox`** — keeps LLM/secret surface out of the sealed webview; reuses the existing edit-turn flow. | **In-webview text field** — pushes prompt handling toward the sandbox; no benefit. |
| **Pin button target** | **The previewed document** (`DiffViewController.pin(document)`) — the preview knows its bound doc. | **`activeTextEditor`-based command** — may not point at the previewed doc; the source of the orphan. |
| **Pin confirmation** | **No confirm** — matches the existing command; re-pinning recovers. | **Confirm dialog** — friction for a routine, recoverable gesture. |
### 6.8 Testing strategy
- **Unit (vitest, vscode-free):** `data-src-start/end` present and correct on every
block for **both** `renderReview` and `renderPlain` (offsets equal the
`BlockWithRange` ranges; determinism — same inputs → identical HTML);
`diffToHunks` over fixtures — a single-hunk rewrite → one range; a multi-hunk
rewrite → the expected disjoint ranges with correct replacements; an unchanged
rewrite → zero hunks; whole-document replacement → one full-range hunk.
- **Host E2E (`@vscode/test-electron`, no LLM, extends the F10 suite):** open a
markdown fixture → `cowriting.showTrackChangesPreview`. Simulate
`{type:"pinBaseline"}``getLastModel` shows cleared change-marks + advanced
epoch. Simulate `{type:"askClaude", scope:"selection", start, end}` with a
stubbed edit turn → exactly **one** proposal over the resolved range, anchored
inline. Simulate `{type:"askClaude", scope:"document"}` with a stubbed
multi-hunk rewrite → **N** proposals matching the hunks. Invoke the
`editor/title` gateway command → panel opens. Non-authorable document → toolbar
edit controls disabled (asserted via the model/flags the host exposes). The
webview DOM, real button clicks, the `selectionchange` label flip, and the
selection→`data-src` lookup are **not** E2E-asserted (sealed sandbox) — manual
smoke.
- **Live smoke (manual — `docs/MANUAL-SMOKE-F11.md`):** open a markdown doc; open
the review preview; confirm the three header controls; select a paragraph →
button reads "Edit Selection", click → enter instruction → a blue ✓/✗ block
appears at that paragraph; clear the selection → button reads "Edit Document",
click → instruction → several blue blocks appear; click **Pin baseline** → marks
clear, `Since` label updates; right-click the tab → **Open Review Preview** opens
the panel; verify light/dark theming and that `git status` shows nothing
unexpected.
### 6.9 Failure modes, rollback & flags
A selection that resolves to no live-source block → **Edit Document** scope (never
an error). `runEditTurn` failing → existing error handling, no proposal created,
preview unchanged. `diffToHunks` producing zero hunks (rewrite == current) → no
proposals, a brief "no changes proposed" host notice. Webview disposed mid-gesture
→ the host routine completes against the document; the next open re-renders.
**No feature flag** — the toolbar controls are additive UI; nothing persists.
Rollback is reverting the PR with **zero** data migration (nothing persisted; the
F6 baseline, F4 sidecar, F3 attribution data are untouched; the unhidden pin
command and the gateway menu simply disappear).
---
## 7. Delivery Plan
### 7.1 Approach / strategy
One planning-and-executing session (F11 = `#43`), plan written just-in-time from
this spec — the F2F10 precedent. Host-E2E tier (a VS Code extension has no
browser/deploy stage); no LLM in CI (edit turns stubbed). The webview's visual
rendering, the adaptive label, and the selection→source DOM lookup are verified by
the manual smoke; the automated seams are the pure block-wrapping + `diffToHunks`
model and the host's message→seam wiring.
### 7.2 Slicing plan
- **SLICE-1 — Pin baseline button + reachability.** Webview header **Pin
baseline** button → `{type:"pinBaseline"}` → host `DiffViewController.pin(
previewedDoc)`; unhide `cowriting.pinDiffBaseline` (`when: editorLangId ==
markdown`). Host E2E: pin message clears marks. *(Immediate win — homes the
orphaned command.)*
- **SLICE-2 — Block-offset emission.** Shared pure helper wrapping each block with
`data-src-start/end` in `renderReview` **and** `renderPlain`; vitest for both
modes + determinism. No UI yet. (INV-36 data layer.)
- **SLICE-3 — Edit Document button + hunk path.** Webview **Ask Claude to Edit
Document** button (no-selection state) → `{type:"askClaude", scope:"document"}`;
host `runEditAndPropose({document})``runEditTurn``diffToHunks` → N
`propose()`; register `cowriting.editDocument`; vitest for `diffToHunks`; host
E2E for the N-proposal path. (INV-37 document half.)
- **SLICE-4 — Adaptive Edit Selection.** Webview `selectionchange` label flip +
selection→nearest-`data-src` resolution → `{type:"askClaude", scope:"selection",
start, end}`; host single-range `propose()`. Host E2E for the selection message →
one anchored proposal. (INV-37 selection half; INV-36 consumer.)
- **SLICE-5 — Gateway, edges, tests & docs.** `editor/title` → Open Review Preview
gateway; non-authorable disabling; host E2E (gateway opens panel; controls
inert on non-authorable); `docs/MANUAL-SMOKE-F11.md`; README F11 section.
E2E are first-class plan tasks (handbook §9/§4); this app's required tier is host
E2E (the F2F10 precedent).
### 7.3 Rollout / launch plan
Non-shippable (no marketplace publish). "Done" = `#43` acceptance: the preview
toolbar hosts the annotations checkbox + a Pin baseline button + a single adaptive
Ask-Claude button (Edit Selection ⇆ Edit Document) that route through the existing
F4/F3/F6 machinery; edits surface as proposals (one for a selection, per-hunk for
a document rewrite); a right-click entry opens the preview; the pin command is no
longer orphaned; unit + host E2E green; live smoke performed once.
### 7.4 Risks & mitigations
| Risk | Mitigation |
| --- | --- |
| Block-level selection feels coarse vs the editor's char-precise Edit Selection | Locked v1 decision (§6.7); a rendered surface is naturally block-grained; char-precise is a deferred follow-up if the coarseness bites |
| `data-src` attributes perturb markdown-it output or the F10 proposal/diff rendering | Wrapping is applied at the block boundary (outside inline parsing); covered by determinism + both-mode unit tests; per-block `try/catch` error chip (F7) on render failure |
| `diffToHunks` produces awkward hunk boundaries on a large rewrite | Pure + unit-tested over fixtures; hunks are line/block-aligned; worst case is more/fewer blocks, all independently ✓/✗-able — never wrong, just granular |
| Selection inside a deletion/proposal block has no live-source range | Falls back to Document scope by design (INV-36); manual-smoke verified |
| The webview selection→`data-src` lookup isn't E2E-testable (sealed) | The host half (offsets→fingerprint→propose) is E2E'd via simulated messages; the DOM lookup is the only manual-smoke-only seam, kept deliberately thin |
| Unhiding pin / adding `editDocument` widens the command surface | Both guard on `editorLangId == markdown`; both route through existing seams; no new model or persisted state |
---
## 8. Traceability matrix
| Requirement (`#43`) | Use case | Design | Slice |
| --- | --- | --- | --- |
| Pin baseline button in the preview toolbar | PUC-5 | INV-35, §6.4 (`DiffViewController.pin`) | SLICE-1 |
| Resolve the orphaned `pinDiffBaseline` reachability | PUC-5 | §6.4 (`when` flip) | SLICE-1 |
| Single adaptive Ask-Claude button (Selection ⇆ Document) | PUC-2/3/4 | INV-37, §6.2 | SLICE-3/4 |
| Preview-selection → source range mapping (block-level) | PUC-3 | INV-36, §6.7 | SLICE-2/4 |
| Edit Document path (new whole-document edit) | PUC-4 | INV-37, §6.5 (hunk diff) | SLICE-3 |
| Edits route through existing F4/F3 (no divergent path) | PUC-3/4 | INV-35, §6.4 | SLICE-3/4 |
| Right-click entry opens the preview (minimal gateway) | PUC-6 | §6.4 (`editor/title`) | SLICE-5 |
| Controls only active for supported (authorable) docs | PUC-1/7 | §6.5 | SLICE-5 |
| Sealed webview, no document mutation / LLM surface | — | INV-21/35, §6.6 | all |
| No new edit/attribution/proposal model | — | §1.7, INV-37 | all |
| Unit + host E2E + right-click-opens-preview coverage | — | §6.8 | SLICE-1..5 |
## 9. Open Questions & Decisions log
- **RESOLVED (session 0036, operator):** preview-selection → source mapping =
**block-level** (`data-src` attributes from `BlockWithRange`; union of
intersected blocks); document edit = **diffed into per-hunk F4 proposals**;
`#43` **lands a minimal right-click → Open Review Preview gateway** (`#41`/`#42`
expand the menus).
- **RESOLVED (this spec, autonomous):** instruction prompt = **host `showInputBox`**
(LLM/secrets stay out of the webview); Pin targets the **previewed document**
(`DiffViewController.pin`); **no confirmation** on pin (matches existing); a new
`cowriting.editDocument` command is registered for `#42` reuse; `pinDiffBaseline`
is unhidden (`editorLangId == markdown`).
- **OPEN → later:** **char-precise** sub-block selection mapping (deferred — block
granularity is v1); the **richer `#41`/`#42` menu sets** (this lands only the
minimal gateway); preview→source **scroll-sync** (`#32`); whether a large
document rewrite should cap/segment its hunks (only if real rewrites prove
noisy); the repo rename to `vscode-markdown-cowriting-plugin` (`#35`, deferred).
## 10. Glossary & References
- **Preview toolbar** — the review preview's header row: the annotations on/off
checkbox (existing) plus F11's Pin baseline and adaptive Ask-Claude buttons.
**Adaptive Ask-Claude button** — one button reading "Edit Selection" (non-empty
preview selection) or "Edit Document" (none). **Block-level selection mapping**
resolving a rendered-preview selection to the union of source blocks it
intersects, via `data-src-start/end` attributes emitted by the pure render
layer. **Hunk-diffed document edit** — Claude's whole-document rewrite split
into changed hunks, each surfaced as its own F4 proposal. **Pin baseline** — F6's
`DiffViewController.pin` applied to the previewed document. **Gateway** — a
right-click entry that opens the preview (this feature lands the minimal one;
`#41`/`#42` expand them).
- Feature `#43` (F11) · Epic `#1` · builds on F3 `#6`, F4 `#12`, F6 `#17`/`#19`,
F7 `#21`/`#22`, F9 `#27`, F10 `#29`/`#31` · coexists with `#41`/`#42` · parent
specs `coauthoring-inner-loop.md`, `coauthoring-attribution.md`,
`coauthoring-propose-accept.md`, `coauthoring-diff-view.md`,
`coauthoring-rendered-preview.md`, `coauthoring-interactive-review.md` · capture
session 0035 · lineage `ben.stull/rfc-app#48`.
+592
View File
@@ -0,0 +1,592 @@
---
status: graduated
---
# Solution Design: The Ask-Claude Document-Edit Flow (#42 · #47 · #46)
| | |
| --- | --- |
| **Author(s)** | Ben Stull (with Claude) |
| **Reviewers / approvers** | Ben Stull |
| **Status** | `draft` |
| **Version** | v0.1.0 |
| **Source artifacts** | Features `benstull/vscode-cowriting-plugin#42` (Ask Claude to Edit Document, `type/feature`, `priority/P2`) · `#47` (block-granularity proposals, `type/feature`, `priority/P1`) · `#46` (accept-all, `type/feature`, `priority/P2`) · Epic `#1` (closed) · Capture sessions `vscode-cowriting-plugin-0035` (#42), `0040` (#47), `0039` (#46) · Brainstorming session `vscode-cowriting-plugin-0041` · Builds on (all shipped): F4 `#12` (propose/accept seam), F10 `#29` (interactive review preview), F11 `#43` (preview toolbar + `editDocument`/`runEditAndPropose`) · Parent specs (graduated): `coauthoring-propose-accept.md`, `coauthoring-attribution.md`, `coauthoring-interactive-review.md`, `coauthoring-rendered-preview.md` · F11 design currently lives in code + issue draft `issues/preview-toolbar-interaction-surface.md` (un-graduated — see §9) · Lineage: `ben.stull/rfc-app#48` |
**Change log**
| Date | Version | Change | By |
| --- | --- | --- | --- |
| 2026-06-12 | v0.1.0 | Initial draft — brainstorming session 0041. One combined design for the document-edit cluster, sequencing #42#47#46. Two forks locked with the operator: per-block proposals **preserve unchanged-span attribution** (block = decision unit, word = attribution unit); the cluster ships as **one** Solution Design. | Ben Stull + Claude |
---
## 1. Business Context
### 1.1 Executive Summary
The plugin can already ask Claude to edit a whole document (F11 shipped
`cowriting.editDocument``runEditAndPropose({kind:"document"})`), but the flow
around that capability is rough on three sides, each captured as its own feature:
- **You can't easily reach it** (#42). "Ask Claude to Edit" only appears in the
editor body **when text is selected**. Right-clicking the body with *no*
selection, or right-clicking the editor **tab**, offers nothing — two natural
"do something with this document" gestures hit a dead end.
- **What comes back is unreviewable** (#47, **P1**). A whole-document rewrite is
diffed at **word** granularity (`diffToHunks`, INV-37): a light copy-edit pass
explodes into dozens of tiny blue ✓/✗ blocks, forcing the reviewer to
adjudicate word-fragments out of the sentence they live in.
- **Taking the result is tedious** (#46). Accepting Claude's edits is **one
proposal at a time**; a document a writer wants wholesale still demands a click
per proposal.
This design treats the three as **one flow****reach → review → accept** — and
ships them in that order. **#42** makes the document edit reachable from the
obvious gestures; **#47** cuts the rewrite into **one proposal per changed block**
(paragraph / header / bullet / fence) so the reviewer decides in the unit they
think in; **#46** adds a single **Accept all** gesture. The whole flow is built on
the existing F4 propose/accept seam and the F10/F11 preview — **no new edit path,
no new accept mechanism, no new persisted model.**
### 1.2 Background
The inner loop shipped F2F5 (threads · attribution · propose/accept ·
cross-rung). F10 (`#29`) collapsed the review surfaces into one: a **clean
editor** + the **rendered markdown preview** as the single annotated review
surface, with ✓/✗ acting only on pending F4 proposals (INV-32..34). F11 (`#43`)
made the preview's **toolbar the primary interaction surface** — a **Pin
baseline** button and an **adaptive Ask-Claude** button — and, as part of that,
added the host-side **`cowriting.editDocument`** command and the
**`runEditAndPropose`** path that diffs a whole-document rewrite into per-hunk F4
proposals (INV-35 sealed-webview intent, INV-36 `data-src` block→source mapping,
INV-37 word-level per-hunk granularity).
So the *capability* exists; this cluster is about the **experience around it**.
Three captures landed it:
- **0035 → #42**: "Add 'Ask Claude to Edit Document' on right-click with no
selection; same Edit-Selection/Edit-Document on the tab."
- **0040 → #47**: "A single change should be a paragraph (or header, or
bulletpoint), not individual words. It's too much to review."
- **0039 → #46**: "We need a way to accept all of Claude's edits."
The captures themselves flagged that these "form an edit-flow cluster — brainstorm
/ sequence together," which this session does.
### 1.3 Business Actors / Roles
- **Coauthor (human)** — the writer/engineer; the sole user of every gesture
here (right-click to ask, ✓/✗ to adjudicate, Accept-all to commit).
- **Coauthor (machine)** — Claude via `@cline/sdk`; produces the document
rewrite that becomes block-level proposals; its accepted blocks land
Claude-attributed (word-precise — §6, INV-40).
### 1.4 Problem Statement
How do we make "ask Claude to revise this whole document, look at what it did,
and take the parts I want" a **fluid, reviewable, low-friction** loop — when today
it is **hard to invoke, exploded into word-fragments, and accepted one click at a
time** — **without** forking a second edit path, a second accept mechanism, or a
new persisted artifact?
### 1.5 Pain Points
| # | Pain | Felt by | Today |
| --- | --- | --- | --- |
| P-1 | No whole-document ask from the body (no selection) or the tab | Mouse-first / tab-oriented writers | Dead-end gestures |
| P-2 | A document edit fans out into many word-level proposals | Anyone reviewing a document edit (worse the lighter the edit) | "Too much to review" (#47, P1) |
| P-3 | Accepting many proposals is one click each | Writers who want Claude's whole pass | Manual sweep of ✓ |
| P-4 | Block-level accept could lose attribution fidelity inside a block | The product's F3 attribution value | (a design risk this spec resolves — INV-40) |
### 1.6 Targeted Business Outcomes
- A **consistent "Ask Claude to Edit"** entry point — selection-scoped with a
selection, whole-document without — from both the body and the tab.
- A document edit produces a **small number of in-context, block-sized**
decisions instead of a flood of word-fragments.
- **"Take Claude's whole pass"** is a **single gesture**.
- Attribution stays **word-precise** even though the decision unit is the block.
### 1.7 Scope (business)
**In scope:** the three features as one sequenced flow — entry-point wiring
(#42); document-edit proposal granularity word→block (#47); bulk-accept over the
current document (#46). Each remains its own implementation increment / PR.
**Out of scope / non-goals:**
- **Selection edits are unchanged** — a selection edit is already a single
proposal; #47 touches only the document path, #42 leaves selection behavior
intact, #46 reuses the same seam.
- **No Explorer context-menu entry** for Ask-Claude (#42 is tab + body only, per
the capture; the review-panel Explorer entry shipped separately as #41).
- **No "reject all"** (#46 is accept-only; a sibling ask if wanted).
- **No cross-document / workspace-wide accept-all** — current document only.
- **No new proposal-granularity *setting*** — block is the chosen unit, not a
toggle.
- **No change to the inline word-level `<ins>`/`<del>` rendering** inside a block
— the reviewer still sees exactly what changed; only the *decision unit* and the
*attribution reconciliation* change.
### 1.8 Assumptions · Constraints · Dependencies
- **F11 already shipped the document-edit path** (`editDocument`,
`askClaude`, `runEditAndPropose`, `diffToHunks`). This is the strongest
constraint: #42 is mostly **menu wiring** (the command exists); #47 **replaces
the diff inside `runEditAndPropose`'s document branch**; #46 **loops the existing
`acceptById`**. The cluster adds almost no new surface.
- **The block model already exists**`splitBlocks` / `splitBlocksWithRanges` /
`diffBlocks` (`BlockOp`: unchanged / added / removed / changed+atomic; fences
atomic per INV-23). #47 **reuses this**, not a second notion of "block."
- **The F4 accept seam is the only mutation path**
`ProposalController.accept``AttributionController.applyAgentEdit`
(`WorkspaceEdit`); the sealed webview posts **intent** only (INV-8/21/35).
- **No new persisted artifact** (INV-20) — proposals live in the F4 sidecar; this
cluster adds at most one transient field on the proposal (granularity marker —
§6.3) and transient webview messages.
- VS Code context menus render a command's own `title`; the tab menu picks one of
two commands by a `when` clause on selection state (no per-menu relabel of one
command).
### 1.9 Business Use Cases
- **BUC-1 (reach)** — A writer right-clicks the document (body, no selection) or
the tab and asks Claude to edit the whole document.
- **BUC-2 (review)** — The writer reads Claude's pass as block-sized proposals
and accepts/rejects each.
- **BUC-3 (accept)** — The writer, happy with the pass, accepts all pending
proposals in one gesture.
---
## 2. Solution Proposal
**One flow, three increments, all on existing seams.**
```mermaid
flowchart LR
subgraph reach["#42 — REACH"]
body["right-click body / tab\n(selection-aware)"] --> cmd["editSelection /\neditDocument"]
end
subgraph review["#47 — REVIEW"]
cmd --> rep["runEditAndPropose\n(document branch)"]
rep -- "diffToBlockHunks\n(per changed block)" --> props["F4 proposals\n(one per block)"]
end
subgraph accept["#46 — ACCEPT"]
props --> one["✓/✗ per block"]
props --> all["Accept all\n(toolbar intent)"]
end
one --> seam["F4 acceptById →\napplyAgentEdit"]
all --> seam
seam -- "block proposals: word-precise\nintra-block attribution (INV-40)" --> doc[(document)]
```
The defining idea of the cluster is in **#47's accept**: a document edit's
proposals are cut at **block** granularity (the unit a human reviews), but each
accept reconciles attribution at **word** granularity (the unit F3 records). The
block is the **decision** unit; the word is the **attribution** unit. This is what
lets us coarsen the review without coarsening the attribution — and it reuses the
*exact* word-level machinery (`diffToHunks`) that INV-37 used, now repurposed as
the intra-block sub-diff at accept time rather than the proposal cut.
---
## 3. Product Personas
- **PP-1 (the coauthoring writer)** — edits a markdown document with Claude;
wants to invoke a whole-document pass quickly, review it at paragraph altitude,
and take it wholesale or selectively.
---
## 4. Product Use Cases
| PUC | As PP-1 I… | Feature | Acceptance |
| --- | --- | --- | --- |
| PUC-1 | right-click the body with **no selection****Ask Claude to Edit Document** | #42 | whole-document edit → block proposals in the preview |
| PUC-2 | right-click the body **with a selection****Ask Claude to Edit Selection** (unchanged) | #42 | one proposal over the selection (existing behavior) |
| PUC-3 | right-click the **tab** → Edit Selection (if selection) / Edit Document (if not) | #42 | same behavior as the body equivalents, against that doc |
| PUC-4 | see Claude's document edit as **one proposal per changed block** | #47 | M changed blocks → M proposals; unchanged blocks → none; fences atomic |
| PUC-5 | accept a block proposal and have **only Claude's actual changes** attributed to Claude | #47 | unchanged words inside the accepted block keep their prior author (INV-40) |
| PUC-6 | press **Accept all** to take every pending proposal at once | #46 | all anchored proposals applied; orphans skipped + reported |
---
## 5. UX Layout
- **#42 entry points.** Two existing commands, surfaced in two more menus:
- **Editor body** (`editor/context`): **Ask Claude to Edit Selection** when
`editorHasSelection`, **Ask Claude to Edit Document** when not.
- **Editor tab** (`editor/title/context`): the same pair, gated the same way,
targeting the tab's document.
- Both entries appear **only for markdown / authorable** docs (mirroring
`editSelection`'s `when`: `resourceScheme == file || untitled` +
`editorLangId == markdown`).
- **#47 review.** The preview is unchanged in *look* — the reviewer still sees
word-level `<ins>`/`<del>` inside each block. What changes: each **changed
block** is **one `cw-proposal` block** carrying one `data-proposal-id` and one
✓/✗ pair, instead of several. A changed code/mermaid fence is one atomic
proposal (INV-23).
- **#46 accept-all.** An **Accept all** button in the F11 preview **toolbar**,
shown when the document has **≥ 2 pending proposals**. Pressing it posts the
`acceptAll` intent; on completion the host reports applied-vs-skipped (status
message / toast). No confirmation dialog — VS Code undo restores (consistent
with single accept).
```
┌─ Review: my-doc.md ──────────────────────────────┐
│ [ Pin baseline ] [ Ask Claude ▾ ] [ Accept all ]│ ← Accept all visible only when ≥2 pending
├───────────────────────────────────────────────────┤
│ ## Heading │
│ ┌───────────────────────────── proposal ───────┐ │
│ │ The ~~quick brown~~ swift fox … ✓ ✗ │ │ ← one block, word-level ins/del inside
│ └───────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────┘
```
---
## 6. Technical Design
### 6.1 Invariants
Parent invariants **INV-1..INV-37 carry over except where #47 supersedes
INV-37**. This cluster adds:
- **INV-38 (consistent, selection-aware Ask-Claude entry points)** — "Ask Claude
to Edit" is reachable from the editor **body** and the editor **tab**,
**selection-scoped when there is a selection** (`cowriting.editSelection`),
**whole-document when there is not** (`cowriting.editDocument`). Both menus are
gated to **markdown / authorable** docs. Both scopes route through the **single**
`runEditAndPropose` path — no divergent edit code (#42).
- **INV-39 (document edits propose per changed block — supersedes INV-37)** — a
whole-document rewrite is cut into **one F4 proposal per changed block**, where a
block is the unit `splitBlocks` already recognizes (prose paragraph / header /
bullet; code & mermaid fences **atomic**, INV-23). **Unchanged blocks produce no
proposal.** A block proposal's anchor spans the **whole** source block and its
replacement is Claude's **whole** version of that block. This **replaces**
INV-37's per-hunk word-level granularity **for document edits** (selection edits
are unaffected) (#47).
- **INV-40 (block decision, word-precise attribution)** — accepting a block
proposal applies Claude's whole block but attributes **only the runs Claude
actually changed** to Claude; **unchanged spans within the block keep their
prior authorship**. Mechanism: an intra-block **word** sub-diff at accept time
(reusing `diffToHunks`) drives one `applyAgentEdit` per changed run. The **block
is the decision unit; the word is the attribution unit** (#47).
- **INV-41 (block-insertion anchoring)** — a newly **inserted** block (one Claude
added) is anchored to an adjacent block boundary so its proposal **resolves and
is acceptable** — the block-level analogue of `anchorInsertion`; **no
unresolvable zero-width block proposal** (#47).
- **INV-42 (accept-all = batched F4 seam; current doc; orphan-skip)**
`cowriting.acceptAllProposals` applies **every pending proposal on the current
document** through the existing `acceptById` seam, in a **re-anchor-safe order**,
**skipping (never force-applying)** proposals that can't anchor, and **reports
applied-vs-skipped**. It is a **batched application of the existing accept path**,
not a new mechanism; the webview button posts **intent** (INV-35). Per-proposal
✓/✗ is unchanged (#46).
**Supersession (explicit):** **INV-39 reverses INV-37** (document rewrite → per
*hunk*/word) in favour of per *block*. INV-37's word-level engine (`diffToHunks`)
is **not removed** — it is **repurposed** as the intra-block sub-diff that
realizes INV-40's word-precise attribution. The F11 spec carrying INV-37 is
un-graduated (§9); this spec is the canonical home of the superseding decision.
### 6.2 High-level architecture
All three increments are localized to four already-existing files; no new module
is required (one new pure function + one new command + menu JSON).
```mermaid
flowchart TD
pkg["package.json\n(menus: editor/context + editor/title/context)"] -. "#42 wiring" .-> cmds
cmds["editSelection / editDocument\n(existing commands)"] --> rep["runEditAndPropose\n(trackChangesPreview.ts)"]
rep -- "selection branch (unchanged)" --> single["one single-range proposal"]
rep -- "document branch (#47)" --> dbh["diffToBlockHunks\n(trackChangesModel.ts — NEW, pure)"]
dbh --> blockprops["one proposal per changed block\n(granularity: 'block')"]
single & blockprops --> pc["ProposalController\n(proposalController.ts)"]
pc -- "accept (block) → intra-block sub-diff (#47, INV-40)" --> attr["AttributionController.applyAgentEdit\n(per changed run)"]
pc -- "acceptAll (#46, INV-42)" --> pc
tb["preview toolbar\nAccept all button"] -- "{type:'acceptAll'} intent (INV-35)" --> pc
```
- **`package.json`** (#42) — add `editSelection` + `editDocument` to
`editor/context` and `editor/title/context`, each with a selection-state `when`
clause; titles "Ask Claude to Edit Selection" / "Ask Claude to Edit Document".
- **`trackChangesModel.ts`** (#47, pure / vscode-free) — add
**`diffToBlockHunks(currentText, rewrittenText): EditHunk[]`**: split both sides
into the existing block units, diff blocks (reusing `diffBlocks`/`diffArrays`
keying), and emit **one `EditHunk` per changed-or-added block** spanning the
whole block's source range → its rewrite text; fences atomic; inserted blocks
anchored (INV-41). Returns the same `EditHunk[]` shape `diffToHunks` does, so the
proposal-minting loop is unchanged. **`diffToHunks` stays** as the intra-block
sub-diff helper for INV-40.
- **`trackChangesPreview.ts`** (#42/#47) — `runEditAndPropose`'s **document
branch** calls `diffToBlockHunks` instead of `diffToHunks`, and tags each minted
proposal `granularity: "block"`; `editDocument` resolves the **tab's** document
(URI arg) when invoked from `editor/title/context`, not only `activeTextEditor`.
Adds the `acceptAll` inbound-message case (#46) → `proposals.acceptAllProposals`.
- **`proposalController.ts`** (#47/#46):
- `accept` (#47): when `proposal.granularity === "block"`, instead of one
whole-block `applyAgentEdit`, compute the intra-block word sub-diff
(`diffToHunks(resolvedBlockText, proposal.replacement)`) and apply **one
`applyAgentEdit` per changed run** (Claude-attributed), unchanged runs left in
place — one undo-grouped edit. Non-block proposals keep the existing single
`applyAgentEdit`.
- `acceptAllProposals(document)` (#46): gather pending proposals, apply via
`acceptById` in **descending document order** (last anchor first, so earlier
anchors' offsets stay valid), skip orphans (suppressing per-item warnings),
and return `{ applied, skipped }` for the controller to report.
### 6.3 Data model & ownership
**No new persisted artifact** (INV-20). The only model change is **one optional
field** on the F4 `Proposal`:
```ts
// F4 sidecar Proposal — additive, optional, defaulted (back-compat):
interface Proposal {
// …existing fields…
granularity?: "block" | "single"; // "block" ⇒ accept reconciles attribution per word (INV-40)
}
```
- A document-edit proposal is minted with `granularity: "block"`; selection
proposals are `"single"` (or absent → treated as single). A missing field on an
older sidecar reads as `"single"` — existing proposals accept exactly as before.
- New transient webview message (host ⇄ webview), extending the F11 toolbar
intents:
```ts
// webview → host (intent only — INV-35)
type ToolbarMsg =
| /* …existing: setMode | accept | reject | pinBaseline | askClaude… */
| { type: "acceptAll" };
```
Baseline owned by F6; proposals by F4 (sidecar); attribution by F3. This cluster
adds no new ownership.
### 6.4 Interfaces & contracts
- **`trackChangesModel`** (vscode-free): `diffToBlockHunks(currentText: string,
rewrittenText: string): EditHunk[]` — deterministic; one hunk per changed/added
block over the existing block units; fences atomic; inserted blocks anchored
(INV-41). Same `EditHunk` shape as `diffToHunks`. `diffToHunks` is **retained**
(intra-block sub-diff for INV-40).
- **`ProposalController`**: `acceptAllProposals(document): Promise<{ applied:
number; skipped: number }>` — batched accept over the document's pending set via
the existing `acceptById` seam, re-anchor-safe order, orphan-skip (INV-42). The
private `accept` gains the block-aware attribution branch (INV-40) keyed off
`proposal.granularity`.
- **Commands**: `cowriting.editDocument` (existing — extended to resolve a tab
URI arg); new `cowriting.acceptAllProposals` (active doc; reachable outside the
webview).
### 6.5 PerProduct-Use-Case design
- **PUC-1/2/3 (#42)** — pure `package.json` menu wiring + the `editDocument` tab
URI resolution. `runEditAndPropose` is reached unchanged; the only code touch is
resolving the right document for the tab gesture (mirroring #41's clicked-doc
resolution).
- **PUC-4 (#47 cut)**`diffToBlockHunks` replaces `diffToHunks` in the document
branch. For each `BlockOp` that is `changed` or `added`, emit one `EditHunk`:
`changed``[block.start, block.end) → after-block text`; `added` → a zero-width
range anchored to the adjacent block boundary (INV-41) with the new block text +
a separating newline as needed; `removed` → a hunk that deletes the block;
`unchanged` → nothing. Fences (`atomic`) emit one whole-fence hunk (never
word-refined).
- **PUC-5 (#47 attribution, INV-40)** — at accept of a `block` proposal: re-resolve
the anchor → the current block text; `diffToHunks(currentBlockText,
replacement)` → the word-level changed runs *within* the block; apply each run's
replacement via `applyAgentEdit` (Claude-attributed) in one undo-grouped
`WorkspaceEdit`, leaving unchanged runs untouched so their prior attribution
stands. Net buffer text equals the whole-block replacement; net attribution is
word-precise.
- **PUC-6 (#46)**`acceptAllProposals`: snapshot the pending list, sort
descending by resolved anchor start, `acceptById` each (block proposals take the
INV-40 path automatically), tally applied vs. skipped-orphan, report once.
### 6.6 Non-functional requirements & cross-cutting concerns
- **Determinism / testability**`diffToBlockHunks` is pure and vscode-free
(unit-tested like `diffToHunks`/`diffBlocks`). The INV-40 attribution
reconciliation is exercised through the controller with a fake document.
- **Security** — unchanged: the webview stays sealed; **Accept all** posts intent;
the host validates and performs every mutation (INV-8/21/35). No LLM/secret
surface enters the sandbox.
- **Performance** — block diffing is cheaper than the prior word fan-out (fewer
proposals minted/rendered). Accept-all is O(pending) seam calls; large documents
bounded by proposal count, not document size.
- **Reversibility** — accept-all is one undo-restorable step-set; block accept is
one undo-grouped edit. No destructive irreversibility.
### 6.7 Key decisions & alternatives considered
| Decision | Chosen | Alternative (rejected) |
| --- | --- | --- |
| Cluster artifact | **One combined Solution Design**, three increments sequenced #42#47#46 (§3.3 — one design absorbs the roadmap) | Three separate specs — more duplication of the shared F11 edit path + F4 seam |
| #47 block model | **Reuse `splitBlocks`/`diffBlocks`** | Invent a second "block" notion — divergence risk (capture warned against it) |
| #47 attribution of unchanged words in an accepted block | **Preserve unchanged spans** — block decision, word-precise attribution (INV-40), via intra-block `diffToHunks` sub-diff at accept | **Whole block → Claude** — simpler (one seam call) but loses F3 fidelity inside the block; operator chose fidelity |
| #47 vs INV-37 | **Supersede** INV-37 for document edits; **repurpose** `diffToHunks` as the intra-block engine | Keep word-level proposals + a separate "group view" — two models, more surface |
| #46 apply-order | **Descending document order** through `acceptById` (each accept re-resolves the fingerprint, INV-11) | Re-resolve-then-rescan after every accept — more work for the same result |
| #46 confirmation | **None** (undo restores) | A confirm dialog — friction the single-accept path doesn't have |
| #46 scope | **Current document only** | Workspace-wide — capture non-goal |
| #42 surfaces | **Body + tab only**, selection-aware | Add Explorer entry — capture non-goal (#41 covers Explorer for the review panel) |
### 6.8 Testing strategy
- **Unit (vitest, pure)**`diffToBlockHunks`: N words across M blocks → **M**
hunks (one per changed block, spanning the block); unchanged block → none;
changed fence → **one atomic** hunk; inserted block → an anchored, resolvable
hunk (INV-41); each hunk's `[start,end)` lands on real block boundaries in
`currentText`.
- **Unit (controller)** — INV-40: accepting a block proposal whose replacement
changes 2 of 6 words attributes **only those 2 runs** to Claude (the other 4
keep prior author); orphaned block proposal is flagged, not force-applied.
- **Host E2E (Playwright)**
- #42: body no-selection shows **Edit Document**; body selection shows **Edit
Selection**; tab shows the right one per selection state; each produces
proposal(s).
- #47: a document edit changing words across M blocks yields **M proposals**,
each spanning its block; unchanged block → none; changed fence → one atomic
proposal; an inserted block accepts cleanly.
- #46: with N pending proposals, **Accept all** applies all N (text replaced,
Claude-attributed, proposals cleared); an orphaned proposal in the set is
skipped and **reported**, not mangled; the button is hidden with < 2 pending.
- **Manual smoke** — extend `docs/MANUAL-SMOKE-F11.md` (or a new
`MANUAL-SMOKE-F12.md`) covering the three gestures end-to-end.
### 6.9 Failure modes, rollback & flags
- **Orphaned proposal** (target text changed / missing) — single accept already
warns + flags (never applied by guess); accept-all **skips** it and includes it
in the applied-vs-skipped report (INV-42). Reject-or-undo remains the recovery.
- **Inserted block fails to anchor** — INV-41's adjacent-boundary anchoring makes
this resolvable; if the boundary block itself later changes, the proposal orphans
and is handled as above (never silently dropped).
- **Back-compat** — the additive `granularity` field defaults to `"single"`;
pre-existing sidecars/proposals accept unchanged. No migration.
- **No feature flags** — each increment ships green through the pipeline; this is
a VS Code extension (no flotilla deploy stage).
---
## 7. Delivery Plan
### 7.1 Approach / strategy
Ship the flow **reach → review → accept** as three independent
planning-and-executing increments, each its own PR, in sequence. They are
**code-independent** (different files / functions), so the order is chosen for
**user-visible value compounding**, not technical dependency — each increment is
usable on its own.
> **WSJF note (transparent):** **#47 carries the P1 value** ("too much to review"
> is the sharpest pain); #42 and #46 are P2. The operator set the goal "starting
> with #42," and #42 is the smallest, lowest-risk increment (the command already
> exists — it's menu wiring), so leading with it completes the entry-point story
> first. Because the three are code-independent, **#47 and #42 could swap** with no
> rework if the P1 review pain should land first — a one-line resequencing.
### 7.2 Slicing plan
- **SLICE-1 — #42 (reach).** `package.json` menus (`editor/context` +
`editor/title/context`, selection-aware, markdown-gated); `editDocument`
resolves the tab's document; titles. **+ E2E** (body/tab × selection state →
right item → proposal). Smallest; no model change. (INV-38.)
- **SLICE-2 — #47 (review).** `diffToBlockHunks` (pure, + unit tests); document
branch of `runEditAndPropose` switches to it and tags `granularity:"block"`;
block-aware accept in `ProposalController` (intra-block sub-diff, INV-40);
block-insertion anchoring (INV-41). **Supersede INV-37 → INV-39** in the spec/
invariant set. **+ unit + E2E.** The substantive increment. (INV-39/40/41.)
- **SLICE-3 — #46 (accept).** `acceptAllProposals` (controller, re-anchor-safe
order, orphan-skip + report); `cowriting.acceptAllProposals` command; preview
toolbar **Accept all** button + `{type:"acceptAll"}` intent (shown ≥2 pending).
**+ E2E.** (INV-42.)
### 7.3 Rollout / launch plan
No deploy pipeline (VS Code extension — no flotilla / PPE / prod stages; the §9
pipeline's deploy stages don't apply). Each slice merges to `main` green
(unit + host E2E). "Done" = merged with tests green; there is no prod-promotion
gate for an extension increment.
### 7.4 Risks & mitigations
| Risk | Mitigation |
| --- | --- |
| INV-40 intra-block multi-edit accept skews offsets mid-apply | Apply changed runs **last-position-first** within the block, one undo-grouped `WorkspaceEdit`; unit-test the 2-of-6-words case |
| Block taxonomy edge cases (lists w/o blank lines, tables, blockquotes, nested) | **Reuse `splitBlocks` exactly** — whatever it already treats as one block *is* one proposal; document the taxonomy = the splitter's, no second notion |
| Inserted-block anchor collides / can't resolve | INV-41 anchors to an adjacent **unchanged** block boundary; orphan-handling is the backstop (never force-applied) |
| Accept-all on a large pending set partially applies then an orphan appears | Each `acceptById` is independent + re-resolves (INV-11); skipped orphans reported; undo restores the whole step-set |
| F11 spec un-graduated → INV-35/36/37 only in code | This spec restates the load-bearing F11 invariants it depends on; §9 logs the graduation gap as a follow-up |
---
## 8. Traceability matrix
| Requirement | PUC | Invariant / §6 | Slice | Feature |
| --- | --- | --- | --- | --- |
| Ask-Claude reachable body (no-sel) + tab, selection-aware | PUC-1/2/3 | INV-38, §6.5 | SLICE-1 | #42 |
| Selection edit unchanged | PUC-2 | INV-38 (single path) | SLICE-1 | #42 |
| Document edit → one proposal per changed block | PUC-4 | INV-39, §6.5 (`diffToBlockHunks`) | SLICE-2 | #47 |
| Unchanged block → no proposal; fence atomic | PUC-4 | INV-39, INV-23 | SLICE-2 | #47 |
| Accepted block attributes only changed words to Claude | PUC-5 | INV-40, §6.5 | SLICE-2 | #47 |
| Inserted block anchors + accepts | PUC-4 | INV-41 | SLICE-2 | #47 |
| Accept-all over current doc, orphan-skip + report | PUC-6 | INV-42, §6.5 | SLICE-3 | #46 |
| All mutation via the F4 seam; webview intent-only | PUC-4/5/6 | INV-35, §6.6 | SLICE-2/3 | #47/#46 |
---
## 9. Open Questions & Decisions log
**Decided this session (operator):**
- **D-1**#47 accepts **preserve unchanged-span attribution** (block = decision
unit, word = attribution unit; INV-40). *Not* whole-block→Claude.
- **D-2** — the cluster ships as **one** combined Solution Design (this doc),
three sequenced increments.
- **D-3 (driver call, low-confidence — for finalize review)** — sequence **#42
#47#46** per the goal's "starting with #42," despite #47 carrying the P1
value. The three are code-independent so the order is reversible (§7.1).
- **D-4 (driver call)** — block taxonomy = **exactly `splitBlocks`'s** units (no
second notion of "block"); list/table/blockquote/nested behavior is whatever the
splitter already does.
- **D-5 (driver call)** — accept-all needs **no confirmation** (undo restores);
button shown at **≥ 2** pending.
**Open / deferred:**
- **OQ-1** — Should **selection** edits *also* preserve-unchanged-span
attribution for consistency with INV-40? Out of #47's captured scope (selection
edits explicitly unchanged); a future consistency pass could unify them. **Not
in this cluster.**
- **OQ-2 (spec-hygiene, not blocking)** — the **F11 (#43) Solution Design is
un-graduated** — INV-35/36/37 live only in code comments + the issue draft
`issues/preview-toolbar-interaction-surface.md`, not in `specs/`. This spec
restates the F11 invariants it depends on and supersedes INV-37 here, but the
F11 design should be graduated to `specs/` for a clean invariant ledger.
Captured for finalize as a follow-up.
- **OQ-3** — Very large blocks: no special fallback is specified (block proposals
are bounded by the block, and accept reconciles per word). Revisit only if a
pathological single-giant-block document surfaces.
---
## 10. Glossary & References
- **Block** — the unit `splitBlocks` recognizes: a prose paragraph, header, or
bullet/list item; a code or mermaid fence (atomic, INV-23). The #47 decision
unit.
- **Block proposal** — an F4 proposal whose anchor spans a whole block and whose
replacement is Claude's whole version of that block (`granularity: "block"`).
- **Intra-block sub-diff** — the word-level `diffToHunks` run *inside* an accepted
block to realize word-precise attribution (INV-40).
- **`diffToHunks` / `diffToBlockHunks`** — word-level (legacy INV-37; now the
intra-block engine) / block-level (INV-39, new) document-rewrite differs, both
emitting `EditHunk[]`.
- **References**`coauthoring-propose-accept.md` (F4 seam, INV-9..13),
`coauthoring-interactive-review.md` (F10 preview, INV-32..34),
`coauthoring-attribution.md` (F3, `applyAgentEdit`/`spansFor`),
`issues/preview-toolbar-interaction-surface.md` (F11, INV-35..37 — un-graduated),
features `#42` / `#47` / `#46`, capture sessions `0035` / `0040` / `0039`,
brainstorming session `0041`. Lineage `ben.stull/rfc-app#48`.
+18 -1
View File
@@ -9,10 +9,27 @@ status: draft
| **Type** | Throwaway discovery **prototype** (handbook §5.3) — code is disposable |
| **Goal** | Prove the API-dependent design assumptions on the **real** VS Code surfaces, cheaply, **before** the full build — so post-ship iteration is polish, not design |
| **Non-goal** | Production code, real Claude/SDK wiring (except E4), persistence durability, polish, breadth |
| **Status** | `draft` |
| **Status** | `draft`**in progress: E1/E3/E5 done, E2/E4 remain** |
---
## Status (2026-07-01, session 0063)
Three of the five experiments are built and passed in `vscode-cowriting-prototype`:
- **E1 — PASS with pivot.** VS Code does **not** render extension CodeLens inside
a diff editor; per-hunk CodeLens works in a normal editor, and tweak-then-Keep
attributes machine words vs. writer edits correctly. Folded back into the
Solution Design as **D18** (the review surface is the live buffer itself,
in-buffer pending changes — see `coauthoring-native-surfaces.md` v0.2.0).
- **E3 — PASS.** The `markdown-it` contribution renders author-colored marks +
deletions in the built-in preview, theme-aware, with a working toggle.
- **E5 — PASS.** Title-bar + status-bar entry points read as obvious without the
Source Control pane.
- **E2 (baseline router: QuickDiff vs git `HEAD` + snapshot) and E4 (comment →
reply → offer → proposal) are not built yet** — now the riskiest unvalidated
assumptions and the spike's remaining work.
## Why this exists
Static mockups validated the *look* and *state coverage*; they prove **nothing**
+445
View File
@@ -0,0 +1,445 @@
---
status: graduated
---
# Solution Design: Inline editable proposed-change diff in the Markdown editor (#64)
| | |
| --- | --- |
| **Author(s)** | Ben Stull (with Claude) |
| **Reviewers / approvers** | Ben Stull |
| **Status** | `graduated` |
| **Version** | v0.1.1 |
| **Source artifacts** | Feature `benstull/vscode-cowriting-plugin#64` (Inline editable proposed-change diff in the Markdown editor + Accept/Reject controls in both surfaces, `type/feature`, `priority/P2`) · Brainstorming session `vscode-cowriting-plugin-0058` (2026-06-26) · Builds on (all shipped): F4 `#12` (propose/accept seam, INV-9/10/11/13), F6 `#17` (baseline / machine-landing), F7 `#21` (rendered preview, pure render engine INV-22), F10 `#29` (interactive review preview — the **clean-editor** decision INV-32; ✓/✗ accept-reject), F11 `#43` (preview toolbar, `data-src` block→source mapping INV-36), document-edit-flow `#42/#47/#46` (block proposals INV-39/40, accept-all INV-42) · Parent specs (graduated): `coauthoring-propose-accept.md`, `coauthoring-diff-view.md`, `coauthoring-rendered-preview.md`, `coauthoring-interactive-review.md`, `coauthoring-document-edit-flow.md` · Lineage: `ben.stull/rfc-app#48` |
**Change log**
| Date | Version | Change | By |
| --- | --- | --- | --- |
| 2026-06-26 | v0.1.1 | Implementation refinement (planning-and-executing session 0059): made the **re-anchor** explicit. Optimistic apply (§3.2) must store the pre-apply text on a new `Proposal.original` field **and** re-fingerprint the proposal's anchor to the *applied* text — F4's `fp.text` is the *original* target, which leaves the buffer once the replacement lands, so `resolve()` would orphan every applied proposal otherwise. `finalizeInPlace`/`revertInPlace`/decorate all key off the re-anchored fp; revert restores `original`. Reload-safety (INV-51/54): a proposal already carrying `original` is never re-captured (the in-memory applied-set is empty after a window reload), so the revert target survives save+reload. Shipped #64 (PR, session 0059). |
| 2026-06-26 | v0.1.0 | Initial draft — brainstorming session 0058. Four forks locked with the operator: **(1) editor model** = *optimistic apply + decorations* — on propose, the editor buffer becomes the would-be-accepted text (insertions real & editable & tinted; deletions shown as struck-red non-editable hints); accept = finalize-in-place, reject = revert. **(2) timing** = *on proposal* (turn complete), not a live token-stream into the editor (that stays in #60's notification/OutputChannel). **(3) editor affordance** = CodeLens `Accept ▾ / Reject ▾` above each block, `▾` → QuickPick (this / all); the webview keeps HTML buttons with the same dropdown. **(4) controls parity** = Accept / Reject / Accept-all / Reject-all reachable from **both** the editor and the webview. Two sub-decisions confirmed: a **dedicated `EditorProposalController`** owns optimistic-apply + decorations + CodeLens (keeps `ProposalController` the pure F4 state/seam owner); **saving while pending persists** the proposed (accepted-result) text. Reverses INV-32 and INV-10; supersedes the ✓/✗ glyph controls. | Ben Stull + Claude |
---
## 1. Business Context
### 1.1 Executive Summary
Today, when Claude proposes an edit, the change is shown **only in the rendered
review webview** (`<del>old</del><ins>new</ins>` with ✓/✗ controls); the Markdown
**editor stays deliberately clean** (F10 / INV-32 — "the preview is the single
review surface"). The writer who lives in the editor sees nothing: to review what
Claude proposed they must open the preview panel, and they cannot *edit* the
proposed text in place — they can only accept or reject it wholesale in the
webview.
This design brings the proposed change **into the Markdown editor itself**:
editable, with a track-changes diff that matches the webview and the post-accept
result **exactly**. It also replaces the per-proposal `✓`/`✗` glyphs with labelled
**Accept** / **Reject** controls — each carrying a `▾` dropdown for **Accept all**
/ **Reject all** — and makes all four actions reachable from **both** the editor
and the webview. The writer can now read, tweak, and resolve a proposal without
leaving the document.
### 1.2 Background
The plugin's review model (graduated specs `coauthoring-propose-accept.md`,
`coauthoring-rendered-preview.md`, `coauthoring-interactive-review.md`):
- A machine edit becomes an **F4 proposal** — a pending-only record
(`Proposal{ id, anchorId, replacement, granularity }` + a `Fingerprint` anchor
whose `text` is the exact target) that **never mutates the document** (INV-10),
re-resolved against the live text at accept (INV-11) and cleared when
accepted/rejected (INV-13).
- The **render engine** (`trackChangesModel.ts`) is a pure, deterministic,
`vscode`-free unit (INV-22): it diffs at **block** granularity (INV-39), word-
merges prose blocks, and emits webview HTML — `renderReview` (annotated) /
`renderPlain` (clean) — with proposal blocks (`proposalBlockHtml`) carrying the
✓/✗ buttons and `data-src` offsets for selection mapping (INV-36).
- The **F10 decision (INV-32)** removed *all* in-editor decorations
(`grep` confirms zero `TextEditorDecorationType` / `setDecorations` in the tree)
so the webview is the sole review surface; `ProposalController` carries the note
*"no in-editor UI — INV-32 makes the rendered preview the single review surface."*
- The sealed webview is **intent-only** (INV-35): it posts `{accept|reject|
acceptAll|…}` to the host, which routes to `ProposalController.acceptById` /
`rejectById` / `acceptAllProposals` (#46 / INV-42). Accept **applies** the
replacement via the F4 seam (`applyAgentEdit`), which advances the F6 baseline
(machine-landing, INV-18).
This feature **deliberately reverses two of those decisions** for the editor
surface, and supersedes the ✓/✗ glyph controls in both surfaces.
### 1.3 Who feels it & why
The **human coauthor working in the editor** — the writer who asked Claude to edit
and wants to see, adjust, and resolve the result without context-switching to the
preview panel. The pain: the proposed change is invisible in the place they are
actually writing, and it is not editable at all (only accept/reject-able). P2
(issue #64): it materially deepens the inner-loop "edit-in-place" experience and
unifies the review controls, but the webview review path already works, so it is
an enhancement rather than a gap.
---
## 2. Product Design
### 2.1 The experience
When Claude's turn lands a proposal (or, for a document edit, N block proposals),
the **editor** shows the change as track-changes, in place:
```
Accept ▾ Reject ▾ ← CodeLens, above each proposed block
The quick ~~brown~~ red fox jumps over the lazy dog.
└ struck red ┘└ green/blue, editable ┘
```
- **Insertions** are **real, editable buffer text**, tinted by origin
(green = human-origin, blue = LLM) — the writer can click in and edit them like
any other text.
- **Deletions** appear as a **struck-red, non-editable hint** rendered adjacent to
the insertion (a decoration, not buffer text) — visually mirroring the webview's
`<del>…</del>`.
- The buffer content **is exactly the would-be-accepted result**, so what the
writer sees (and can edit) is precisely what accepting produces.
Above each proposed block sit two **CodeLens** actions — **`Accept ▾`** and
**`Reject ▾`**. Clicking opens a small QuickPick:
```
Accept ▾ → ┌───────────────────────┐ Reject ▾ → ┌───────────────────────┐
│ Accept this proposal │ │ Reject this proposal │
│ Accept ALL proposals │ │ Reject ALL proposals │
└───────────────────────┘ └───────────────────────┘
```
The **webview** shows the same proposal with the same diff, and its controls are
the parallel HTML buttons — **`Accept ▾`** / **`Reject ▾`** — where the `▾`
reveals *Accept all* / *Reject all*. The legacy ✓/✗ glyphs are **replaced** by
these labelled controls in both surfaces.
All four actions — **Accept**, **Reject**, **Accept all**, **Reject all** — are
reachable from **either** surface and route to the same controller logic
(INV-53).
### 2.2 Accept / Reject semantics (editable-in-place)
- **Accept** finalizes the change *that is already in the buffer*: it records the
span's attribution as landed, advances the F6 baseline (machine-landing), and
clears the proposal. It does **not** re-apply text (the text is already there) —
see §3.3.
- **Reject** reverts that block's region back to the stored original
(`Fingerprint.text`), and clears the proposal.
- **If the writer edited the inserted text before deciding:** **Accept** keeps
their edited text (their keystrokes layer on as human authorship over the
proposed span); **Reject** still reverts the whole block to the original.
- **Accept all / Reject all** operate on the current document's pending proposals,
in descending anchor order, skipping orphans (the #46 / INV-42 shape; **Reject
all is new**).
### 2.3 Save semantics
Because the proposed text is *in the buffer*, **saving while a proposal is pending
persists the proposed (accepted-result) text** to disk. The deletion hints are
decorations (never buffer text), so the **saved file is clean** — it contains the
accepted-result text, not yet "finalized" only in the sense of attribution /
baseline. "Pending" therefore means *provisional attribution, baseline not yet
advanced* — **not** "document unchanged" (INV-54). We deliberately **allow** the
save rather than block it or strip on save; once the editor shows the live
document, blocking saves would be the surprising behavior.
### 2.4 What this is *not* (non-goals)
- **Not live token-streaming into the editor.** The diff appears when the turn
produces a proposal; watching Claude type into the document is out of scope (the
live token stream is #60's notification + OutputChannel).
- **Not a new review *model*.** F4 proposals, anchoring (INV-11), block
granularity (INV-39/40), accept-all (INV-42) are reused; this adds an editor
*surface* and unifies the controls.
- **Not non-Markdown.** Same Markdown-gated scope as the rest of the review UI.
- **Not intra-diagram mermaid editing.** Atomic fences stay atomic (INV-23); a
mermaid block proposal is editable as its whole fence, not sub-diagram.
- **Not removing the webview.** The rendered preview remains a full review surface;
it gains label/dropdown parity, not a demotion.
### 2.5 Surfaces considered & rejected
| Editor approach | Why not |
| --- | --- |
| **Read-only decoration overlay** (doc unchanged; ghost insertions via decoration) | The inserted text would **not be editable** — fails the core "human-editable" requirement. |
| **Inline track-changes markup in the buffer** (`{~~old~~|++new++}`) | Pollutes the file on disk with markup until resolved; complex; conflicts with clean save. |
| **Native `vscode.diff` two-pane** | The two-pane diff was *removed* in #34 (F10) precisely to make one review surface; a separate diff editor is not "in the Markdown file." |
Chosen: **optimistic apply + decorations** — the only model that yields an
editable diff that is *exactly* the accepted result, in the document itself.
---
## 3. Engineering Design
### 3.1 Architecture
```
┌──────────────────────────────────────────────┐
one F4 turn ───▶ │ ProposalController (F4 state/seam owner) │
(#12 propose) │ propose() · finalizeInPlace() · revertInPlace│
│ · rejectAll() · onDidChangeProposals │
└───────────────┬──────────────────────────────┘
│ proposals (pending records + anchors)
┌─────────────────────┴───────────────────────┐
▼ ▼
┌───────────────────────────┐ ┌────────────────────────────┐
│ EditorProposalController │ shared │ TrackChangesPreview- │
│ (NEW, vscode host) │ pure diff │ Controller (webview) │
│ · optimistic buffer write │◀──────────────▶ │ · renderReview HTML │
│ · insertion tint deco │ trackChangesModel│ · Accept▾/Reject▾ buttons │
│ · deletion-hint deco │ (diff → plan) │ · dropdown → acceptAll/ │
│ · CodeLens Accept▾/Reject▾│ │ rejectAll │
└───────────────────────────┘ └────────────────────────────┘
▲ ▲
└──────────── both route actions to ───────────┘
ProposalController.{finalizeInPlace,revertInPlace,
acceptAll,rejectAll}
```
The decisive rule: **one pure diff is the single source of truth** for both
surfaces (INV-49). The editor renders it as *buffer text + decorations + CodeLens*;
the webview renders it as *HTML*. They cannot diverge because they consume the
same hunks.
### 3.2 Optimistic apply (on propose) — INV-48
`propose()` gains a buffer-write step, owned by `EditorProposalController` (so
`ProposalController` stays the pure state owner):
1. Record the pending proposal(s) + anchor(s) **exactly as today** (the sidecar
record is unchanged: `replacement`, `Fingerprint{text,before,after,lineHint}`,
`granularity`).
2. Write the proposed text into the **live editor buffer**: replace the resolved
anchor span with `replacement` (for a document turn, this makes the buffer
Claude's full proposed document; for a selection turn, the single span). This
write **does not advance the F6 baseline** — the baseline stays at the
pre-proposal text, so the change reads as *pending*, not *landed*.
3. Compute the **decoration plan** from the same per-block word/line diff used by
`acceptBlock` (`wordEditHunks(fp.text, replacement)`): insertion segments →
tinted ranges (buffer coords, offset by the live anchor); deletion segments →
struck-red hint injections (decoration `before`/`after` content) at the segment
boundary.
Critically, the optimistic write must **not** fire the F4 seam's
`onDidApplyAgentEdit` (which would advance the baseline / register a landed edit).
Optimistic apply is a *distinct* buffer-mutation path from accept; only **accept**
advances the baseline (§3.3). This is the load-bearing distinction that keeps the
change *pending* while *present*.
### 3.3 Accept = finalize-in-place; Reject = revert-in-place — INV-51
Because the proposed text is already in the buffer, the old "accept ⇒ apply via
seam" path would **double-apply**. The accept/reject paths are reworked:
- **`finalizeInPlace(id)`** — resolve the proposal's anchor against the live text;
record the span's attribution as **landed** (provenance from the proposal's
`author`, word-precise per INV-40 for block granularity); **advance the F6
baseline** for that region (the machine-landing the seam used to do on apply);
`removeProposal(id)`. No text is re-applied. If the writer edited the inserted
span, the *current* buffer text is what gets finalized (their edits become
layered human authorship).
- **`revertInPlace(id)`** — resolve the proposal's live span; replace it with the
stored original `Fingerprint.text`; `removeProposal(id)`. Reverts the whole
block regardless of any in-place edits.
- **`acceptAll()` / `rejectAll()`** — iterate the current document's pending
proposals in **descending** anchor order (so earlier resolutions don't shift
later offsets), skipping orphans with a tally (INV-42 shape). `acceptAll` reuses
the #46 batch ordering; **`rejectAll` is new** and symmetric.
Both surfaces (editor CodeLens/QuickPick and webview buttons/dropdown) call these
**same four methods** (INV-53). The webview's existing intent messages are renamed/
extended (`accept`/`reject`/`acceptAll` + new `rejectAll`) but stay intent-only
(INV-35).
### 3.4 Single diff source & render-once — INV-49 / INV-50
- **INV-49 (single diff source).** The per-proposal diff (block hunks → word/line
edit hunks) is produced by one pure function in `trackChangesModel.ts` (extends
the existing `diffToBlockHunks` / `wordEditHunks`, INV-39/40). The webview HTML
path and the editor decoration-plan path both consume it. Same proposal ⇒
identical diff in both surfaces (this is the testable parity guarantee).
- **INV-50 (render once).** With optimistic apply, the baseline→buffer delta now
*contains* the proposed change. The renderer must attribute any baseline→buffer
delta that is covered by a **pending proposal** to that proposal (rendered as a
proposal block), and must **not** also render it as an already-landed
machine-diff. Mechanism: the webview render reconciles baseline-diff spans
against pending-proposal anchors; a span covered by a pending proposal is
rendered exactly once, as the proposal. *(This reconciliation is the primary
implementation risk; the implementation plan owns the exact span-mapping, but the
invariant — rendered once — is fixed here.)*
### 3.5 Editor decorations & CodeLens (the editor surface)
`EditorProposalController` (new, `vscode` host module) owns:
- **Two `TextEditorDecorationType`s** — an *insertion tint* (green/blue background,
by provenance) over real buffer ranges, and a *deletion hint* (a
`before`/`after` render-option carrying the struck original text, red,
strikethrough, non-editable). Both are recomputed from the decoration plan on
every `onDidChangeProposals` and on active-editor change.
- **A `CodeLensProvider`** — for each pending proposal whose anchor resolves in the
active document, emit two lenses positioned above the block: `Accept ▾`
(command `cowriting.proposalAcceptMenu` with the proposal id) and `Reject ▾`
(`cowriting.proposalRejectMenu`). Each command opens a `vscode.window
.showQuickPick(["… this proposal","… ALL proposals"])` and dispatches to
`finalizeInPlace`/`acceptAll` or `revertInPlace`/`rejectAll`.
- **Lifecycle wiring** — subscribes to `proposals.onDidChangeProposals` (the same
event the preview uses) and `window.onDidChangeActiveTextEditor`; clears its
decorations + lenses for documents with no pending proposals (so a clean editor
stays clean — INV-32's spirit holds *when there is nothing pending*).
Deletion hints are **decoration-only** (never buffer text, never saved) and
insertion tints decorate **real** buffer text (INV-52).
### 3.6 Webview controls (label + dropdown parity)
`proposalBlockHtml` (`trackChangesModel.ts`) swaps the ✓/✗ glyph buttons for an
**`Accept ▾`** / **`Reject ▾`** control pair. The `▾` opens a small CSS/JS dropdown
in the sealed webview (`preview.ts`/`preview.css`, no network — INV-21) offering
*Accept all* / *Reject all*; selecting posts the corresponding intent
(`acceptAll` / `rejectAll`) or the per-proposal `accept`/`reject`. The host routes
all four to the controller methods of §3.3 (INV-35 preserved).
### 3.7 Components & files
| File | Change |
| --- | --- |
| `src/editorProposalController.ts` | **NEW** — optimistic apply, decoration types, deletion-hint injection, `CodeLensProvider`, QuickPick menus; subscribes to `onDidChangeProposals` + active-editor change. |
| `src/proposalController.ts` | Add `finalizeInPlace(id)`, `revertInPlace(id)`, `rejectAll(docKey)`; `propose()` delegates the optimistic buffer write (so the controller stays state-pure); accept/reject routing now finalizes/reverts in place (no seam re-apply). |
| `src/trackChangesModel.ts` | Factor the shared per-proposal diff into one pure producer feeding both surfaces; reconcile pending-proposal spans in `renderReview` (INV-50); swap ✓/✗ → `Accept ▾`/`Reject ▾` in `proposalBlockHtml`. |
| `src/trackChangesPreview.ts` | Route new `rejectAll` intent; keep `accept`/`reject`/`acceptAll`; relabel toolbar/proposal controls. |
| `src/preview.ts` / `preview.css` | Dropdown control for `Accept ▾`/`Reject ▾`; same diff CSS reused. |
| `src/extension.ts` | Register `EditorProposalController`, its CodeLens provider, and the menu commands; wire it alongside `ProposalController`/`TrackChangesPreviewController`. |
| `package.json` | Add `cowriting.rejectAllProposals`, `cowriting.proposalAcceptMenu`, `cowriting.proposalRejectMenu`; CodeLens contribution; markdown-gated `when` clauses. |
### 3.8 Invariants
- **INV-48 — Optimistic apply (with re-anchor).** On propose, the proposed text is
written into the live editor buffer (the buffer becomes the would-be-accepted
result); this write does **not** advance the F6 baseline and does **not** fire the
F4 machine-landing seam. Because F4's `fp.text` is the *original* target — which
leaves the buffer once the replacement lands — optimistic apply **stores the
pre-apply text on `Proposal.original` and re-fingerprints the anchor to the applied
text**, so `resolve()` keeps finding the proposal in the mutated buffer; finalize /
revert / decorate all key off the re-anchored fp, and revert restores `original`.
`original` is captured **exactly once** (first apply): a proposal that survives a
save+reload already carries it, so a fresh session never re-captures it from the
already-applied buffer (reload-safety, INV-51/54). **Reverses INV-10** (propose no
longer leaves the document untouched) and **INV-32** (the editor is no longer kept
unconditionally clean — it shows pending proposals).
- **INV-49 — Single diff source.** Editor decorations and webview HTML both derive
from one pure, deterministic per-proposal diff (extends `diffToBlockHunks` /
`wordEditHunks`, INV-22/39/40). The same proposal yields the same diff in both
surfaces.
- **INV-50 — Rendered once.** A span covered by a pending proposal is rendered
exactly once — as that proposal — never additionally as an already-landed
baseline diff.
- **INV-51 — Finalize / revert in place.** Accept finalizes the span already in the
buffer (record attribution, advance baseline, clear proposal) without
re-applying text; Reject reverts the block region to the stored
`Fingerprint.text`. No double-apply.
- **INV-52 — Decoration roles.** Deletion hints are decoration-only (non-editable
`before`/`after` content, never buffer text, never saved); insertion tints
decorate real, editable buffer text.
- **INV-53 — Control parity.** Accept / Reject / Accept-all / Reject-all are
reachable from **both** the editor (CodeLens + QuickPick) and the webview
(buttons + dropdown) and route to the same `ProposalController` methods; the ✓/✗
glyph controls are superseded by labelled `Accept ▾`/`Reject ▾`.
- **INV-54 — Save persists pending.** Saving while a proposal is pending persists
the proposed (accepted-result) text; "pending" denotes provisional attribution /
un-advanced baseline, not document divergence. (Decorations are not persisted, so
the saved file is clean text.)
### 3.9 Error & edge handling
- **Orphaned proposal** (anchor no longer resolves) — no editor decorations/lenses
for it; it still surfaces in the webview at end with a dashed border (INV-34);
accept-all/reject-all skip it with a tally (INV-42).
- **Writer edits the inserted span, then accepts** — current buffer text is
finalized (their edits layer as human authorship); **then rejects** — block
reverts to original regardless.
- **External / concurrent edit shifts geometry** — anchors re-resolve on
`onDidChangeProposals` (existing resolve-or-flag); decoration plan + lenses
recompute.
- **Multiple proposals from one turn** — all optimistically applied (buffer = full
proposed document); each block independently finalizable/revertible; descending
order on accept-all/reject-all keeps offsets valid.
- **Reject of an in-buffer span after partial accept of siblings** — each
proposal's revert uses its own live-resolved span, independent of siblings.
- **Non-authorable document** (read-only / not on disk) — controls disabled exactly
as today (`authorable` gating).
---
## 4. Testing & E2E
Per the §9 pipeline and `coauthoring-*` precedent (this is a VS Code extension —
**no flotilla/PPE**; the gate is unit + host-E2E green).
- **Unit (the core):**
- The shared per-proposal diff producer: same hunks → insertion/deletion
segments; parity assertion that the decoration plan and the webview HTML derive
identical add/del spans for the same proposal (INV-49).
- `renderReview` render-once reconciliation: a pending-proposal span is emitted
as a proposal block and **not** as a landed baseline diff (INV-50).
- Decoration-plan computation from `wordEditHunks(fp.text, replacement)`:
insertion ranges in buffer coords; deletion-hint positions (INV-52).
- **Host E2E:**
- Propose (via the injectable `editTurn` stub) → editor shows insertion tint +
deletion hint + `Accept ▾`/`Reject ▾` CodeLens; buffer equals the accepted
result (INV-48).
- Edit the inserted text → **finalizeInPlace** keeps the edited text; baseline
advanced; proposal cleared (INV-51).
- **revertInPlace** restores `Fingerprint.text`; proposal cleared.
- Accept / Reject / Accept-all / Reject-all from **both** the editor command path
and the webview intent path produce the same end state (INV-53); `rejectAll`
clears all pending and reverts all blocks.
- Save while a proposal is pending → file on disk has the proposed text; no
decoration markup in the saved bytes (INV-54).
- Webview/editor parity: same proposal renders the same diff in both (INV-49).
- **Manual smoke:** ask Claude to edit a paragraph; confirm the editor diff is
editable, the deletion hint reads correctly, both surfaces' controls resolve it,
and a clean editor returns once nothing is pending.
---
## 5. Delivery Plan (rollout — not a task list)
One increment, single design-then-build (#64). Ships through branch → PR → `main`;
no migration and no new persisted sidecar shape (the `Proposal` record is
unchanged). The implementation plan (downstream `wgl-planning-and-executing`
session) owns the Task breakdown; a natural cut:
1. **Shared diff + render-once** — factor the pure per-proposal diff producer;
reconcile pending spans in `renderReview` (INV-49/50) + unit tests.
2. **Accept/reject rework**`finalizeInPlace` / `revertInPlace` / `rejectAll` on
`ProposalController` (no seam re-apply); unit tests.
3. **Editor surface**`EditorProposalController`: optimistic apply, decorations,
deletion hints, CodeLens + QuickPick menus; wire in `extension.ts`.
4. **Control parity** — relabel ✓/✗ → `Accept ▾`/`Reject ▾` + dropdown in the
webview; route `rejectAll`; `package.json` commands/`when`.
5. **Host E2E + manual smoke** across both surfaces.
Reversible by reverting the PR (restores the clean-editor / ✓-✗ webview behavior).
---
## 6. Open Questions
- **OQ-1 — Provenance tint exactness.** Insertion tint uses green=human-origin /
blue=LLM by the proposal `author`; whether to additionally word-tint *within* a
block by the F3 attribution of edited-in-place text (vs a single block tint) can
follow the existing F9/F10 coloring; v1 tints the proposed insertion by the
proposal author. *(Not blocking.)*
- **OQ-2 (inherited)** — The F11 design (`#43`) remains un-graduated (lives in code
+ an issue draft); unrelated to #64 but noted in the shared lineage. Track
separately.
- **OQ-3 — Editor dropdown fidelity.** The editor uses a QuickPick to stand in for
the webview's true `▾` dropdown (CodeLens cannot render a caret menu inline); if
a more button-like affordance is wanted later, a webview-style overlay is a
possible increment. *(Accepted for v1.)*
+609
View File
@@ -0,0 +1,609 @@
---
status: graduated
---
# Solution Design: Interactive Track-Changes Review in the Markdown Preview (F10)
| | |
| --- | --- |
| **Author(s)** | Ben Stull (with Claude) |
| **Reviewers / approvers** | Ben Stull |
| **Status** | `draft` |
| **Version** | v0.1.0 |
| **Source artifacts** | Feature `benstull/vscode-cowriting-plugin#29` (F10, `type/feature`, `priority/P1`) · Epic `#1` (closed) · Capture session `vscode-cowriting-plugin-0028` (2026-06-11) · Brainstorming session `vscode-cowriting-plugin-0029` · Builds on (all shipped): F3 `#6` (live attribution), F4 `#12` (propose/accept), F6 `#17`/`#19` (baseline + diff view), F7 `#21`/`#22` (rendered preview + intra-diagram mermaid), F9 `#27` (authorship preview) · Parent specs (graduated): `coauthoring-inner-loop.md`, `coauthoring-attribution.md`, `coauthoring-propose-accept.md`, `coauthoring-diff-view.md`, `coauthoring-rendered-preview.md`, `2026-06-11-authorship-preview-design.md` · Lineage: `ben.stull/rfc-app#48` |
**Change log**
| Date | Version | Change | By |
| --- | --- | --- | --- |
| 2026-06-11 | v0.1.0 | Initial draft — brainstorming session 0029 (from the capture in session 0028, which followed the session-0027 ideation where the operator found the F6 two-pane diff + F3 attribution overlap confusing). | Ben Stull + Claude |
---
## 1. Business Context
### 1.1 Executive Summary
The plugin shipped its review signals as **several overlapping surfaces that
collide on the same text**: F6's two-pane red/green raw diff (`ctrl+alt+d`), F3's
in-editor attribution decorations (green human border / blue Claude fill), F4's
in-editor amber proposal threads, and F7/F9's rendered preview (two modes). In
manual testing the operator opened the F6 diff and saw F3's blue attribution
bleeding into it at the same time — *"I don't understand this."*
F10 resolves this into **one mental model: write on the left, review on the
right.** The editor becomes a **clean, zero-annotation** markdown pane. The
**rendered preview becomes the single review surface**, with an **annotations
on/off** toggle. With annotations **on**, the preview paints every change since
the F6 baseline in one visual language — **green = human-authored, blue =
LLM-authored, strikethrough = deleted** — and lets the human **accept (✓) or
reject (✗) Claude's pending proposals, and only those, right inside the
preview.** F10 is **mostly assembly** of shipped features (F3/F4/F6/F7/F9) plus
three new pieces: strip the editor decorations, collapse F9's two preview modes
into the one toggle, and make the preview interactive.
### 1.2 Background
The inner loop shipped F2F5 (threads · attribution · propose/accept ·
cross-rung). F6 added the baseline + a native two-pane diff toggle (`#17`),
broadened to any file (`#19`). F7 added the rendered track-changes preview
(`#21`) with full mermaid support, refined by F7.1 intra-diagram mermaid diffing
(`#22`). F9 added an **Authorship** mode to that preview (`#27`), switched by a
segmented `[ Track changes | Authorship ]` header control — and F9 **explicitly
chose two separate modes, never combined** (F9 INV-26).
The friction the operator hit is the *multiplicity* of surfaces, not any one of
them: the two-pane raw diff is low-altitude for prose; the in-editor decorations
clutter the writing surface (the human authored most of the document, so
attribution paints everywhere); and there is no single place to answer "what
changed, who changed it, and do I keep the LLM's edits?" Capture session 0028
filed Feature `#29` locking the product vision (markdown-only; clean editor;
preview as the single review surface). This spec is the Solution Design for it.
### 1.3 Business Actors / Roles
- **Coauthor (human)** — the writer/engineer (PP-1); F10's sole user.
- **Coauthor (machine)** — Claude via `@cline/sdk`; not a user of F10, but its
proposals are what the human reviews in the preview, and its landings advance
the F6 baseline.
### 1.4 Problem Statement
A markdown writer reviewing a coauthoring session has **no single, intuitive
surface** that answers "what changed since the last coauthoring moment, who
changed it, and do I keep Claude's edits?" The signal is fragmented across a raw
two-pane diff, in-editor decorations, and a two-mode preview that shows changes
**or** authorship but never both, and never lets the human act on Claude's
proposals from inside the rendered view.
### 1.5 Pain Points
- **Surface collision** — F6's raw diff and F3's attribution paint the same text
simultaneously and read as noise ("I don't understand this").
- **Cluttered editor** — F3 attribution + F4 proposal threads decorate the
writing pane; since the human authored most of the document, attribution paints
nearly everywhere.
- **Two-pane raw diff is low-altitude for prose** — you read markdown source, not
the rendered document.
- **Changes XOR authorship** — F9's preview shows *what changed* (track-changes)
or *who authored* (authorship) but never both in one glance, and neither lets
you accept/reject from the rendered view.
### 1.6 Targeted Business Outcomes
One clean model: **write on the left, review on the right.** The editor is
distraction-free (zero annotations). The preview, with annotations on, shows
every change since the baseline in one visual language **and** lets the human
keep or revert Claude's proposals without leaving that pane — so "what did the
LLM change, and do I want it?" is answered **in place**, in the rendered
document, not in a raw diff or a cluttered editor.
### 1.7 Scope (business)
**In scope:** a clean (zero-annotation) markdown editor; the rendered preview as
the **single** review surface; an **annotations on/off** toggle (off = clean
rendered markdown, built-in-preview parity; on = the combined annotated view);
the unified per-author + strikethrough annotation language (green human / blue
LLM / struck deletions, reconstructed from the F6 baseline); **interactive
accept/reject of Claude's pending proposals from inside the preview** (✓/✗);
deprecating F6's two-pane view (command + keybinding hidden) and stripping the
editor's F3/F4 decorations; unit + host-E2E coverage; manual webview smoke.
**Out of scope (deferred, not forgotten):** **non-markdown review** (F6's
any-file reach `#19` is dropped *as a user goal* — see §6.7); the **repo rename**
to `vscode-markdown-cowriting-plugin` (the markdown-only identity is affirmed,
but the rename — Gitea repo, `app.json`, content repo, session-history paths — is
a separate deliberate gesture); preview→source **scroll-sync**; export / print /
copy-as-clean; **editing in the preview** beyond the ✓/✗ proposal controls;
intra-emphasis sentinel hardening (the F9 deferred refinement).
**Non-goals (firm):** any change to the **sidecar** persistence, the **F4
propose/accept model** (proposals stay pending-by-default — F10 surfaces and acts
on them, it does **not** change how Claude's edits enter the system), the
**cross-rung contract**, or `SCHEMA_VERSION`; a general-purpose markdown
previewer; a WYSIWYG editor; combining anything with the LLM/network/credential
surface (INV-8 untouched).
### 1.8 Assumptions · Constraints · Dependencies
- **Anchor:** Feature `#29` (F10). **Mostly assembly** of shipped features:
- **F6 baseline** (`#17`/`#19`) — kept as the **data layer** ("what changed
since when"; auto-advances at every machine landing, INV-18); F6's two-pane
*view* is deprecated as a user surface (command/keybinding hidden, code kept).
- **F7 render/diff engine** (`#21`/`#22`) — the rendered preview, block/word
diff, strikethrough deletions, atomic code/mermaid, and F7.1 intra-diagram
mermaid diff already exist and are reused.
- **F3 attribution** (`#6`) — kept as the **data layer** (`spansFor` — who
authored each span); its *in-editor decorations* are removed.
- **F4 propose/accept** (`#12`) — the accept/reject *logic*
(`ProposalController.accept`/`reject``applyAgentEdit`, the INV-9
`WorkspaceEdit` seam) already exists; F10 moves the *controls* into the
preview and removes the in-editor proposal threads.
- **F9 authorship preview** (`#27`) — its two preview modes **collapse** into
the one annotations on/off toggle; its PUA-sentinel author-coloring technique
is **reused** inside the new combined render; its standalone
`renderAuthorship` / segmented control are **superseded** (removed).
- **Constraint:** the preview is a **sealed webview** (strict CSP, local assets,
no network — F7 INV-21). Interactive ✓/✗ controls post messages to the
extension host, which applies/discards proposals via the F4 path; the webview
**never** edits the document directly.
- **Constraint:** deletions aren't in the live buffer — they are reconstructed by
diffing the buffer against the F6 baseline (the F7 engine already does this).
- No LLM, no network, no new credential surface (INV-8 untouched); nothing
persisted changes (`.threads/`, the contract, `SCHEMA_VERSION` all untouched).
### 1.9 Business Use Cases
- **BUC-1 (unified review)** Mid-session the writer opens the preview and sees the
rendered chapter with their own additions in green, their deletions struck, and
Claude's pending proposals as blue blocks carrying ✓/✗ — one glance answers
"what changed, who, and do I keep Claude's edits?"
- **BUC-2 (act in place)** The writer clicks ✓ on a Claude proposal in the
preview; the proposal lands in the document, the baseline advances, and the
blue block becomes ordinary (unmarked) text — without leaving the preview or
touching the editor.
- **BUC-3 (clean writing)** The writer works in the editor, which shows **no**
coauthoring decorations at all — a plain markdown surface — and flips
annotations **off** in the preview to read the document clean (built-in-preview
parity) when they want to.
---
## 2. Solution Proposal
The editor is stripped of **all** coauthoring decorations (F3 attribution tint,
F4 proposal threads), and F6's two-pane diff command/keybinding are hidden
(`when:false`); the F6 baseline store and `onDidChangeBaseline` event remain as
the data layer.
The preview's per-panel mode collapses from F9's `"changes" | "authorship"` to a
single **`"on" | "off"`** annotations toggle (default **on**):
- **Off** — plain `markdown-it` render of the current buffer, no marks (parity
with VS Code's built-in markdown preview).
- **On** — a **combined annotated render** produced by one new **pure,
vscode-free** engine, `renderReview(baselineText, currentText, authorSpans,
proposals)`, that overlays **two axes in a single pass**:
1. **Changes since the F6 baseline** — the F7 block + word diff of
`baselineText` vs `currentText`: additions and deletions, **author-colored**
via F3 `authorSpans` (human → green, agent → blue) using F9's PUA-sentinel
technique; deletions rendered **struck** (reconstructed from the baseline).
2. **Pending Claude proposals** — each F4 pending proposal rendered **inline at
its resolved anchor** as a **blue block** (the replaced text struck, the
proposed text shown) carrying a `data-proposal-id` and **✓/✗ buttons**.
The webview becomes **interactive**: clicking ✓/✗ posts `{type: "accept" |
"reject", proposalId}` to the host, which calls `ProposalController.accept` /
`reject` — accept runs the existing `applyAgentEdit` `WorkspaceEdit` seam and
advances the baseline (INV-18), reject discards the proposal; either way the host
re-renders. The webview never mutates the document (INV-20/21 hold). Everything
downstream of `(baseline, current, spans, proposals) → HTML` is a pure function
(INV-33), unit-testable with no vscode and no webview.
Because proposals are now **preview-only**, a small **status-bar indicator**
("N Claude proposals — open review", clickable to open the preview) signals
pending proposals when no preview is open (§6.5 PUC-6).
---
## 3. Product Personas
- **PP-1 Inner-loop coauthor** — the human markdown writer/engineer (as F2F9);
the only persona F10 serves.
## 4. Product Use Cases
- **PUC-1 (clean editor)** Opening / editing a markdown document shows a plain
editor with **no** coauthoring decorations (no F3 tint, no F4 threads, no F6
diff). The writing surface is distraction-free.
- **PUC-2 (toggle annotations)** In the preview, a header switch flips
annotations **on** (combined annotated render) / **off** (clean rendered
markdown). Default on. Mode is remembered per panel.
- **PUC-3 (see who changed what)** With annotations on, the writer's additions
since the baseline render **green**, their deletions render **struck**, and
Claude's pending proposals render as **blue** blocks. One visual language.
- **PUC-4 (accept a proposal)** The writer clicks **✓** on a blue proposal → it
lands in the document via the F4 seam → the baseline advances → the block
re-renders as ordinary (unmarked) text. The editor reflects the new text, still
with no decorations.
- **PUC-5 (reject a proposal)** The writer clicks **✗** on a blue proposal → the
proposal is discarded → the block disappears on re-render → the document is
unchanged.
- **PUC-6 (proposal while no preview open)** Claude proposes while no preview is
open → a **status-bar item** ("N Claude proposals — open review") appears;
clicking it opens the preview where the proposals are reviewable. (No forced
auto-open — the writer stays in control of focus.)
- **PUC-7 (graceful edges)** Non-markdown active editor → the open command warns
and opens nothing (markdown-only). A malformed markdown/mermaid block → an
inline error chip for that block, the rest renders. No baseline yet → render
current doc with **no** change-marks (everything is "since opened") + a header
note; proposals (if any) still render with ✓/✗.
## 5. UX Layout
A webview in the editor column **beside** the source (`ViewColumn.Beside`). The
source editor stays fully editable but is now **visually clean** — no coauthoring
decorations. The preview is **read-only rendered output** except for the ✓/✗
proposal controls, which post intent (never edit the doc directly). With
annotations **on**:
- **Human additions** since baseline — soft green background (`cw-by-human` over
`<ins>`), themeable.
- **LLM-authored text** (already landed, attributed by F3) — soft blue
(`cw-by-claude`).
- **Deletions** — struck-through, muted (`<del class="cw-del">`), reconstructed
from the baseline.
- **Pending Claude proposal** — a **blue proposal block**: the text it would
replace shown struck, the proposed text shown, and a compact **✓ / ✗** control
pair in the block's corner (`data-proposal-id` on the block).
- **Atomic blocks** (code / mermaid) — diffed whole (INV-23) with F7.1
intra-diagram mermaid styling for changed flowchart/sequence diagrams; an
overlapping author span yields a block-level author badge (F9 INV-27).
- A compact **header bar**: the annotations **on/off** switch; in the on state, a
`Since <epoch>` label (reusing F6's epoch text) + a `+N M · P proposals`
summary and the legend (● You · ● Claude · ✓/✗ = accept/reject Claude).
Colors derive from the active VS Code theme via webview CSS variables (light /
dark / high-contrast); proposal-block and ✓/✗ button styling are bespoke but
theme-variable-driven.
---
## 6. Technical Design
### 6.1 Invariants
Parent invariants INV-1..INV-31 carry over **except where F10 supersedes them**.
F10 adds:
- **INV-32 (single review surface; clean editor)** The rendered preview is the
**only** annotated review surface. The editor carries **zero** coauthoring
decorations: F3 attribution decorations, F4 in-editor proposal threads, and the
F6 two-pane diff are all removed from / hidden in the editor. (F3 `spansFor`,
F4 accept/reject logic, and the F6 baseline store remain as **data layers**.)
- **INV-33 (combined pure render)** The "on" render unifies two axes —
changes-since-baseline (author-colored additions + struck deletions) **and**
interactive pending proposals — in **one pass** of a pure, vscode-free,
DOM-free function `renderReview(baselineText, currentText, authorSpans,
proposals): string`: same inputs → identical HTML (extends INV-22). The "off"
render is the plain `markdown-it` of the current buffer.
- **INV-34 (✓/✗ act only on pending Claude proposals, via the F4 seam)**
Interactive controls appear **only** on pending Claude proposals (blocks
carrying `data-proposal-id`); human (green) changes carry no controls. ✓/✗ post
**intent** to the host; **all** document mutation goes through the existing F4
`ProposalController.accept`/`reject``applyAgentEdit` (`WorkspaceEdit`) seam
(INV-9). The webview never mutates the document, sidecar, or baseline
(INV-20/21 hold).
**Supersession (explicit):** F10 **reverses F9 INV-26** ("authorship mode is
baseline-independent and never combined with the diff"). F10 *combines* the
authorship axis with the change axis by design; F9's standalone authorship mode
and segmented control are removed. F9 INV-27 (atomic fences get a block-level
author badge) and INV-28 (pure authorship render) are **absorbed** into INV-33
(the combined render keeps fences atomic and stays pure).
### 6.2 High-level architecture
Three reused data layers + one new pure render path + an interactive webview.
```mermaid
flowchart LR
edit["source editor\n(clean — no decorations)"] -- onDidChangeTextDocument (debounced) --> ctl
base["F6 baseline\n(DiffViewController, INV-18)"] -- onDidChangeBaseline --> ctl["trackChangesPreview\n(vscode layer)"]
attr["F3 AttributionController\nspansFor(doc)"] -- author spans --> ctl
prop["F4 ProposalController\npending proposals"] -- onDidChangeProposals --> ctl
ctl -- "(baseline, current, spans, proposals)" --> model["renderReview\n(pure, vscode-free)\nblock+word diff · author sentinels · proposal blocks"]
model -- annotated HTML --> ctl
ctl -- postMessage{html, mode, summary} --> wv["webview\n(sealed CSP, local assets)\nmermaid.run() · ✓/✗ buttons"]
wv -- "postMessage{accept|reject, proposalId}" --> ctl
ctl -- "accept/reject" --> prop
prop -- "applyAgentEdit (WorkspaceEdit)" --> doc[(document)]
prop -- accept --> base
```
- **`trackChangesModel.ts`** (pure, vscode-free — INV-33) gains
**`renderReview(baselineText, currentText, authorSpans, proposals): string`**.
Internals: (1) the existing F7 `diffBlocks` over baseline/current → block ops;
(2) for changed/added **prose**, the existing word-level `<ins>`/`<del>`,
**then** author-color each `<ins>` (and live text) by intersecting `authorSpans`
via the F9 **PUA-sentinel** technique (refactored out of `renderAuthorship`
into a shared `colorByAuthor` helper); (3) **inject proposal blocks**: for each
proposal, resolve its anchor offset in `currentText`, emit a
`<div class="cw-proposal" data-proposal-id="…">` containing the struck
replaced-text and the proposed text + a `<span class="cw-actions">✓ ✗</span>`
placeholder rendered by the webview; (4) atomic code/mermaid unchanged (F7.1
reused). Off-mode calls a thin `renderPlain(currentText)` (markdown-it only).
- **`trackChangesPreview.ts`** (vscode layer) — `mode: Map<key, "on"|"off">`
(default `"on"`, replacing F9's `"changes"|"authorship"`); gains a
**`ProposalController` dependency** and subscribes to a F4
`onDidChangeProposals` event (additive, mirroring F6's `onDidChangeBaseline`)
alongside the existing edit + baseline triggers; on the new
`{accept|reject, proposalId}` inbound message, calls
`ProposalController.accept`/`reject` then re-renders; owns the **status-bar
item** (PUC-6).
- **Webview asset** (`media/preview.ts` + `.css`) — header **on/off** switch
(replacing the segmented control); **inbound** click handler on `.cw-actions`
buttons → `postMessage({accept|reject, proposalId})` (nonce-gated inline
script, CSP-safe); proposal-block + ✓/✗ button CSS; reuses the existing
`mermaid.run()` swap. Stays sealed (INV-21).
- **F3 `AttributionController`** — remove the `render()` `setDecorations` calls
and dispose the decoration types; **keep** `spansFor`. Retire/hide
`cowriting.toggleAttribution`.
- **F4 `ProposalController`** — remove the in-editor proposal-thread decorations
and the in-editor accept/reject codelens/commands' UI; **keep**
`accept`/`reject`/`propose`. Add `listProposals(document): ProposalView[]`
(anchor offset + replacement + id) and `onDidChangeProposals` for the preview.
- **F6 `DiffViewController`** — unchanged code; `cowriting.toggleDiffView` +
`ctrl+alt+d` set `when:false` in `package.json`.
### 6.3 Data model & ownership
**No new persisted artifact** (INV-20). F10 holds only in-memory webview panels
keyed by document URI + the per-panel on/off mode + the status-bar item. The
baseline is owned by F6; proposals by F4 (sidecar); attribution by F3. The only
on-the-wire models are transient:
```ts
// host → webview (transient HTML payload)
type RenderMsg = {
type: "render"; mode: "on" | "off"; html: string;
epoch?: string; summary?: { added: number; removed: number; proposals: number };
};
// webview → host (intent only — INV-34)
type ActionMsg =
| { type: "setMode"; mode: "on" | "off" }
| { type: "accept"; proposalId: string }
| { type: "reject"; proposalId: string };
// internal to renderReview (illustrative — not persisted)
type ProposalView = { id: string; anchorStart: number; anchorEnd: number; replacement: string };
```
### 6.4 Interfaces & contracts
- **`trackChangesModel`** (vscode-free): `renderReview(baselineText: string,
currentText: string, authorSpans: AuthorSpan[], proposals: ProposalView[]):
string` — annotated HTML for the on-state body; deterministic (INV-33).
`renderPlain(currentText: string): string` — off-state body. Exports
`colorByAuthor(html-or-blocks, spans)` (the salvaged F9 sentinel helper) for
unit tests. **Removes** the public `renderAuthorship` (superseded);
`renderTrackChanges` is retained internally / folded into `renderReview`.
- **`TrackChangesPreviewController`** (vscode layer): `show(document)`,
`refresh(document)` (debounced), `setMode(uriString, "on"|"off")` (test seam),
`getLastModel(uriString)` (test seam — the last computed block/proposal model,
so host E2E asserts marks without reading webview DOM). Constructor now takes
`AttributionController` **and** `ProposalController`.
- **`AttributionController`** (additive removal): `render()` no longer applies
editor decorations; `spansFor(document): AuthorSpan[]` unchanged.
- **`ProposalController`** (additive): `listProposals(document): ProposalView[]`;
`readonly onDidChangeProposals: vscode.Event<{ uri: string }>` fired on
propose/accept/reject. `accept`/`reject`/`applyAgentEdit` unchanged.
- **`DiffViewController`** (F6) — unchanged; only its `package.json`
command/keybinding `when` flips to `false`.
- **Commands / keybindings** (`package.json`): `cowriting.showTrackChangesPreview`
("Cowriting: Open Review Preview", `ctrl+alt+r`, `when: editorLangId ==
markdown`) — retitled, kept. `cowriting.toggleDiffView` + `ctrl+alt+d`,
`cowriting.toggleAttribution`, and the in-editor `cowriting.acceptProposal` /
`cowriting.rejectProposal` palette entries → `when:false` (hidden; the seams
stay for tests/programmatic use). Status-bar item registered in `extension.ts`.
### 6.5 PerProduct-Use-Case design
- **PUC-1 (clean editor):** delete `AttributionController.render`'s
`setDecorations` + decoration-type creation; remove F4's in-editor proposal
decorations. Editor shows plain text.
- **PUC-2 (toggle):** header switch → `{type:"setMode", mode}` → controller
updates the per-panel map → re-render. Off = `renderPlain`; on = `renderReview`.
- **PUC-3 (who changed what):** `renderReview` overlays the F7 baseline diff
(author-colored via F9 sentinels) + proposal blocks.
- **PUC-4 (accept):** `{type:"accept", id}` → `ProposalController.accept(state,
proposal)` → `applyAgentEdit` `WorkspaceEdit` (INV-9) → baseline advances
(INV-18) → `onDidChangeBaseline` + `onDidChangeProposals` → re-render (block now
ordinary text).
- **PUC-5 (reject):** `{type:"reject", id}``ProposalController.reject`
proposal removed from sidecar → `onDidChangeProposals` → re-render (block gone);
document untouched.
- **PUC-6 (proposal while no preview):** on `onDidChangeProposals`, if no panel
is open for the doc, show/update a status-bar item with the pending count,
`command = cowriting.showTrackChangesPreview`. Hidden when count = 0 or a panel
is open.
- **PUC-7 (edges):** non-markdown → `showWarningMessage`, no panel. Per-block
`try/catch` → error chip (F7). No baseline → render with no change-marks + a
note; proposals still render.
### 6.6 Non-functional requirements & cross-cutting concerns
Render cost is O(document) on a debounced edit / proposal change — fine at
inner-loop scale (large-doc debounce/idle cap is the F7 tunable, not v1
critical). The webview stays **sealed** (INV-21): local assets, strict CSP with a
per-load nonce, no network; mermaid lives only in the webview bundle. The ✓/✗
buttons are host-rendered HTML with `data-proposal-id`; the nonce'd inline script
only reads those ids and posts intent (no eval, no remote). No telemetry, no LLM,
no credentials, nothing persisted.
### 6.7 Key decisions & alternatives considered
| Decision | Chosen | Alternatives rejected |
| --- | --- | --- |
| **Where Claude's reviewable changes come from** | **Pending F4 proposals surfaced in the preview** — keep propose-by-default; ✓ = F4 accept (apply + advance baseline), ✗ = F4 reject. | **Apply-then-review** (LLM edits land in the buffer; ✓/✗ pin/revert per span) — reverses F4 INV-10/11 and needs new per-span baseline machinery. **Both axes overlaid** (landed-LLM authorship + pending proposals + human diff) — richest but most rendering complexity; deferred. *(Operator decision, session 0029.)* |
| **Editor decoration scope** | **Fully clean** — strip F3 attribution **and** F4 proposal threads; proposals are **preview-only**. | **Strip F3 only, keep amber threads** — editor isn't truly "zero annotations"; two proposal surfaces to keep in sync. *(Operator decision, session 0029.)* |
| **F6 two-pane diff** | **Hide** command + `ctrl+alt+d` (`when:false`); keep the controller + baseline store. | **Delete the view code now** — larger, less reversible diff; a later cleanup PR can remove the dead view. *(Operator decision, session 0029.)* |
| **F9's two modes** | **Collapse** into one **on/off** toggle; on = combined render; off = plain markdown. Remove `renderAuthorship` + segmented control. | **Keep three modes** (changes / authorship / combined) — re-introduces the very surface-multiplicity F10 removes. |
| **Combined render approach** | **One pure `renderReview`** overlaying diff + author sentinels + proposal blocks (extends INV-22). | **Compose two HTML passes in the webview** — pushes logic into the sealed sandbox, loses unit-testability (INV-33). |
| **Proposal-while-no-preview** | **Status-bar indicator** (clickable to open). | **Force auto-open the preview** — hijacks focus, fights "write left, review right." *(Deferred decision — cheap to flip to auto-open.)* |
| **Deletion coloring** | **Neutral struck** (`cw-del`), per the issue's "strikethrough = deleted". | **Color the strikethrough by who deleted** — adds a fourth signal for marginal value. |
| **Non-markdown review** | **Dropped as a goal** (markdown-only product). | **Keep F6's any-file reach** — contradicts the locked product vision; rename to a markdown-specific identity is the eventual corollary (deferred). |
### 6.8 Testing strategy
- **Unit (vitest, vscode-free):** `renderReview` over fixtures — human addition
(green `<ins>`), human deletion (struck `cw-del`), a proposal **insert** (blue
`cw-proposal` block + `data-proposal-id` + ✓/✗ markup), a proposal **replace**
(struck old + proposed new in one block), mixed human+proposal in a paragraph,
an atomic code fence + a changed mermaid fence (F7.1 augmentation intact, no
inner sentinels), **off-mode** = plain markdown (`renderPlain` equals
markdown-it of source), determinism (same inputs → identical HTML). `colorByAuthor`
helper unit tests (salvaged F9 cases). Proposal-anchor resolution edge: an
unresolvable fingerprint → the proposal is rendered as a trailing block, never
dropped silently.
- **Host E2E (`@vscode/test-electron`, no LLM, extends the F7/F9 suite):** open a
markdown fixture → `cowriting.showTrackChangesPreview` → assert panel open +
`getLastModel` (opened baseline → no change-marks). Type → an `added` block,
author = human. `proposeAgentEdit` seam → `getLastModel` shows a proposal op
with id; **simulate `{type:"accept", id}`** → proposal applied, baseline
advanced, block now `unchanged`/ordinary; **`{type:"reject", id}`** → proposal
gone, document text unchanged. Assert the **editor has no decorations** (the F3
decoration types are not created / applied). Assert `toggleDiffView` is not in
the active command set (or its `when` is false). Non-markdown doc → command
warns, no panel. Status-bar item appears on a proposal with no panel open.
Webview DOM / real button clicks are **not** E2E-asserted (sealed sandbox) —
manual smoke.
- **Live smoke (manual — `docs/MANUAL-SMOKE-F10.md`):** open a markdown doc;
confirm the editor is clean (no tint/threads); edit prose (green ins / struck
del); ask Claude to edit a selection → a blue proposal block with ✓/✗ appears
in the preview; click ✓ (lands, mark clears) and ✗ on another (block vanishes,
doc unchanged); toggle annotations off (clean render) / on; verify the
status-bar indicator with no preview open; verify light/dark theming and that
`git status` shows nothing.
### 6.9 Failure modes, rollback & flags
`markdown-it`/`mermaid` throwing on a block → inline error chip (F7), preview
still renders. A proposal whose fingerprint no longer resolves → rendered as a
trailing "unanchored proposal" block with ✓/✗ (never silently dropped — INV-34
must remain actionable). Webview disposed → controller drops the panel + clears
the status-bar item; re-run the command to reopen. **No feature flag** — the
preview is a pure read-only view (INV-20); not opening it is the off state.
Rollback is reverting the PR with **zero** data migration (nothing persisted; the
F6 baseline, F4 sidecar, F3 attribution data are untouched, so the hidden/removed
UI can be restored). The clean-editor change is mostly deletion of decoration
calls — trivially reversible.
---
## 7. Delivery Plan
### 7.1 Approach / strategy
One planning-and-executing session (F10 = `#29`), plan written just-in-time from
this spec — the F2F9 precedent. Host-E2E tier (a VS Code extension has no
browser/deploy stage); no LLM in CI. The webview's *visual* rendering and real
button clicks are verified by the manual smoke; the automated seams are the pure
render **model** and the host's accept/reject wiring.
### 7.2 Slicing plan
- **SLICE-1 — Clean editor.** Strip F3 attribution decorations
(`AttributionController.render`) + F4 in-editor proposal threads; hide
`toggleDiffView`/`ctrl+alt+d` + `toggleAttribution` (`when:false`). Host E2E:
no editor decorations. *(Mostly deletion.)*
- **SLICE-2 — Combined render engine.** `renderReview` + `renderPlain` in
`trackChangesModel.ts`; salvage `colorByAuthor` from `renderAuthorship`; remove
the public `renderAuthorship`; proposal-block emission + anchor resolution;
vitest suite (§6.8). Pure, vscode-free (INV-33).
- **SLICE-3 — Interactive controller + webview.** `ProposalController.listProposals`
+ `onDidChangeProposals`; wire `ProposalController` + `AttributionController`
into `TrackChangesPreviewController`; collapse mode to on/off; inbound
`accept`/`reject`/`setMode` handling + re-render; webview on/off switch + ✓/✗
click→postMessage + proposal/button CSS; status-bar indicator (PUC-6).
- **SLICE-4 — Tests & docs.** Host E2E (open / toggle / propose→accept→reject /
clean-editor / hidden-F6 / status-bar) + `docs/MANUAL-SMOKE-F10.md` + README
F10 section (and a note that F6/F9 user surfaces are superseded).
E2E are first-class plan tasks (handbook §9/§4); this app's required tier is host
E2E (the F2F9 precedent).
### 7.3 Rollout / launch plan
Non-shippable (no marketplace publish). "Done" = `#29` acceptance: a clean
editor; the preview is the single review surface with an annotations on/off
toggle; on-state paints green human / blue LLM / struck deletions and renders
Claude's pending proposals with working ✓/✗ that land/discard via the F4 seam;
unit + host E2E green; live smoke performed once. The repo rename remains
deferred.
### 7.4 Risks & mitigations
| Risk | Mitigation |
| --- | --- |
| Proposals being preview-only hides pending work when no preview is open | Status-bar indicator (PUC-6); README documents "review happens in the preview"; auto-open is a cheap fallback if it proves insufficient |
| Overlaying author sentinels onto the diff's `<ins>`/`<del>` perturbs markdown-it inline parsing (F9's known edge) | Per-block `try/catch` → error chip (no hard failure); fences atomic; covered by the common-case tests; intra-emphasis hardening stays deferred |
| Proposal anchor can't be resolved at render time | Render as a trailing "unanchored proposal" block (never dropped); unit-tested |
| Removing F4's in-editor threads loses a familiar affordance | The ✓/✗ move to the preview (the single review surface by design); status-bar signals presence |
| Two reversed decisions (F9 INV-26; F6 any-file `#19`) confuse future readers | This spec records the supersession explicitly (§6.1, §6.7); README updated |
| Webview interactivity widens the attack surface | Nonce-gated inline script reads only `data-proposal-id` and posts intent; CSP unchanged; host validates the id against live proposals before acting (INV-34) |
---
## 8. Traceability matrix
| Requirement (#29) | Use case | Design | Slice |
| --- | --- | --- | --- |
| Editor carries no coauthoring decorations | PUC-1 | INV-32, §6.2 | SLICE-1 |
| Preview annotations on/off toggle (off = clean render) | PUC-2 | §6.2, §6.4 | SLICE-3 |
| One language: green human / blue LLM / struck deleted | PUC-3 | INV-33, §6.2 (renderReview) | SLICE-2 |
| ✓/✗ on Claude's changes only; ✓ keeps, ✗ reverts | PUC-4/5 | INV-34, §6.5 (F4 seam) | SLICE-2/3 |
| Accept/reject updates doc + preview consistently | PUC-4/5 | §6.5, INV-18 reuse | SLICE-3 |
| Deletions reconstructed from the F6 baseline | PUC-3 | F7 reuse, §6.2 | SLICE-2 |
| Deprecate F6 as a view; keep baseline data layer | — | §6.2, §6.7 | SLICE-1 |
| Markdown-only; no sidecar/contract/SCHEMA change | — | §1.7, INV-20 | all |
| Sealed webview, no network/LLM | — | INV-21, §6.6 | SLICE-3 |
| Unit + host E2E, no LLM in CI | — | §6.8 | SLICE-1..4 |
## 9. Open Questions & Decisions log
- **RESOLVED (session 0029, operator):** Claude's reviewable changes = **pending
F4 proposals surfaced in the preview** (propose-by-default kept; ✓ = F4 accept +
baseline advance, ✗ = F4 reject); editor decoration scope = **fully clean**
(strip F3 attribution **and** F4 proposal threads — proposals preview-only); F6
two-pane diff = **hidden** (`when:false`, code/baseline kept).
- **RESOLVED (this spec, autonomous — cheap to revisit):** proposal-while-no-preview
**status-bar indicator** (not forced auto-open); F9's dead `renderAuthorship`
/ segmented control → **removed** (the PUA-sentinel coloring salvaged into a
shared `colorByAuthor` helper); deletions → **neutral** strikethrough.
- **SUPERSEDED:** F9 INV-26 ("authorship never combined with the diff") — F10
combines them by design; F9's authorship mode + segmented control are removed.
F6's any-file review reach (`#19`) is dropped as a user goal (markdown-only).
- **OPEN → later:** the repo rename to `vscode-markdown-cowriting-plugin`
(deliberate, deferred); preview→source scroll-sync; intra-emphasis
sentinel-safety hardening; whether to eventually overlay *landed*-LLM authorship
(F9's old axis) in the on-state too (the "both axes" option, deferred); deleting
F6's two-pane view code in a later cleanup PR.
## 10. Glossary & References
- **Review preview** — the rendered markdown preview as the single review
surface: clean (off) or annotated (on). **Annotations on/off** — the per-panel
toggle replacing F9's two modes. **Combined render**`renderReview`'s
single-pass overlay of changes-since-baseline (author-colored + struck) and
pending proposals. **Pending proposal** — an F4 propose-by-default edit, not yet
in the buffer, rendered blue with ✓/✗. **Baseline / epoch / advance** — as F6
(`coauthoring-diff-view.md`). **`spansFor`** — F3's author-span data layer
(`coauthoring-attribution.md`). **PUA sentinels / `colorByAuthor`** — F9's
author-coloring technique (`2026-06-11-authorship-preview-design.md`), salvaged
here.
- Feature `#29` (F10) · Epic `#1` · builds on F3 `#6`, F4 `#12`, F6 `#17`/`#19`,
F7 `#21`/`#22`, F9 `#27` · parent specs `coauthoring-inner-loop.md`,
`coauthoring-attribution.md`, `coauthoring-propose-accept.md`,
`coauthoring-diff-view.md`, `coauthoring-rendered-preview.md`,
`2026-06-11-authorship-preview-design.md` · capture session 0028 · lineage
`ben.stull/rfc-app#48`.
+398
View File
@@ -0,0 +1,398 @@
---
status: graduated
---
# Solution Design: Live Turn Progress (#60)
| | |
| --- | --- |
| **Author(s)** | Ben Stull (with Claude) |
| **Reviewers / approvers** | Ben Stull |
| **Status** | `graduated` |
| **Version** | v0.1.0 |
| **Source artifacts** | Feature `benstull/vscode-cowriting-plugin#60` (Show Claude's live output/progress during the "asking Claude…" status, `type/feature`, `priority/P1`) · Capture session `vscode-cowriting-plugin-0054` (2026-06-15) · Brainstorming session `vscode-cowriting-plugin-0055` · Builds on (all shipped): the LiveTurn machine-edit ingress (`liveTurn.ts`, INV-8/9), F4 `#12` (propose/accept seam), F10 `#29` (interactive review preview), F11 `#43` (preview toolbar + `editDocument`/`runEditAndPropose`) · Parent specs (graduated): `coauthoring-inner-loop.md`, `coauthoring-propose-accept.md`, `coauthoring-rendered-preview.md`, `coauthoring-document-edit-flow.md` · Lineage: `ben.stull/rfc-app#48` |
**Change log**
| Date | Version | Change | By |
| --- | --- | --- | --- |
| 2026-06-22 | v0.1.0 | Initial draft — brainstorming session 0055. Three forks locked with the operator: **(1) surface** = the existing "asking Claude…" notification carries a live **activity line** *plus* a host **OutputChannel** streaming the full assistant text; **(2) content** = activity (`thinking…`/`writing… (N chars)`/`running <tool>…`) **+ running token count**; **(3) cancellation** = reflect aborted/failed states **and** add a cancel button wired to `agent.abort()`. Two sub-decisions confirmed: OutputChannel auto-reveals (preserveFocus) on first text delta, gated by a `cowriting.liveProgress.revealOutput` setting (default on); the channel **appends** per-turn (with a header), doubling as a debug log. | Ben Stull + Claude |
---
## 1. Business Context
### 1.1 Executive Summary
When the writer asks Claude to edit, the plugin shows one static notification —
*"Cowriting: asking Claude…"* — and then nothing until the whole turn resolves.
Both edit entry points (`editSelection` in `extension.ts:232` and the preview's
`askClaude` in `trackChangesPreview.ts:232`) `await` the turn as **one opaque
promise** (`liveTurn.ts` `runEditTurn``agent.run()`). For anything past a
trivial edit this is a black box: the writer can't tell whether Claude is
thinking, reading, partway through writing, or stuck — and a long edit feels like
the tool hung.
The fix is small and almost entirely *additive observability*: `@cline/sdk`
**already streams** everything we need (`assistant-text-delta`,
`tool-started|finished`, `usage-updated`, lifecycle events) via
`agent.subscribe(listener)`, and exposes `agent.abort()` for cancellation — the
extension just never subscribes (`liveTurn.ts` constructs the Agent with no
hook). This design wires that stream to the existing notification (a live
**activity line** + running **token count**), mirrors the full assistant text
into a host **OutputChannel** so the writer can *read it forming*, and makes the
notification **cancellable**. The final result path — `runEditTurn`
`runEditAndPropose` → F4 proposals — is **untouched**.
### 1.2 Background
The plugin's machine-edit ingress is `LiveTurn` (`liveTurn.ts`, spec
`coauthoring-inner-loop.md` §6.2): one `@cline/sdk` Agent turn on the built-in
`claude-code` provider (INV-8 — the extension holds no credentials; it rides the
operator's local Claude Code login). The turn's result feeds the F4 propose/accept
seam (`coauthoring-propose-accept.md`) and is surfaced in the F10/F11 interactive
review preview. `liveTurn.ts` is **deliberately `vscode`-free** (its module header
says so) so the SDK ingress stays independent of the editor host.
Two gestures invoke a turn, both wrapping it in `vscode.window.withProgress(…
"asking Claude…")`:
- **`editSelection`** (`extension.ts`) — selection → one single-range proposal.
- **preview `askClaude`** (`trackChangesPreview.ts`) — selection or whole
document → one proposal (range) or one-per-changed-block (document, INV-39).
The preview path runs the turn through an **injectable** seam — `editTurn`
(`trackChangesPreview.ts:56`, type `EditTurn`) — so host E2E can stub the LLM (no
Claude in CI). This design widens that seam without breaking the stub.
### 1.3 Who feels it & why it's P1
The **human coauthor** — anyone who asks Claude to edit and waits for the turn,
most acutely on longer/slower edits. It is **P1** because the opaque wait hurts
*every* Claude turn (the core gesture of the tool), the enabling stream already
exists (low cost), and visible progress materially raises trust ("the tool is
alive and on task"). WSJF (issue #60): Value 6 · TC 3 · OE 4 ÷ Size 4 ≈ 3.25.
---
## 2. Product Design
### 2.1 The experience
During a turn, the writer sees **two coordinated surfaces**, both tied to the
existing "asking Claude…" status — no new window, no webview change:
1. **The notification line (legible motion + scale).** Below the
"Cowriting: asking Claude…" title, a live activity line that updates as events
arrive:
| State | Line |
| --- | --- |
| before first output | `thinking…` |
| streaming text | `writing… (412 chars)` |
| streaming text + usage seen | `writing… · 1.2k tokens` |
| a tool is running | `running read_file…` |
The line shows the **current phase** and, once a `usage-updated` event has
arrived, the **running token total** (input + output). `chars` and `tokens`
render together when both are known (`writing… (412 chars) · 1.2k tokens`).
2. **The OutputChannel (read it forming).** A single shared `"Cowriting"` output
channel receives the **full assistant text** as it streams, under a per-turn
header `── asking: <instruction> ──`. On the **first** text delta the channel
reveals itself **without stealing focus** (`show(true)`), so the writer can
start reading the output before the turn finishes. Auto-reveal is gated by a
setting `cowriting.liveProgress.revealOutput` (default `true`) for writers who
prefer to open it manually.
The notification is **cancellable** (`✕`): clicking it aborts the turn
(`agent.abort()`); the status resolves to **"cancelled"** (not a frozen spinner,
not a "failed" error), and **no proposal is created**. If the turn *fails*, the
existing error path shows the error (no longer a frozen "asking Claude…").
### 2.2 What this is *not* (non-goals — from #60)
- **Not a chat/transcript panel.** This is *in-flight progress*, not a persistent
conversation history surface. (The OutputChannel's append-log is a debug
convenience, not a product history feature.)
- **Not streaming into the document.** The result still lands as F4 proposals
exactly as today; only *observation* is added.
- **Not a cancellation feature in its own right.** The `✕` button + abort wiring
are the cheap, in-scope *reflection* of cancel; rich cancel controls (partial
apply, resume, queueing) are out of scope.
- **Not surfacing reasoning text.** `assistant-reasoning-delta` is collapsed to a
generic `thinking…`; the chain-of-thought text is **not** shown (kept the
notification/OutputChannel uncluttered; operator fork).
### 2.3 Surfaces considered & rejected
| Surface | Why not (as the *primary*) |
| --- | --- |
| **Webview relay** (into the preview) | The `editSelection` path has **no** webview; can't cover both entry points uniformly (acceptance requires both). |
| **Status-bar item only** | One terse line; no room for streamed text; weaker "what is it doing" signal than the in-status notification line. |
| **OutputChannel only** (notification stays a plain spinner) | The "in/alongside the asking-Claude status" signal weakens — the writer must open the Output panel to learn anything. |
Chosen: **notification activity-line (primary, in-status) + OutputChannel
(secondary, full text)** — covers both call sites, no new network/webview surface
(INV-21/INV-45), and delivers both "see motion" and "read output as it forms."
---
## 3. Engineering Design
### 3.1 Architecture — three units
```
@cline/sdk Agent events (raw AgentRuntimeEvent stream)
│ agent.subscribe(listener)
┌─────────────────────────┐ pure, vscode-free, SDK-free
│ turnProgress.ts (NEW) │ reduce(state, event) → TurnProgressSnapshot
│ the reducer (INV-46) │
└─────────────────────────┘
│ TurnProgressSnapshot
┌─────────────────────────┐ vscode-free (INV-43)
│ liveTurn.ts runEditTurn │ subscribe → reduce → onProgress(snapshot);
│ (EXTENDED) │ signal?.abort → agent.abort(); result UNCHANGED
└─────────────────────────┘
│ onProgress(snapshot) + AbortSignal in
┌─────────────────────────┐ the two UI call sites (relay only)
│ extension.ts:editSelection │ format notification line · append OutputChannel ·
│ trackChangesPreview:askClaude │ withProgress({cancellable}) · token→AbortController
└─────────────────────────┘
```
The decisive layering rule: **progress flows *out* of `liveTurn` as a domain
`TurnProgressSnapshot`; cancel flows *in* as a web-standard `AbortSignal`.** No
`vscode` import is added to `liveTurn.ts` or `turnProgress.ts` (INV-43).
### 3.2 `src/turnProgress.ts` (new, pure) — INV-46
The reducer owns *all* event→state logic so the call sites stay dumb (format +
relay only) and the logic is unit-tested in isolation.
```ts
export interface TurnProgressSnapshot {
phase: "thinking" | "writing" | "tool";
tool?: string; // present iff phase === "tool"
chars: number; // accumulated assistant-text length so far
tokens?: number; // running total (input+output); undefined until first usage event
textDelta?: string; // the new assistant-text chunk since the last snapshot
}
// Opaque carried state; start from createTurnProgressState().
export interface TurnProgressState { /* phase, chars, tokens, toolDepth/name */ }
export function createTurnProgressState(): TurnProgressState;
// Pure: fold one SDK event into the state, returning the next state and the
// snapshot to emit (or undefined for events that don't change the surface).
export function reduceTurnProgress(
state: TurnProgressState,
event: AgentRuntimeEvent, // imported as a TYPE only from @cline/shared
): { state: TurnProgressState; snapshot?: TurnProgressSnapshot };
```
Event mapping (the relevant `AgentRuntimeEvent` members):
| SDK event | Effect on snapshot |
| --- | --- |
| `run-started` / `turn-started` | `phase: "thinking"` |
| `assistant-text-delta` | `phase: "writing"`, `chars = accumulatedText.length`, `textDelta = text` |
| `assistant-reasoning-delta` | `phase: "thinking"` (text **not** surfaced) |
| `tool-started` / `tool-updated` | `phase: "tool"`, `tool = toolCall.name` |
| `tool-finished` | revert `phase` to `writing` if any text seen, else `thinking` |
| `usage-updated` | `tokens = usage` total (input+output) |
| `turn-finished` / `run-finished` / `run-failed` | no snapshot (resolution handled by the turn promise) |
`AgentRuntimeEvent` is imported **type-only** (`import type`) — `turnProgress.ts`
has **no runtime dependency** on the SDK and no `vscode` import, so it is a pure,
fast unit (INV-43/46). Tool concurrency is tracked by name/depth so overlapping
`tool-started`/`tool-finished` pairs resolve the phase correctly.
### 3.3 `liveTurn.ts` `runEditTurn` (extended)
`opts` gains two optional fields; the signature and result type are otherwise
unchanged (INV-44):
```ts
export interface RunEditTurnOptions {
modelId?: string;
onProgress?: (s: TurnProgressSnapshot) => void; // out: live progress
signal?: AbortSignal; // in: cancellation
}
export async function runEditTurn(
instruction: string,
selectedText: string,
opts?: RunEditTurnOptions,
): Promise<EditTurnResult>;
```
Inside, around the existing `agent.run(...)`:
```ts
const agent = new sdk.Agent({ providerId: "claude-code", modelId, systemPrompt });
let state = createTurnProgressState();
const unsubscribe = opts?.onProgress
? agent.subscribe((ev) => {
const next = reduceTurnProgress(state, ev);
state = next.state;
if (next.snapshot) opts.onProgress!(next.snapshot);
})
: undefined;
const onAbort = () => agent.abort();
opts?.signal?.addEventListener("abort", onAbort);
try {
const result = await agent.run(`<instruction>…</instruction><text>…</text>`);
// existing non-"completed" guard throws (covers "aborted" and "failed")
} finally {
unsubscribe?.();
opts?.signal?.removeEventListener("abort", onAbort);
}
```
`AbortSignal`/`addEventListener` are web standards (no `vscode`), preserving
INV-43. An aborted turn yields `status: "aborted"` → the existing guard throws →
the call site reflects "cancelled" and proposes nothing (INV-47). **`extractReplacement` and the return shape are untouched** (INV-44).
### 3.4 The two call sites (UI relay)
A small shared host module owns the relay so both sites are identical:
```ts
// src/liveProgressUi.ts (host; vscode-only)
export function createLiveProgressUi(): {
channel: vscode.OutputChannel;
// Begin a turn: clears nothing, writes the header, returns the wiring for
// one withProgress run.
begin(instruction: string, progress: vscode.Progress<{message: string}>, token: vscode.CancellationToken):
{ signal: AbortSignal; onProgress: (s: TurnProgressSnapshot) => void };
};
```
- **`onProgress(s)`** → `progress.report({ message: formatLine(s) })` where
`formatLine` renders `phase`/`chars`/`tokens`; and appends `s.textDelta` (if
any) to the shared `"Cowriting"` channel. On the **first** non-empty
`textDelta`, if `cowriting.liveProgress.revealOutput` is `true`, call
`channel.show(true)` once.
- **`signal`** comes from an `AbortController` whose `abort()` is fired by
`token.onCancellationRequested`.
- The `withProgress` options gain **`cancellable: true`**.
- On `catch`, the call site checks `token.isCancellationRequested` → show
**"cancelled"** (information) instead of the "failed" error.
`extension.ts:editSelection` passes `{ onProgress, signal }` straight into
`runEditTurn`. The preview path threads the same through its seam:
```ts
// trackChangesPreview.ts — widen the injectable seam (back-compat: opts optional)
type EditTurn = (instruction: string, text: string, opts?: RunEditTurnOptions) => Promise<EditTurnResult>;
```
`runEditAndPropose` forwards `opts` to its single `editTurn(...)` call (both the
`range` and `document` branches invoke the turn exactly once per gesture, so there
is one progress stream per gesture). The **host-E2E stub** (the default-overriding
injection) keeps working — `opts` is optional and ignored by the stub; a focused
test may simulate progress by calling `opts?.onProgress` (§4).
### 3.5 OutputChannel semantics
- **One shared channel** (`createOutputChannel("Cowriting")`), created once at
activation and disposed with the extension.
- **Append, don't clear** — each turn writes a `── asking: <instruction> ──`
header then streams text, so the channel doubles as a debug log of recent turns
(per #60 solution-notes). (Trimming an unbounded log is a possible later
increment, not v1.)
- **Reveal** on first text delta, `preserveFocus: true`, gated by
`cowriting.liveProgress.revealOutput` (boolean, default `true`).
### 3.6 New configuration
| Setting | Type | Default | Effect |
| --- | --- | --- | --- |
| `cowriting.liveProgress.revealOutput` | boolean | `true` | Auto-reveal the "Cowriting" OutputChannel (without focus) on the first streamed text of a turn. |
Declared in `package.json` `contributes.configuration`.
### 3.7 Invariants
- **INV-43**`liveTurn.ts` and `turnProgress.ts` stay **`vscode`-free**:
progress leaves as a domain `TurnProgressSnapshot`, cancel enters as a
web-standard `AbortSignal`; `AgentRuntimeEvent` is imported **type-only**. No
`vscode` import is added to either module.
- **INV-44** — Live progress is **purely additive**: it never changes the result
path (`runEditTurn` return shape, `extractReplacement`, `runEditAndPropose`,
the F4 proposals). A turn that emits **no** events (the E2E stub) produces the
identical proposals it does today.
- **INV-45****No new network/webview surface.** Progress rides the existing
`withProgress` notification + a host `OutputChannel` only; the sealed webview is
untouched (preserves INV-21).
- **INV-46** — The event→progress **reduction is a pure function**
(`reduceTurnProgress`), unit-tested in isolation; the UI call sites only format
and relay its snapshots.
- **INV-47****Cancellation reflects, doesn't mutate.** Abort surfaces
"cancelled" (not a frozen spinner, not a silent partial apply); an aborted turn
proposes **nothing** (parity with the empty / no-change outcomes).
### 3.8 Error & edge handling
- **Failure** — non-"completed" status throws (existing guard); call site shows
the error message, the notification resolves (no frozen spinner).
- **Cancel**`token``agent.abort()``status: "aborted"` → guarded throw →
call site detects `token.isCancellationRequested` → "cancelled", no proposal.
- **No events** — if the provider emits nothing before completing, the line stays
`thinking…` and resolves normally; result identical to today (INV-44).
- **Overlapping tools** — phase tracked by tool name/depth so interleaved
`tool-started`/`tool-finished` resolve back to `writing`/`thinking` correctly.
- **Empty / no-change replacement** — unchanged from today (warning / info),
independent of progress.
---
## 4. Testing & E2E
Per the §9 pipeline and `coauthoring-*` precedent (unit + host E2E; this is a
VS Code extension — **no flotilla/PPE**, the pipeline's deploy tiers are N/A; the
gate is unit + host-E2E green).
- **Unit (the core):** `turnProgress.test.ts` exercises `reduceTurnProgress`
across the full event table — phase transitions (thinking→writing→tool→writing),
`chars` accumulation, `tokens` appearing only after `usage-updated`, `textDelta`
carrying each chunk, reasoning-delta staying `thinking`, overlapping tools. Pure,
no vscode/SDK. (`formatLine` gets its own small unit test.)
- **Unit (relay):** `liveProgressUi` line formatting and first-delta reveal gating
(mock `OutputChannel`/`Progress`).
- **Host E2E:** the existing preview `editTurn` stub is extended in one test to
**emit synthetic progress** (call `opts.onProgress` with a couple of snapshots)
and assert the turn still yields the same proposals (INV-44) and that cancel
(firing the token) yields **zero** proposals (INV-47). Streamed UI itself (the
live notification text) isn't asserted — E2E can't read notification subtitles
reliably; that's covered by the unit tests on the mapping + a **manual smoke**
on a longer real edit (per #60 acceptance).
- **Manual smoke:** a longer real edit shows the activity line advancing, token
count rising, the OutputChannel streaming, and `✕` cancelling cleanly.
---
## 5. Delivery Plan (rollout — not a task list)
One increment (the feature is a single design-then-build, #60 decomposition).
Ships through the standard branch → PR → `main` flow; no migration, no persisted
state, no config beyond the one additive setting (default preserves prior
behavior except that progress is now visible). Reversible by reverting the PR.
The implementation plan (downstream `wgl-planning-and-executing` session) owns
the Task breakdown; a natural cut is: **(1)** pure `turnProgress.ts` + tests →
**(2)** `runEditTurn` subscribe/abort wiring → **(3)** `liveProgressUi` +
both call sites + setting → **(4)** host-E2E stub extension + manual smoke.
---
## 6. Open Questions
- **OQ-1** — Unbounded OutputChannel log: append-forever is fine for v1 (it's a
manual debug surface); if it becomes noisy, add per-turn trimming or a
"clear on new turn" mode later. *(Deferred — not blocking.)*
- **OQ-2 (inherited)** — The F11 design (`#43`) is still un-graduated (lives in
code + an issue draft). Unrelated to #60 but noted in the shared lineage; track
separately.
+272 -162
View File
@@ -1,5 +1,5 @@
---
status: draft
status: graduated
---
# Solution Design: Coediting Markdown with a Machine — a Native VS Code Experience
@@ -7,15 +7,17 @@ status: draft
| --- | --- |
| **Author(s)** | Ben Stull (with Claude) |
| **Reviewers / approvers** | Ben Stull |
| **Status** | `draft` |
| **Version** | v0.1.0 |
| **Source artifacts** | North-star architecture spec (design-only) for human↔machine markdown coediting. Reference: VS Code's native review surfaces (SCM/QuickDiff, diff editor, multi-diff editor, Markdown preview, Comments). Supersedes: none. |
| **Status** | `graduated` |
| **Version** | v0.2.1 |
| **Source artifacts** | North-star architecture spec for human↔machine markdown coediting. Reference: VS Code's native review surfaces (SCM/QuickDiff, diff editor, Markdown preview, Comments). Inputs: the shipped plugin (F1F12), the rung-2 feasibility spike (`vscode-cowriting-prototype`), `coauthoring-feasibility-spike.md`. Supersedes: none. |
**Change log**
| Date | Version | Change | By |
| --- | --- | --- | --- |
| 2026-06-27 | v0.1.0 | Initial draft — brainstorming session 0062 | Ben Stull + Claude |
| 2026-07-01 | v0.2.0 | Session 0063 recommendation folded in: review surface is **in-buffer pending changes + per-hunk CodeLens** (supersedes the multi-diff Keep/Undo surface — D18, resolves Q2/Q7, amends INV-5/INV-12); **Q1 resolved — evolve the shipped extension in place** (D17); **comments-first ask surface** (D19); D11 updated to shipped reality (D21); rung-2 spike findings (E1/E3/E5) recorded | Ben Stull + Claude |
| 2026-07-01 | v0.2.1 | Rung-2 spike **complete** (session 0064): **E2 PASS**, **E4 PASS-with-fallback** — comment-loop API seams folded into §6.4 (commenting-range refresh on gate change, input focus via `workbench.action.addComment`, `commentThread` menu when-key, offer affordances) and baseline-router notes (nested-repo discovery); §7.1 rung 2 closed | Ben Stull + Claude |
---
@@ -110,9 +112,9 @@ between blind trust and tedious manual reconciliation.
staying in control — seeing what changed, accepting/rejecting changes
individually, knowing authorship, reading the rendered result, and following the
machine's progress — entirely within their editor.
- **Out of scope:** committing a specific product packaging or release; multi-file /
whole-project machine orchestration; a server or cloud backend. This is a
**design-only north star** (see §2): packaging and delivery decisions are deferred.
- **Out of scope:** marketplace release; multi-file / whole-project machine
orchestration; a server or cloud backend. (v0.1.0 also deferred the packaging
decision; v0.2.0 resolves it — the shipped extension evolves in place, D17/§13-Q1.)
- **Non-goals:** replacing the writer's judgment (the machine proposes; the human
disposes); non-markdown document formats in v1; a real-time multi-human
collaboration server.
@@ -122,8 +124,9 @@ between blind trust and tedious manual reconciliation.
- **Assumptions:** the writer works in an editor with native diff, source-control,
multi-change review, Comments, and Markdown-preview surfaces (VS Code is the
target); a capable machine coauthor is available to run locally.
- **Constraints:** serverless and git-native (no external store); the writer's
document is never mutated without their explicit acceptance; no secret bytes in
- **Constraints:** serverless and git-native (no external store); nothing the
machine proposes ever *lands* without the writer's explicit acceptance — a
pending suggestion is always revertible in one gesture; no secret bytes in
any persisted artifact.
- **Dependencies:** a local machine-coauthor runtime; the editor's stable extension
APIs for the surfaces above; the built-in **Git extension API** (to read `HEAD` for
@@ -154,13 +157,15 @@ Scenario: BUC-2 — keep or reject, change by change
Given the machine has proposed changes to my document
When I review them
Then I can keep or discard each change individually, or all at once
And nothing the machine proposed alters my document until I keep it
And nothing the machine proposed lands in my document until I keep it
discarding a change restores my text exactly as it was
```
- **BUC-2 acceptance criteria:** no proposed change alters the document until the
writer keeps it; each change is independently keepable/discardable; the writer can
**edit a proposed change before keeping it**, and those edits are attributed to the
writer; keep-all and reject-all exist.
- **BUC-2 acceptance criteria:** no proposed change *settles* until the writer
keeps it, and rejecting one restores the prior text exactly; each change is
independently keepable/discardable; the writer can **edit a proposed change
before keeping it**, and those edits are attributed to the writer; keep-all and
reject-all exist.
**BUC-3 — As a writer, I can tell who wrote which parts of my document and read a
clean rendered version, so that I can judge the result as finished prose.**
@@ -227,7 +232,7 @@ already know:
| What the writer needs | Native surface that delivers it | What it gives them, for free |
| --- | --- | --- |
| See what changed (BUC-1) | Source-control gutter change bars + the **native diff editor** against a virtual *baseline* document | The exact diff experience they use for everything else: inline/side-by-side, navigation, accessibility |
| Keep/reject — and **tweak** — changes (BUC-2) | The **multi-diff review editor** with per-change **Keep/Undo** and a **writable proposed side** | One native surface to sweep all of the machine's changes — editing the proposal in place before keeping or discarding each (INV-12) |
| Keep/reject — and **tweak** — changes (BUC-2) | **Pending changes in the live editor** — proposed edits land decorated-but-unsettled in the buffer, with per-hunk **✓ Keep / ✗ Reject** CodeLens (D18) | The paradigm the platform's own AI edit sessions use: review the change *in the document*, tweak it by just typing (INV-12), revert it with one gesture (INV-5) |
| Authorship + rendered result (BUC-3) | The **built-in Markdown preview**, extended via a `markdown-it` plugin to inject authorship + change marks | Native rendering, scroll-sync, theming, and cooperation with the writer's other Markdown extensions |
| Discuss a passage — and have the machine respond (BUC-5) | The **Comments API** — threaded comments where, on a coedited document, the machine replies in-thread and offers an edit | The familiar review-comment experience, in the gutter, that can turn a remark into a proposed change |
| Follow + stop the machine (BUC-4) | A progress **notification** + a shared **OutputChannel**, with Cancel | Live, stoppable, legible machine work |
@@ -252,12 +257,16 @@ of building review machinery without getting the polish, and asks the writer to
learn a foreign surface. **Delivering through the editor's native surfaces** gets
that behavior for free, makes coediting feel built-in, and shrinks what must be
built and maintained to a thin layer of wiring over proven cores. The cost is
accepting the platform's affordances where they differ from a bespoke one, plus a
small stable-API boundary on per-change Keep/Undo (§6.10, §13-Q2).
accepting the platform's affordances where they differ from a bespoke one. (The
v0.1.0 stable-API worry on per-change Keep/Undo resolved the other way: the spike
showed CodeLens does not render in a diff editor at all, which — together with the
shipped F12 experience — moved the review surface into the live buffer, D18.)
**Decision posture:** this is a **design-only north star**. It commits to the
architecture and the native-surface mapping; it does **not** commit to a product
packaging or a delivery sequence — those are taken later (§7.1, §13-Q1).
**Decision posture:** this is a **north star with a committed delivery vehicle**.
It commits to the architecture and the native-surface mapping, and (as of v0.2.0,
D17) to the packaging: **the shipped extension evolves in place onto these
surfaces** — its pure cores are retained, its bespoke webview surfaces sunset
(§6.10, §7.1).
**Solution scope.** *In:* the architecture, surface mapping, data model, contracts,
and per-use-case design for native, in-editor human↔machine markdown coediting,
@@ -292,12 +301,16 @@ Scenario: PUC-1 — see the diff against the baseline checkpoint (realizes BUC-1
Scenario: PUC-2 — keep/reject the machine's proposed changes (realizes BUC-2)
Given I ask the machine to revise the document (or a selection)
When the turn completes
Then the proposed changes open in a multi-diff review (baseline ⟷ proposed)
And the proposed side is editable, so I can tweak the machine's wording before keeping it
And each change offers Keep / Undo (Accept / Reject)
And toolbar actions offer Keep all / Reject all
And my document is unchanged until I keep a change
Then each proposed change appears in my editor as a pending, decorated change
(proposed words marked by author color, replaced words struck) — visible in
place but not settled
And each change carries ✓ Keep / ✗ Reject actions (CodeLens above the change)
And Keep all / Reject all are available (CodeLens menu, title bar, status bar)
And I can tweak the proposed wording by simply typing in the pending text
And rejecting a change restores my text exactly as it was
And on keep, the machine's words are attributed to the machine and my tweaks to me
And "Review Changes" opens the native diff (baseline ⟷ live) to sweep everything
at once
```
```gherkin
@@ -374,8 +387,11 @@ panel to wireframe. Described in text (source of truth):
the document is coedited, title-bar buttons (**Review Changes**, **Ask Claude**,
**Toggle Annotations**, and in snapshot mode **Mark Changes as Reviewed**) and the
`✦ Coediting · N changes` status-bar item are the everyday entry points — visible
without going anywhere. The review also **auto-opens** on new proposals, and the
live-progress notification carries a *Review* action.
without going anywhere. **Ask Claude** opens a comment thread (on the selection if
there is one, else on the whole document) — the comment box *is* the ask input
(D19). New proposals land as pending changes **in the buffer in front of the
writer** (no navigation at all), and the live-progress notification carries a
*Review* action.
- **Source Control gutter & viewlet (optional, for git users).** A dedicated
source-control entry, *"Cowriting,"* coexists with Git: its quick-diff provider
draws the familiar change bars in the editor gutter against the baseline checkpoint,
@@ -384,16 +400,26 @@ panel to wireframe. Described in text (source of truth):
— never the required path (INV-13).
- **Native diff editor.** The writer's normal diff (`baseline ⟷ live`), with the
platform's inline/side-by-side toggle, navigation, and accessibility.
- **Multi-diff review editor.** When the machine proposes edits, a multi-diff editor
opens showing `baseline ⟷ proposed`, each change carrying **Keep/Undo**; a
toolbar carries **Keep all / Reject all**. This is the accept/reject surface.
- **Pending changes in the editor (the accept/reject surface, D18).** When the
machine proposes edits, each change lands **in the live buffer** as a pending,
decorated change — proposed words marked in the machine's author color, replaced
words shown struck — with per-hunk **✓ Keep / ✗ Reject** CodeLens (and a
Keep-all/Reject-all menu). The writer tweaks a pending change by **typing in it**;
Reject restores the prior text exactly. This is the same review-in-the-document
paradigm the platform's own AI edit sessions use, so it reads as native even
though the decorations are the extension's. (The v0.1.0 multi-diff Keep/Undo
surface is superseded — the spike showed CodeLens cannot render in a diff editor,
§7.1/D18.)
- **Built-in Markdown preview.** The writer's normal "Open Preview to the Side";
with annotations on, authorship colors and change marks appear *inside* it. A
command/setting toggles annotations.
- **Comments gutter.** Region threads via the native Comments API (create on
selection, reply, resolve). On a coedited document, the machine replies in-thread
- **Comments gutter — the primary ask surface (D19).** Region threads via the
native Comments API (create on selection, reply, resolve). The comment box is the
one native multi-line input the editor offers, so it is **how the writer asks for
anything**: an imperative ask ("tighten this paragraph") and a discussion remark
are the same gesture. On a coedited document, the machine replies in-thread
and offers an edit (BUC-5 / PUC-8); the offer, when accepted, becomes a pending
proposed change reviewed in the multi-diff. A comment can be **started from the
proposed change reviewed in the buffer (PUC-2). A comment can be **started from the
rendered preview** (select text there → "Comment on this"): the selection is mapped
back to the source range and a **native** thread is created — the preview shows a
💬 marker that deep-links to it, but the thread itself renders in the gutter /
@@ -419,7 +445,7 @@ panel to wireframe. Described in text (source of truth):
### 5.2 Screen: native surfaces (serves PUC-1…PUC-6)
Covered in the §5 text above. No custom screen; states map to platform states (diff
editor empty/identical, multi-diff empty, preview loading/error) plus the
editor empty/identical, no pending changes, preview loading/error) plus the
coauthoring **displaced** state surfaced in the thread / proposed-change listing.
## 6. Technical Design
@@ -442,9 +468,15 @@ coauthoring **displaced** state surfaced in the thread / proposed-change listing
`threads` share one envelope; **unknown fields are preserved** on read/merge, so
the artifact round-trips to an outer review forge (e.g. Gitea/GitHub) without
loss.
- **INV-5 — proposed changes are pending until kept:** a machine-proposed edit does
**not** mutate the document until the writer keeps it (Keep in the multi-diff, or
Accept). The machine proposes; the human disposes.
- **INV-5 — proposed changes are pending until kept:** a machine-proposed edit
never **settles** until the writer keeps it. It appears in the live buffer as a
visibly pending, decorated change (D18) — but it is fully revertible: **Reject
restores the prior text exactly**, the model retains each proposal's original,
and nothing is attributed as landed machine work until Keep. The machine
proposes; the human disposes. *(Amended in v0.2.0: v0.1.0 kept the buffer
byte-untouched until Keep; the guarantee is now "nothing lands irrevocably,"
which is what the writer actually needs — and what makes tweak-by-typing
possible, INV-12.)*
- **INV-6 — pure cores stay vscode-free:** attribution, anchoring, merge, diffing,
and the progress reducer are pure modules with no `vscode` import, unit-tested in
isolation; controllers are the only `vscode`-aware layer, so surfaces can change
@@ -486,24 +518,28 @@ coauthoring **displaced** state surfaced in the thread / proposed-change listing
remains, so re-entering restores state.
- **INV-11 — the native diff is author-blind:** the native diff editor shows *what*
changed against the baseline, not *who* changed it (the diff API has no per-author
color scoping — §6.7). Authorship (human vs. machine) is shown on a surface the
extension renders: the annotated preview (and optionally a decoration-based
track-changes editor), never by recoloring the native diff.
color scoping — §6.7). Authorship (human vs. machine) is shown on surfaces the
extension renders: the annotated preview and the in-editor author-colored
track-changes decorations (D21), never by recoloring the native diff.
- **INV-13 — no surface requires the Source Control pane:** the primary,
always-visible entry points for a coedited document are the **editor title-bar
actions** and a **status-bar item** (both gated on `cowriting.isCoediting`), and the
review **auto-opens** on new machine proposals. The SCM viewlet entry and the gutter
actions** and a **status-bar item** (both gated on `cowriting.isCoediting`), and
new machine proposals land **in the buffer in front of the writer** (D18) — the
review requires no navigation at all. The SCM viewlet entry and the gutter
dirty-diff are additional native affordances, not the required path — a writer
unfamiliar with git / Source Control never has to find them to coedit, review, or
settle a checkpoint.
- **INV-12 — proposed changes are editable before they are kept:** the review
surface presents the machine's proposal on a **writable** side, so the writer can
tweak it in place before keeping it (the common "close, but a few human edits"
case). The live document stays untouched until Keep (INV-5 still holds); on Keep,
the words the machine proposed are attributed to the machine and the writer's
tweaks to the writer — so authorship is correct **at the moment the change is
made**, never reconstructed afterward. Editing happens at review time; once kept,
the text is just text (no lingering "trapped proposal" state to untangle).
- **INV-12 — proposed changes are editable before they are kept:** a pending
change sits in the live buffer, so the writer tweaks it by **typing directly in
it** (the common "close, but a few human edits" case) — no separate proposal
document, no mode. Nothing settles until Keep (INV-5); on Keep, the words the
machine proposed are attributed to the machine and the writer's tweaks to the
writer — by diffing *final ↔ the machine's original proposal ↔ baseline* — so
authorship is correct **at the moment the change is made**, never reconstructed
afterward. Once kept, the text is just text (no lingering "trapped proposal"
state to untangle). A pending change re-anchors around the writer's edits; one
displaced by heavy rewriting collapses to a whole-proposal Keep/Reject (D20,
INV-1 discipline).
### 6.2 High-level architecture
@@ -523,7 +559,7 @@ flowchart LR
subgraph Ctrls["Thin controllers (vscode-aware)"]
SC[SourceControl + QuickDiffProvider]
BP[Baseline provider<br/>TextDocumentContentProvider]
PR[Review controller<br/>multi-diff Keep/Undo]
PR[Pending-change controller<br/>in-buffer decorations + CodeLens]
PV[Preview extension<br/>extendMarkdownIt]
TC[ThreadController<br/>Comments API]
LP[liveProgress relay]
@@ -532,7 +568,7 @@ flowchart LR
subgraph Native["Native VS Code surfaces"]
GUT[Gutter dirty-diff + SCM viewlet]
DIFF[Native diff editor]
MDIFF[Multi-diff review + Keep/Undo]
MDIFF[Live editor buffer<br/>pending changes + CodeLens]
MDPREV[Built-in Markdown preview]
COMM[Comments gutter/peek]
NOTE[Notification + OutputChannel]
@@ -555,9 +591,10 @@ flowchart LR
- **Baseline provider** — a `TextDocumentContentProvider` for the
`cowriting-baseline:` scheme; serves the pinned baseline text for any document URI
from the out-of-repo baseline store (INV-7).
- **Review controller** — turns the machine's emitted edits into pending proposed
changes, opens the multi-diff review, and applies Keep/Undo by delegating to the
proposed-change model; honors INV-5.
- **Pending-change controller** — turns the machine's emitted edits into pending
proposed changes, lands them in the live buffer as decorated pending text
(retaining each proposal's original for exact revert), and renders per-hunk
Keep/Reject CodeLens; Keep settles + attributes, Reject restores; honors INV-5.
- **Preview extension** — contributes `markdown-it` so the pure `renderAnnotations`
transform runs inside the built-in preview; owns no webview.
- **Sidecar router + store** — persistence: a repo-committed sidecar when the
@@ -606,23 +643,31 @@ state (INV-7), not durable coauthoring metadata.
document always resolves a baseline; non-coedited documents resolve none.
- **QuickDiffProvider**`provideOriginalResource(docUri) → cowriting-baseline:` URI
(drives gutter bars). Registered on the "Cowriting" `SourceControl`.
**Spike-verified (E2, 2026-07-01):** gutter bars and the native diff render in both
baseline modes, including for a file in **no repository at all**;
`Repository.show('HEAD', path)` and `state.onDidChange` behave as assumed (the
change event is coarse — key the re-read off `state.HEAD.commit`); a **nested**
repo may need explicit `openRepository()` discovery before `getRepository()`
resolves.
- **Machine-edit seam** — the single boundary where machine text enters the model:
`proposeAgentEdit(docUri, range, newText, provenance)` → produces **pending**
`proposals[]` entries (never mutates the doc — INV-5). Block/word granularity via
`proposals[]` entries; nothing settles until Keep (INV-5). Block/word granularity via
the pure diff engine (`diffToBlockHunks` / `wordEditHunks`): a block (paragraph,
header, list item, fenced code) is the *decision* unit a writer keeps or rejects;
the word diff inside it is the *attribution* unit, so keeping a block attributes
only the words that actually changed.
- **Review controller**`openReview(docUri)` opens the multi-diff
(`baseline ⟷ proposed`); `keep(proposalId)` = Accept (applies the edit, attributes
changed words); `undo(proposalId)` = Reject (drops it); `keepAll()` / `undoAll()`
= Keep/Reject all (apply descending, skip + report any displaced change). The
**proposed side is a writable scratch document** (untitled / in-memory) seeded with
the machine's text (INV-12); the left side (baseline) is read-only. `keep` diffs
the **final** (possibly writer-tweaked) proposal against the baseline, applies it
to the live document, and attributes machine-proposed words to the machine and
writer tweaks to the writer by comparing *final ↔ the machine's original proposal*.
The live document is untouched until `keep` (INV-5).
- **Pending-change controller**on new proposals, lands each **optimistically in
the live buffer** as decorated pending text (the model retains the proposal's
`original` for exact revert) and renders per-hunk **✓ Keep / ✗ Reject** CodeLens
(D18). `keep(proposalId)` = Accept: settles the change and attributes it —
machine-proposed words to the machine, any writer tweaks (typed directly into the
pending text, INV-12) to the writer, by comparing *final ↔ the machine's original
proposal ↔ baseline*. `reject(proposalId)` restores the retained original
exactly. `keepAll()` / `rejectAll()` apply descending, skip + report any
displaced change. Pending changes re-anchor around writer edits; a displaced one
collapses to whole-proposal Keep/Reject (D20). `openReview(docUri)` opens the
**native diff editor** (`baseline ⟷ live`) as the sweep-everything reference
view — read-only, author-blind (INV-11); decisions stay in the buffer.
- **Preview extension**`extendMarkdownIt(md) → md.use(cowritingPlugin)`; the
plugin invokes the pure `renderAnnotations(tokens, attributions, proposals,
options)` transform, gated by the `cowriting.annotations` setting/command.
@@ -637,8 +682,12 @@ state (INV-7), not durable coauthoring metadata.
`isCoediting(docUri)` before attaching a surface: the QuickDiffProvider returns no
original for non-coedited docs, the comment-response controller ignores them, and
the annotation/edit commands are disabled (`when` clause `cowriting.isCoediting`).
- **ThreadController + comment-response** — create/reply/resolve via the Comments
API. On a coedited document only, a new comment fires `runEditTurn` to **reply in
- **ThreadController + comment-response — the primary ask surface (D19)**
create/reply/resolve via the Comments API. The comment box is the editor's one
native multi-line input, so **asking is commenting**: the title-bar **Ask
Claude** action just creates a thread (on the selection, else whole-document) and
focuses its input; no bespoke input surface exists. On a coedited document only,
a new comment fires `runEditTurn` to **reply in
the thread** and, when the remark implies a change, **offer an edit**; an accepted
offer routes through the machine-edit seam to a **pending** proposed change
(INV-5) — the comment never edits the document directly. The reply is posted as a
@@ -648,6 +697,18 @@ state (INV-7), not durable coauthoring metadata.
but cannot host the thread; the Comments API renders only in the gutter + Comments
panel), or on the **whole document** (anchored at line 0, full-document context).
All threads are native — the preview never hosts comment UI (INV-3).
**Spike-verified API seams (E4, 2026-07-01):** (1) the editor **caches commenting
ranges per document** — when the CoeditingRegistry gate flips for an already-open
document, the controller must **re-assign `commentingRangeProvider`** (its setter is
the API's only ranges-changed signal), or the `+` gutter and Add Comment stay stale
("cursor must be within a commenting range"); (2) there is **no API to focus a
thread's input** — "Ask Claude" focuses the comment box by invoking the built-in
**`workbench.action.addComment`** (cursor moved to the top first for a
whole-document ask), not by `createCommentThread` + `Expanded`; (3) the
thread-contextValue menu `when` key is **`commentThread`** (not
`commentThreadContextValue`); the "make this edit" offer is carried as contributed
thread actions (`comments/commentThread/title` + `…/context`), optionally
reinforced by an in-body command link (requires `isTrusted.enabledCommands`).
### 6.5 PerProduct-Use-Case design
@@ -677,34 +738,37 @@ sequenceDiagram
```mermaid
sequenceDiagram
actor U as Writer
participant R as Review ctrl
participant R as Pending-change ctrl
participant S as Machine runtime
participant M as Multi-diff editor
participant E as Live editor buffer
U->>R: Ask the machine to revise
R->>S: runEditTurn(prompt, {onProgress, signal, env})
S-->>R: proposed text
R->>R: proposeAgentEdit → pending proposals[] (INV-5)
R->>M: openReview (baseline ⟷ proposed)
U->>M: Keep / Undo per change · Keep all / Reject all
M->>R: keep(p)/undo(p)
R-->>U: document updated only on Keep; authorship recorded
R->>E: land pending changes (decorated, original retained)
U->>E: tweak by typing · Keep / ✗ Reject per change (CodeLens)
E->>R: keep(p)/reject(p) · keepAll()/rejectAll()
R-->>U: Keep settles + attributes · Reject restores the original exactly
```
- **Implementation:** the machine never edits the document; it emits **pending**
proposed changes (INV-5). The multi-diff editor is the review surface; Keep =
Accept (apply + attribute changed words), Undo = Reject. Keep-all/reject-all apply
descending and skip + report any displaced change. **Stable-API boundary:** the
multi-diff editor is stable; per-change inline **Keep/Undo overlays** as some AI
tools render them lean on evolving editor APIs — the **stable fallback** is
per-hunk **CodeLens** Keep/Reject within the diff (§6.10, §13-Q2).
- **Editable before keep (INV-12):** the proposal renders on the **writable** side of
the review, so the writer can tweak the machine's wording ("close, but a few human
edits") before keeping. `keep` attributes the machine's words and the writer's
tweaks separately by diffing *final ↔ machine-original ↔ baseline*. Because editing
happens on the proposal (a scratch doc), not the live document, INV-5 holds and
authorship is correct at creation — the preview (Option 1, the authorship surface)
then shows clean green/blue with no post-hoc untangling. *Open detail (§13-Q7):*
how per-change Keep/Undo behaves once the writer has freely edited across hunks.
- **Implementation:** the machine emits **pending** proposed changes; each lands in
the live buffer as a decorated pending change with its original retained, and
nothing settles until Keep (INV-5). Per-hunk **CodeLens** carries ✓ Keep /
✗ Reject (with a Keep-all/Reject-all menu); keep-all/reject-all apply descending
and skip + report any displaced change. The **native diff editor**
(`baseline ⟷ live`) is the sweep-everything reference view; the buffer is where
decisions happen. **Why in-buffer (D18):** the rung-2 spike verified VS Code does
**not** render extension CodeLens inside a diff editor, ruling out the v0.1.0
multi-diff Keep/Undo surface; the shipped F12 experience proved the in-buffer
model end-to-end, and it matches the platform's own AI edit-session paradigm.
- **Editable before keep (INV-12):** the pending change sits in the live buffer, so
the writer tweaks it by typing directly in it ("close, but a few human edits")
before keeping. `keep` attributes the machine's words and the writer's tweaks
separately by diffing *final ↔ machine-original ↔ baseline*, so authorship is
correct at creation — the preview and the in-editor track-changes then show clean
green/blue with no post-hoc untangling. Pending changes **re-anchor** around
writer edits; one displaced by heavy rewriting collapses to a whole-proposal
Keep/Reject (D20, resolving the old Q7).
#### Always-visible entry points (no Source Control navigation) — INV-13
@@ -713,8 +777,9 @@ sequenceDiagram
it exists). When `cowriting.isCoediting` is set, the actions surface where the
writer already is: **editor title-bar buttons** (Review Changes, Ask Claude, Toggle
Annotations, and in snapshot mode Mark Changes as Reviewed) and a **status-bar
item** (`✦ Coediting · N changes`, click → Review Changes). The multi-diff review
**auto-opens** on new proposals, and the live-progress notification carries a
item** (`✦ Coediting · N changes`, click → Review Changes). New proposals land
**in the buffer in front of the writer** (D18 — the review comes to them), and
the live-progress notification carries a
*Review* action. The SCM viewlet entry and the gutter dirty-diff remain as native
extras for git-literate users, never the required path.
@@ -774,7 +839,7 @@ sequenceDiagram
T-->>U: machine reply posted in thread (onBehalfOf provenance)
U->>T: accept the offered edit
T->>R: proposeAgentEdit → pending proposal (INV-5)
R-->>U: review in multi-diff (PUC-2)
R-->>U: pending change lands in the buffer (PUC-2)
```
- **Implementation:** gated on `isCoediting` (INV-10) — inert otherwise. The turn
@@ -809,7 +874,7 @@ sequenceDiagram
- **Observability:** the OutputChannel stream (PUC-5) is the user-facing turn log;
no telemetry service (a local extension — §9).
- **Accessibility:** inherited from the native surfaces — the diff editor,
multi-diff, Markdown preview, and Comments all carry the editor's accessibility.
CodeLens, Markdown preview, and Comments all carry the editor's accessibility.
This is a primary *reason* for the native-first choice.
### 6.7 Key decisions & alternatives considered
@@ -817,20 +882,21 @@ sequenceDiagram
| Decision | Chosen | Alternatives considered | Why chosen |
| --- | --- | --- | --- |
| Diff surface | `QuickDiffProvider` + native diff editor against a `cowriting-baseline:` virtual doc | bespoke webview diff; decoration-only inline diff | Native gutter + diff editor for free: theming, inline/side toggle, a11y, navigation; zero custom rendering (INV-3) |
| Accept/reject surface | **Multi-diff editor with Keep/Undo**, backed by the proposed-change model | source-control stage/unstage; in-editor CodeLens Keep/Reject; bespoke webview ✓/✗ | Closest to a modern "review the machine's edits" feel; reviews all changes in one native surface. Stable fallback: per-hunk CodeLens (§6.10) |
| Accept/reject surface (v0.2.0, D18 — supersedes the v0.1.0 multi-diff choice) | **Pending changes in the live buffer** with per-hunk **✓ Keep / ✗ Reject CodeLens**, backed by the proposed-change model; native diff editor as the read-only sweep view | multi-diff editor with Keep/Undo (v0.1.0 choice — **ruled out**: the spike verified CodeLens does not render in a diff editor, and inline overlays lean on evolving APIs); a separate writable proposal document (spike E1 pivot — works, but puts an artifact between the writer and their document); source-control stage/unstage; bespoke webview ✓/✗ | The platform's own AI edit-session paradigm: review the change in the document, tweak by typing, revert in one gesture. Shipped and proven end-to-end (F12); all-stable APIs (decorations + CodeLens) |
| Rendered preview | **Extend the built-in Markdown preview** (`markdown-it` contribution) | bespoke sealed webview; both surfaces | Native scroll-sync, theming, refresh, and cooperation with the writer's other Markdown extensions; no webview to own (INV-3) |
| Baseline source | follows the file's home (INV-7): **git `HEAD`** where tracked (resets on commit, storage-free), a **snapshot** (set on entry, re-pinnable; global storage / in-memory) where there is no commit to anchor to — outside any repo, untracked, or untitled | always a stored snapshot; always defer to git (breaks outside a repo) | Git-tracked files get the native "uncommitted work, reset on commit" diff for free; `~/Downloads` / untitled still work via the snapshot fallback. Mirrors the in-repo/out-of-repo `SidecarRouter` |
| Persistence envelope | one forward-compatible, unknown-field-preserving sidecar (INV-4) | a bespoke per-feature format; preview-only ephemeral state | Round-trips to an outer review forge; survives reload; one shared `anchors`/`provenance` primitive |
| Core/controller split | pure vscode-free cores + thin controllers | UI-coupled logic | Unit-testable cores, swappable surfaces; the surface can change without touching the logic (INV-6) |
| Decision vs attribution unit | block = decision unit (keep/reject); word = attribution unit | per-word proposals (too granular to decide on); per-file (all-or-nothing) | The writer decides at a readable grain (a paragraph) while authorship stays word-precise |
| Authorship surface | **Option 1** — the annotated preview is the authorship view (green/blue track-changes incl. deletions); native diff stays author-blind | Option 2 (in-editor decorations); Option 3 (owned author-colored track-changes diff that replaces native diff) | Preview shows author-colored changes for near-zero maintenance; editable proposals (INV-12) keep authorship clean *at creation*, so in-editor / post-hoc authorship rendering is unnecessary. Opts 2/3 deferred |
| Editing proposals | the proposed side of the review is **writable**; pre-Keep tweaks are absorbed and attributed (machine vs. writer) at Keep (INV-12) | machine proposal read-only (tweak only after accepting) | Most revisions are "close, but a few human edits"; editing at review time keeps authorship clean at creation and avoids messy post-acceptance untangling |
| Authorship surface (v0.2.0, D21 — updates D11 to shipped reality) | the annotated preview **and** the in-editor author-colored track-changes decorations (both shipped); native diff stays author-blind (INV-11) | preview-only (v0.1.0 D11 — overtaken: the in-editor decorations shipped 2026-06-27 and earn their keep); Option 3 (owned author-colored track-changes diff replacing native diff) | Both surfaces ride the same pure render/attribution cores; the writer gets authorship where they read (preview) and where they write (editor). Opt 3 still out |
| Editing proposals | pending changes are **tweaked by typing directly in the buffer**; pre-Keep tweaks are absorbed and attributed (machine vs. writer) at Keep (INV-12) | a separate writable proposal document (v0.1.0); machine proposal read-only (tweak only after accepting) | Most revisions are "close, but a few human edits"; editing in place, at review time, keeps authorship clean at creation with no extra artifact and no messy post-acceptance untangling |
| Ask surface (v0.2.0, D19) | **comments-first** — the native comment thread input is the ask input; title-bar "Ask Claude" creates/focuses a thread (selection or whole-document) | the shipped bespoke split-pane input webview (sunset — one custom surface eliminated); single-line `InputBox` (too small for real asks) | The Comments API is the editor's one native multi-line input; ask-for-an-edit and remark-on-a-passage collapse into a single familiar gesture, and the machine's answer lands in the same thread (D8/D10) |
| Coediting activation | opt-in per document via "Coedit this Document with Claude"; every surface gated on the active coediting set (INV-10) | always-on for all markdown; opt-in per workspace/folder | Per-document opt-in is the only grain that avoids hijacking Comments/SCM/preview for the writer's other files while keeping single-file precision |
| Change discovery (resolves Q3) | primary entry = **editor title bar + status-bar item + auto-open review**; the SCM viewlet + gutter are optional extras (INV-13) | rely on the Source Control pane / gutter as the entry point | Writers working outside git won't find (or know) the Source Control pane; always-visible title-bar / status-bar actions need no git familiarity |
| Change discovery (resolves Q3) | primary entry = **editor title bar + status-bar item**, and proposals land **in the buffer in front of the writer** (D18); the SCM viewlet + gutter are optional extras (INV-13) | rely on the Source Control pane / gutter as the entry point | Writers working outside git won't find (or know) the Source Control pane; always-visible title-bar / status-bar actions need no git familiarity |
| Authorship in the diff | the native diff is **author-blind** (what changed); authorship is shown in the preview, optionally a decoration track-changes editor (INV-11) | recolor machine changes inside the native diff editor | **Not possible:** `diffEditor.*` colors are global theme tokens with no per-author scoping; `inlineEdit.*`/chat tokens differentiate accept-state not author; decorations over the diff pane composite over green/red and are unreliable (vanish on tab-switch). Verified against the VS Code API (§13-Q5) |
| Comment → machine response | on a coedited doc, a comment fires a turn that replies + offers an edit → pending proposal | comment is inert (human-only threads) | Turns the familiar review-comment gesture into a coediting action without a new surface; the opt-in gate (INV-10) keeps it from firing on other docs |
| Comments & the preview | comments are **native** (source gutter + Comments panel); the preview can *initiate* a comment (selection → source range via the `markdown-it` source map) and show a deep-linking 💬 marker, but cannot **host** the thread (it's a webview — verified) | render a bespoke comment widget inside the preview webview | Keeps comments on the native surface (no INV-3 exception, keeps the Comments panel + gutter); the writer can still comment on what they're reading and add whole-document comments (top-anchored) |
| Scope | design-only north star (no packaging/migration commitment) | commit a packaging + delivery sequence now | Keeps the architecture decision separate from the delivery decision (§2, §7.1) |
| Scope & packaging (v0.2.0, D17 — resolves Q1) | north star **with a committed vehicle**: the shipped extension **evolves in place** onto these surfaces (pure cores retained, webview surfaces sunset) | greenfield extension importing the cores as a library (re-derives wiring + E2E harness the shipped repo already has); stay design-only (stalls — §7.3) | ~60% of the shipped code (21 pure modules, ~3.5k LOC) *is* this design's cores already; evolve-in-place keeps tests, history, and continuous shipping |
### 6.8 Testing strategy
@@ -841,15 +907,18 @@ sequenceDiagram
markdown-it transform (token-in → annotated-token-out).
- **Integration / E2E (`@vscode/test-electron`):** drive the controllers in the
Extension Development Host — mark-reviewed → assert checkpoint reset; ask-to-edit
→ assert pending proposals + multi-diff opened; Keep/Undo → assert document
mutation only on Keep + authorship recorded; toggle annotations → assert preview
→ assert pending proposals landed decorated in the buffer with originals
retained; Keep/Reject → assert Keep settles + attributes and Reject restores the
original text exactly; toggle annotations → assert preview
HTML marks; thread create → re-anchor → displaced. Where the harness cannot drive
a native surface's chrome directly (multi-diff Keep/Undo, Comments UI, preview
a native surface's chrome directly (CodeLens clicks, Comments UI, preview
DOM), assert controller/provider state and pure-transform output instead. **No
Playwright** — these are native surfaces, not a webview. E2E are first-class plan
tasks (handbook §4 / §6.8).
tasks (handbook §4 / §6.8). The shipped repo's 19 E2E suites carry forward under
evolve-in-place (D17).
- **Named E2E scenarios this design must pass:** PUC-1 pin→diff; PUC-2
propose→review→keep/undo→keep-all; PUC-3 annotate-toggle; PUC-4/6
propose→tweak→keep/reject→keep-all (incl. reject-restores-exactly); PUC-3
annotate-toggle; PUC-4/6
thread→reanchor→displaced; PUC-5 progress→cancel.
### 6.9 Failure modes, rollback & flags
@@ -860,63 +929,99 @@ sequenceDiagram
"start writing, or ask Claude to draft something" affordance; not an error. A
coedited document always has a baseline (set on entry, INV-7), so there is **no
"missing baseline" state** to handle.
- **Multi-diff Keep/Undo API unavailable/changed** → fall back to per-hunk CodeLens
Keep/Reject in the diff editor (§6.10); the proposed-change *model* is unchanged.
- **Save while changes are pending** → the buffer (and on save, the disk file)
contains visibly-pending machine text; safe because every pending proposal's
original is retained in the sidecar and Reject restores it exactly after reload
(INV-5, re-anchored on load per INV-1/PUC-6).
- **Feature flags / settings:** `cowriting.annotations` (preview annotations on/off),
`cowriting.liveProgress.revealOutput`. The whole capability is additive — off by
absence of coediting mode for the document (INV-10).
### 6.10 Migration & compatibility
**Greenfield — nothing to migrate.** This is a from-scratch design for in-editor
human↔machine markdown coediting; it does not replace an existing data format or
contract that must be carried.
**Evolve in place (D17, resolves Q1).** The v0.1.0 draft called this greenfield;
that undersold the shipped extension: **~60% of its code — 21 pure, vscode-free
modules (~3.5k LOC) — already implements this design's cores** (attribution span
algebra, fingerprint anchoring, the sidecar envelope + router + merge, the
block/word diff engine, the render/annotation transform, the turn-progress
reducer, the machine turn runner). The delivery vehicle is therefore the shipped
extension itself, migrated **surface by surface**:
- **Data migration:** none. The sidecar envelope is additive and
unknown-field-preserving (INV-4) from v1, so future versions extend it without a
break. The baseline (out-of-repo, disposable) needs no migration ever.
- **Backwards compatibility:** N/A at v1 (nothing precedes it); the envelope's
forward-compatibility rule (INV-4) protects future consumers.
- **Cutover:** N/A (design-only; no packaging committed — §7.1).
- **Stable-API boundary (recorded risk):** the native diff editor, SCM/QuickDiff,
multi-diff editor, Markdown-preview `markdown-it` contribution, Comments, and
decorations are **stable**. The per-change inline **Keep/Undo** overlay that some
AI tools render uses **evolving** editor APIs; the design's stable contract is
therefore "multi-diff review + per-hunk Keep/Reject CodeLens," with the inline
overlay as a progressive enhancement where the API allows (§13-Q2).
- **Retained as-is (the cores):** the 21 pure modules and their unit suites; the
sidecar contract (INV-4) and stores; the `@cline/sdk` turn runner + live
progress; the in-buffer pending-change experience (F12) and the in-editor
author-colored track-changes decorations — both already match this design (D18,
D21).
- **Rewired (thin controllers):** baseline moves behind the router (git `HEAD` +
snapshot, INV-7 — today it is snapshot-only); a QuickDiffProvider +
`cowriting-baseline:` content provider replace nothing (net-new native surface);
the CoeditingRegistry opt-in gate (INV-10) replaces the current always-on-for-
markdown behavior; ThreadController gains the comment-response loop (D8/D10).
- **Sunset (the bespoke webview stack, ~800 LOC):** the sealed review-panel
webview (`trackChangesPreview` + its client) is replaced by the extended
built-in Markdown preview (D3); the split-pane ask-input webview is replaced by
comments-first asking (D19). Each sunset lands only after its native replacement
is green.
- **Data migration:** none needed — the sidecar envelope carries forward
(additive, unknown-field-preserving, INV-4). The baseline (out-of-repo,
disposable) needs no migration ever.
- **Stable-API boundary:** every surface this design now stands on — diff editor,
SCM/QuickDiff, Markdown-preview `markdown-it` contribution, Comments,
decorations, CodeLens — is **stable API**. The one evolving-API worry from
v0.1.0 (per-change overlays in a multi-diff review) is gone with the in-buffer
decision (D18).
## 7. Delivery Plan
### 7.1 Approach / strategy
This is a **design-only north star** — it ships no code. Delivery proceeds in **four
de-risking rungs, riskiest-API-assumption first**, so any forced design change is
discovered on throwaway code rather than after the full build (the explicit goal:
post-ship iteration is *polish*, not *design*):
Delivery proceeds in **four de-risking rungs, riskiest-API-assumption first**, so
any forced design change is discovered on throwaway code rather than after the
full build (the explicit goal: post-ship iteration is *polish*, not *design*).
The vehicle is the **shipped extension, evolved in place** (D17/§6.10):
1. **Static mockups (done).** Validate visual design + state coverage. *(Already
surfaced and closed the empty-state, comments-from-preview, and discoverability
gaps.)*
2. **Throwaway feasibility spike (next).** A disposable VS Code extension (a §5.3
*prototype*; the machine **stubbed** with canned edits) whose only job is to prove
the API-dependent assumptions on the real surfaces and return a **go/no-go per
assumption — closing the build-time open questions (Q2, Q4, Q7) before any plan is
written.** Riskiest first: (a) the multi-diff proposed side is **editable** with
working **Keep/Undo** (INV-12); (b) the **baseline router** renders QuickDiff
against a snapshot *and* git `HEAD`, going clean on commit / Mark-Reviewed (INV-7);
(c) the **`markdown-it`** plugin injects authorship + deletions in the built-in
preview with a toggle (PUC-3); (d) the **comment → reply → offer → proposal** loop
(PUC-8); (e) the **title-bar + status-bar** discoverability reads as obvious
(INV-13). Companion: `coauthoring-feasibility-spike.md`. Output: a findings report
+ any spec deltas folded back here.
3. **Vertical slice.** One real end-to-end flow (enter → Ask Claude via the **real
`@cline/sdk`** → real proposal → review → keep) on a real document, minimal
breadth — proving the loop works for real once the surfaces are validated.
4. **Full build.** The implementation plan decomposed from this (spike-revised) spec,
built surface-by-surface in the validated order: **pure cores → diff surface**
(highest-confidence native win) **→ preview extension → multi-diff accept/reject**
(largest API-boundary risk, last, with the CodeLens fallback ready). One SPEC →
one plan → one execution per increment.
2. **Throwaway feasibility spike (complete — 5 of 5 experiments, 2026-07-01).** A
disposable VS Code extension (`vscode-cowriting-prototype`; the machine
**stubbed** with canned edits) proving the API-dependent assumptions on the
real surfaces. Companion: `coauthoring-feasibility-spike.md`. **Findings:**
- **E1 (editable proposals + Keep/Undo) — PASS with pivot.** VS Code does
**not** render extension CodeLens inside a diff editor, ruling out the
multi-diff review surface; per-hunk CodeLens works in a normal editor, and
tweak-then-Keep attributes machine words vs. writer edits correctly. Folded
back as **D18** (with the shipped F12 experience): the review surface is the
live buffer itself.
- **E3 (preview authorship) — PASS.** The `markdown-it` contribution injects
author-colored marks + deletions into the built-in preview, theme-aware,
with a working toggle (D3 confirmed).
- **E5 (discoverability) — PASS.** Title-bar + status-bar entry points read as
obvious with no Source Control pane (INV-13 confirmed).
- **E2 (baseline router) — PASS.** QuickDiff gutter bars + the native diff
render in both modes, driven end to end: git **`HEAD`** (tracked file — the
baseline advanced automatically on commit) and **snapshot** (a file in no
repo at all — "Mark Changes as Reviewed" re-pinned and the diff went clean).
INV-7/D13/D14 confirmed; API notes folded into §6.4.
- **E4 (comment → reply → offer → proposal) — PASS with fallback.** The full
loop works on the real Comments API (thread → turn with progress/cancel →
in-thread machine reply → offer → pending in-buffer proposal). Three API
seams required workarounds, folded into §6.4: commenting-range refresh on
gate change, input focus only via `workbench.action.addComment`, and the
`commentThread` menu when-key. *(The preview-selection → source-thread half
of E4/D15 was not spiked — confirm at the preview-surface build.)*
- **The spike is complete; delivery moves to rung 3 (vertical slice) / rung 4.**
3. **Vertical slice.** One real end-to-end flow (enter → ask via a comment thread
with the **real `@cline/sdk`** → real pending change in the buffer → tweak →
keep) on a real document, minimal breadth — proving the loop works for real
once E2/E4 are validated.
4. **Full build (the evolve-in-place migration, §6.10).** The implementation plan
decomposed from this (spike-revised) spec, migrated surface-by-surface in the
validated order: **baseline router + QuickDiff/diff surface** (E2) **
coediting opt-in gate → preview extension replaces the review-panel webview →
comments-first ask + comment-response loop replaces the input webview** (E4) —
each bespoke surface sunsetting only after its native replacement is green.
One SPEC → one plan → one execution per increment.
### 7.2 Rollout / launch plan
@@ -928,11 +1033,13 @@ host E2E green (§6.8). Marketplace packaging/release is out of scope here.
| Risk | Likelihood / impact | Mitigation |
| --- | --- | --- |
| Per-change Keep/Undo overlay relies on evolving editor APIs | M / M | Stable contract = multi-diff + per-hunk CodeLens Keep/Reject; overlay is progressive enhancement (§6.10) |
| Baseline router assumptions fail on real git repos | retired | **E2 PASSed (2026-07-01)** — both baseline modes verified on the real API; snapshot mode remains the always-works fallback (INV-7) |
| Comment-response loop feels laggy or noisy with the **real** SDK (E4 spiked with a ~2s stub) | M / M | E4 PASSed on feel with the stub; real-turn latency lands at the rung-3 vertical slice; D10's off-switch ("Stop editing with Claude") bounds the blast radius |
| Save-while-pending leaves machine text on disk before a decision | M / L | Pending state + originals persist in the sidecar; Reject restores exactly after reload (§6.9); status-bar count keeps pending changes visible |
| Two source controls (Git + Cowriting) confuse the SCM viewlet | L / M | Multiple SCM providers coexist by design; name it clearly; QuickDiff alone (no full SCM) is the minimal fallback |
| Built-in preview can't host interactive ✓/✗ inside the preview | M / L | Accept/reject lives in the multi-diff surface, not the preview; the preview is read/annotate only (a deliberate split) |
| Native surfaces are harder to E2E than a queryable webview | M / L | Assert controller/provider state + pure-transform output |
| Design-only spec stalls (never delivered) | M / L | §13-Q1 names the delivery decision and owner; the architecture stands regardless |
| Built-in preview can't host interactive ✓/✗ inside the preview | M / L | Accept/reject lives in the buffer (D18), not the preview; the preview is read/annotate only (a deliberate split) |
| Native surfaces are harder to E2E than a queryable webview | M / L | Assert controller/provider state + pure-transform output; the shipped E2E harness carries forward (D17) |
| Evolve-in-place migration destabilizes a working product mid-flight | M / M | Surface-by-surface order (§7.1 rung 4): each bespoke surface sunsets only after its native replacement is green; the pure cores never move |
## 8. Success Metrics & Measurement
@@ -948,7 +1055,7 @@ host E2E green (§6.8). Marketplace packaging/release is out of scope here.
| # | Metric (scores PUC-n) | Target | Fed by | Review cadence |
| --- | --- | --- | --- | --- |
| PM-1 | PUC-1 diff opens in the native diff editor with an accurate baseline⟷live | works for any file incl. untitled | host E2E | per build |
| PM-2 | PUC-2 no document mutation until Keep; each change independently keep/undo-able | 100% (INV-5) | host E2E | per build |
| PM-2 | PUC-2 no proposed change settles until Keep; Reject restores the original exactly; each change independently keep/reject-able | 100% (INV-5) | host E2E | per build |
| PM-3 | PUC-3 annotations render in the built-in preview and toggle clean | on/off both correct | unit + host E2E | per build |
## 9. Telemetry, Observability & Monitoring
@@ -1009,17 +1116,14 @@ a bug reaches us as a tracker issue.
| # | Question | Owner | Blocks |
| --- | --- | --- | --- |
| Q1 | Product packaging & delivery sequence — the decision to act on this north star | Ben | Any implementation plan |
| Q2 | Per-change Keep/Undo: how far can the stable API go before falling back to per-hunk CodeLens? | Ben | PUC-2 build (last surface) |
| Q4 | Does any need genuinely require a webview the native surfaces can't serve (INV-3 escape hatch)? | Ben | Per-surface build |
| Q7 | Once the writer freely edits the proposal across hunks (INV-12), how do per-change Keep/Undo behave — do per-hunk actions survive arbitrary edits, or does free editing collapse the change set to a single Keep/Reject-all? | Ben | PUC-2 build |
| Q4 | Does any need genuinely require a webview the native surfaces can't serve (INV-3 escape hatch)? — narrowed at v0.2.0: the two shipped webviews both have native replacements (D3, D19); the one candidate left is mermaid rendering in the preview, expected to ride `markdown.previewScripts` (the `markdown-mermaid` pattern), to be confirmed at the preview-surface build | Ben | Per-surface build |
**Resolved**
| # | Decision | Resolution | Date |
| --- | --- | --- | --- |
| D1 | Scope / altitude | Design-only north star — architecture + surface mapping, no packaging/migration commitment | 2026-06-27 |
| D2 | Accept/reject primary surface | Multi-diff editor with Keep/Undo (CodeLens stable fallback) | 2026-06-27 |
| D1 | Scope / altitude | Design-only north star — architecture + surface mapping, no packaging/migration commitment. *(Amended by D17: the packaging commitment is now taken.)* | 2026-06-27 |
| D2 | Accept/reject primary surface | Multi-diff editor with Keep/Undo (CodeLens stable fallback). *(Superseded by D18: the spike showed CodeLens cannot render in a diff editor.)* | 2026-06-27 |
| D3 | Rendered preview surface | Extend the built-in Markdown preview via a `markdown-it` contribution (no webview) | 2026-06-27 |
| D4 | Diff surface | `QuickDiffProvider` + native diff editor against a `cowriting-baseline:` virtual doc | 2026-06-27 |
| D5 | Core capabilities | Pinned baseline, char-precise authorship attribution, durable git-native sidecar, live cancellable progress, region threads, on-behalf-of identity, pure-core/thin-controller discipline | 2026-06-27 |
@@ -1028,12 +1132,17 @@ a bug reaches us as a tracker issue.
| D8 | Comments can summon the machine | On a coedited doc, a comment fires a turn that replies in-thread + offers an edit → pending proposal (BUC-5, INV-5); inert on non-coedited docs | 2026-06-27 |
| D9 | Native diff is author-blind | Verified the diff editor has no per-author color API; authorship lives in the preview (and optionally a decoration track-changes editor), not the native diff (INV-11) | 2026-06-27 |
| D10 | Comment trigger | While a document is in coediting mode, **every** new comment summons the machine (no per-comment gate); coediting mode is the control, "Stop editing with Claude" the off switch | 2026-06-27 |
| D11 | Authorship surface (Q5) | Option 1 — the annotated preview is the authorship view; native diff stays author-blind; in-editor decorations (opt 2) and an owned track-changes diff (opt 3) are out of scope | 2026-06-27 |
| D12 | Editable proposals | The review's proposed side is writable; the writer tweaks before Keep; machine words vs. writer tweaks are attributed at Keep (INV-12); live doc untouched until Keep (INV-5) | 2026-06-27 |
| D11 | Authorship surface (Q5) | Option 1 — the annotated preview is the authorship view; native diff stays author-blind; in-editor decorations (opt 2) and an owned track-changes diff (opt 3) are out of scope. *(Updated by D21: opt 2 shipped and is retained.)* | 2026-06-27 |
| D12 | Editable proposals | The review's proposed side is writable; the writer tweaks before Keep; machine words vs. writer tweaks are attributed at Keep (INV-12); live doc untouched until Keep (INV-5). *(Amended by D18/D20: the writable surface is the live buffer itself.)* | 2026-06-27 |
| D13 | Baseline semantics & source | Baseline = the last settled version. Its **source follows the file's home**: git `HEAD` where tracked (auto-resets on commit, storage-free), else a snapshot set on entry (global storage / in-memory). A coedited doc always has a baseline; the only empty state is a genuinely empty document (INV-7) | 2026-06-27 |
| D14 | Pin is the non-git fallback only (resolves Q8) | "Mark Changes as Reviewed" exists **only in snapshot mode** (no committed `HEAD`); git-tracked files have no manual pin — the writer **commits** and the baseline follows `HEAD`. The pin command is `when`-gated on `cowriting.baselineMode == snapshot` | 2026-06-27 |
| D15 | Comments & the preview | Native comment threads can't render in the preview (it's a webview — verified). The preview *initiates* a comment (selection → source range via the `markdown-it` source map) and shows deep-linking 💬 markers; a whole-document comment anchors at the top. Threads stay native (gutter + Comments panel); no bespoke in-webview comment UI | 2026-06-27 |
| D16 | Change discovery (resolves Q3) | No flow requires the Source Control pane (INV-13). Primary entry = editor title-bar actions + `✦ Coediting · N changes` status-bar item + auto-open review; SCM viewlet + gutter kept as optional git-user extras | 2026-06-27 |
| D16 | Change discovery (resolves Q3) | No flow requires the Source Control pane (INV-13). Primary entry = editor title-bar actions + `✦ Coediting · N changes` status-bar item + auto-open review; SCM viewlet + gutter kept as optional git-user extras. *(v0.2.0: "auto-open review" becomes "proposals land in the buffer in front of the writer," D18.)* | 2026-06-27 |
| D17 | Packaging & delivery vehicle (resolves Q1) | **Evolve the shipped extension in place** onto the native surfaces: the 21 pure core modules (~3.5k LOC, ~60%) are retained as-is, thin controllers are rewired, and the two bespoke webviews sunset only after their native replacements are green (§6.10). Greenfield rejected (re-derives wiring + E2E harness); staying design-only rejected (stalls) | 2026-07-01 |
| D18 | Accept/reject surface (resolves Q2; supersedes D2) | **Pending changes live in the buffer** — proposals land optimistically as decorated pending text (originals retained), decided by per-hunk ✓ Keep / ✗ Reject CodeLens, tweaked by typing. Grounds: the spike verified CodeLens does **not** render in a diff editor (killing the multi-diff surface); the shipped F12 proved the in-buffer model end-to-end; it matches the platform's own AI edit-session paradigm and uses only stable APIs. INV-5 reworded from "buffer untouched" to "nothing settles until Keep; Reject restores exactly" | 2026-07-01 |
| D19 | Ask surface | **Comments-first**: the native comment thread input is the ask input for both imperative asks and remarks; the title-bar "Ask Claude" creates/focuses a thread (selection, else whole-document); the shipped split-pane input webview sunsets. One custom surface eliminated; ask and discuss collapse into one native gesture (D8/D10) | 2026-07-01 |
| D20 | Tweaks × per-hunk decisions (resolves Q7; amends D12) | Free editing happens in the live buffer; each pending change **re-anchors** around writer edits so per-hunk Keep/Reject survives ordinary tweaking; a change displaced by heavy rewriting collapses to a whole-proposal Keep/Reject (skip + report in keep-all/reject-all) — the INV-1 discipline applied to proposals | 2026-07-01 |
| D21 | Authorship surfaces (updates D11) | The annotated preview **and** the in-editor author-colored track-changes decorations are both authorship surfaces (the latter shipped 2026-06-27, after D11 deferred it); both ride the same pure cores; the native diff stays author-blind (INV-11). Option 3 (owned track-changes diff) remains out | 2026-07-01 |
## 14. Glossary & References
@@ -1043,8 +1152,9 @@ a bug reaches us as a tracker issue.
Disposable; never committed by us (INV-7).
- **QuickDiffProvider** — the VS Code API that supplies gutter dirty-diff bars from a
virtual *original* document.
- **Multi-diff editor** — VS Code's native multi-change review surface; here the
accept/reject surface (Keep/Undo).
- **Pending change** — a machine-proposed edit landed optimistically in the live
buffer as decorated, unsettled text (original retained); decided by per-hunk
✓ Keep / ✗ Reject CodeLens, tweaked by typing (D18). The accept/reject surface.
- **Coauthoring sidecar** — the forward-compatible, unknown-field-preserving artifact
that persists anchors, authorship, proposed changes, and threads, and round-trips
to an outer review forge.
@@ -1059,8 +1169,8 @@ a bug reaches us as a tracker issue.
- **Pure core / thin controller** — the vscode-free, unit-tested logic vs. the
vscode-aware wiring split (INV-6).
- **References:** VS Code APIs — SCM/`QuickDiffProvider`, diff editor (`vscode.diff`),
multi-diff editor, Markdown preview `markdown-it` contribution, Comments,
`TextDocumentContentProvider`, decorations. *Prior art (patterns proven in earlier
Markdown preview `markdown-it` contribution, Comments,
`TextDocumentContentProvider`, decorations, CodeLens. *Prior art (patterns proven in earlier
coauthoring work, reused here on their own merits): baseline pinning, char-precise
attribution, the portable coauthoring sidecar, live turn progress, and editable
proposals (tweak-before-keep with mixed human/machine authorship).*