1253 lines
68 KiB
Markdown
1253 lines
68 KiB
Markdown
# Native Surfaces Migration 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:** Evolve the shipped cowriting extension in place onto VS Code's native review surfaces — opt-in coediting, git-HEAD/snapshot baseline with QuickDiff + native diff, comments-first asking with a machine reply/offer loop, and annotations in the built-in Markdown preview — sunsetting the two bespoke webviews.
|
||
|
||
**Architecture:** Per `specs/coauthoring-native-surfaces.md` **v0.2.1** (graduated; D17 evolve-in-place, §6.10). The 21 pure cores (attribution, anchoring, diff/hunk engine, sidecar, turn progress) are retained untouched; thin controllers are rewired: a `CoeditingRegistry` gates every surface (INV-10), the baseline follows the file's home (git `HEAD` / snapshot, INV-7), a `QuickDiffProvider` + `cowriting-baseline:` content provider drive gutter bars and the native diff, the Comments API becomes the ask surface (D19/D10), and a `markdown-it` contribution annotates the built-in preview (D3/D21). Each bespoke surface sunsets only after its native replacement is green (§7.1 rung 4 order).
|
||
|
||
**Tech Stack:** TypeScript, VS Code stable APIs only (SCM/QuickDiff, `vscode.diff`, Comments, CodeLens, decorations, `markdown.markdownItPlugins`), built-in Git extension API v1, `@cline/sdk` (ESM, dynamic import, never bundled), esbuild, vitest (unit), `@vscode/test-electron` (host E2E — **no Playwright**, §6.8).
|
||
|
||
## Global Constraints
|
||
|
||
- **INV-3 stable-API boundary:** only the surfaces named above; any new custom rendering needs a recorded justification.
|
||
- **INV-5:** nothing settles until Keep; Reject restores the retained original **exactly**.
|
||
- **INV-6:** new pure logic goes in vscode-free modules with vitest suites; controllers stay thin.
|
||
- **INV-7:** baseline = git `HEAD` where the file is tracked (advances on commit only), else a snapshot (set on enter, re-pinned by "Mark Changes as Reviewed"); baseline is never committed. **The shipped machine-landing baseline advance (INV-18) is retired** — kept machine text stays a visible change until commit/pin.
|
||
- **INV-10:** every surface gates on the CoeditingRegistry; non-entered documents show nothing.
|
||
- **Spike-verified seams (spec §6.4, v0.2.1):** re-assign `commentingRangeProvider` whenever the coediting set changes; focus the comment input via `workbench.action.addComment`; the thread-contextValue menu `when` key is **`commentThread`**; key HEAD re-reads off `state.HEAD.commit`; nested repos may need `openRepository()`.
|
||
- **Command copy (spec §5):** "✦ Coedit this Document with Claude", "Stop editing with Claude", "Review Changes", "Ask Claude", "Toggle Annotations", "Mark Changes as Reviewed" (snapshot mode only, D14). Status bar: `✦ Coediting · N changes`.
|
||
- **`@cline/sdk`** stays `external` in `esbuild.mjs` and is loaded via dynamic `import()`; spawned with `CLAUDE_CODE_AUTO_CONNECT_IDE=0` + `CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL=1` (#59).
|
||
- **Sunset discipline (§6.10):** `trackChangesPreview.ts` and `editInstructionInput.ts` are deleted only in their sunset task, after the native replacement's tests are green.
|
||
- Verification loop for every task: `npm run typecheck && npm run test`, and where the task touches controllers `npm run pretest:e2e && npm run test:e2e`.
|
||
- Commits cite the spec section and carry `Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>`.
|
||
|
||
## File Structure (end state)
|
||
|
||
```
|
||
src/
|
||
coeditingRegistry.ts NEW — the INV-10 opt-in gate (thin, persisted set + context keys)
|
||
gitBaseline.ts NEW — built-in Git-extension adapter (HEAD text, commit watch)
|
||
scmSurface.ts NEW — SourceControl "Cowriting": QuickDiff, cowriting-baseline: provider,
|
||
Review Changes (native diff), status bar, Mark Changes as Reviewed
|
||
editFlow.ts NEW — runEditAndPropose + editDocument/editSelection turn plumbing
|
||
(extracted from trackChangesPreview.ts before its sunset)
|
||
previewAnnotations.ts NEW — pure markdown-it plugin: sentinel-injected authorship + change marks
|
||
diffViewController.ts MOD — baseline modes (head|snapshot), reasons (entered|pinned|head),
|
||
enter-time capture, machine-landing advance retired
|
||
threadController.ts MOD — registry gating + provider re-assign; Ask Claude; comment→turn→
|
||
reply+offer loop (D19/D10/D8)
|
||
editorProposalController.ts MOD — registry gating; ✓ Keep / ✗ Reject + Keep all (N) lens copy
|
||
attributionController.ts MOD — registry gating (typing tracked only while coediting)
|
||
proposalController.ts MOD — registry gating; finalize no longer advances the baseline
|
||
extension.ts MOD — construction order, gating wires, extendMarkdownIt export
|
||
trackChangesPreview.ts DEL (Task 8)
|
||
editInstructionInput.ts DEL (Task 6)
|
||
media/preview-annotations.css NEW — preview stylesheet (markdown.previewStyles)
|
||
test/ — new vitest suites per new pure module
|
||
test/e2e/suite/ — coediting.test.ts, baselineRouter.test.ts, commentLoop.test.ts,
|
||
previewAnnotations.test.ts (+ existing suites updated)
|
||
```
|
||
|
||
---
|
||
|
||
### Task 1: CoeditingRegistry — the INV-10 opt-in gate
|
||
|
||
**Files:**
|
||
- Create: `src/coeditingRegistry.ts`
|
||
- Test: `test/coeditingRegistry.test.ts` (pure set logic), `test/e2e/suite/coediting.test.ts` (context keys + enter/exit; extended in later tasks)
|
||
- Modify: `src/extension.ts` (construct + export), `package.json` (commands + menus)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `vscode.Memento` (`context.workspaceState`), `setContext`.
|
||
- Produces (later tasks rely on these exact names):
|
||
```typescript
|
||
export class CoeditingRegistry implements vscode.Disposable {
|
||
constructor(state: vscode.Memento);
|
||
enter(uri: vscode.Uri): void;
|
||
exit(uri: vscode.Uri): void;
|
||
isCoediting(uri: vscode.Uri): boolean;
|
||
list(): string[]; // uri strings
|
||
readonly onDidChange: vscode.Event<{ uri: string; coediting: boolean }>;
|
||
/** Re-derives cowriting.isCoediting / cowriting.baselineMode for the active editor. */
|
||
syncContext(editor: vscode.TextEditor | undefined): void;
|
||
dispose(): void;
|
||
}
|
||
```
|
||
Context keys: `cowriting.isCoediting` (boolean), commands `cowriting.coeditDocument`, `cowriting.stopCoediting`.
|
||
|
||
- [ ] **Step 1: Write the failing unit test**
|
||
|
||
```typescript
|
||
// test/coeditingRegistry.test.ts
|
||
import { describe, expect, it, vi } from "vitest";
|
||
|
||
// The pure set logic lives in a vscode-free helper the class wraps, so unit-test the class
|
||
// with a Memento stub (vitest runs vscode-free; the module imports vscode only for types +
|
||
// EventEmitter — mock it).
|
||
vi.mock("vscode", () => ({
|
||
EventEmitter: class {
|
||
private handlers: Array<(e: unknown) => void> = [];
|
||
event = (h: (e: unknown) => void) => { this.handlers.push(h); return { dispose() {} }; };
|
||
fire(e: unknown) { for (const h of this.handlers) h(e); }
|
||
dispose() {}
|
||
},
|
||
Uri: { parse: (s: string) => ({ toString: () => s }) },
|
||
commands: { executeCommand: vi.fn() },
|
||
window: { activeTextEditor: undefined },
|
||
}));
|
||
import { CoeditingRegistry } from "../src/coeditingRegistry";
|
||
|
||
function memento(): { store: Map<string, unknown> } & { get: any; update: any } {
|
||
const store = new Map<string, unknown>();
|
||
return {
|
||
store,
|
||
get: (k: string, dflt?: unknown) => (store.has(k) ? store.get(k) : dflt),
|
||
update: (k: string, v: unknown) => { store.set(k, v); return Promise.resolve(); },
|
||
} as any;
|
||
}
|
||
|
||
describe("CoeditingRegistry", () => {
|
||
it("enter/exit flips membership and fires onDidChange", () => {
|
||
const reg = new CoeditingRegistry(memento() as any);
|
||
const events: Array<{ uri: string; coediting: boolean }> = [];
|
||
reg.onDidChange((e) => events.push(e as any));
|
||
const uri = { toString: () => "file:///a.md" } as any;
|
||
expect(reg.isCoediting(uri)).toBe(false);
|
||
reg.enter(uri);
|
||
expect(reg.isCoediting(uri)).toBe(true);
|
||
reg.exit(uri);
|
||
expect(reg.isCoediting(uri)).toBe(false);
|
||
expect(events).toEqual([
|
||
{ uri: "file:///a.md", coediting: true },
|
||
{ uri: "file:///a.md", coediting: false },
|
||
]);
|
||
});
|
||
|
||
it("persists the set across construction (reload survival)", () => {
|
||
const m = memento();
|
||
const reg1 = new CoeditingRegistry(m as any);
|
||
reg1.enter({ toString: () => "file:///a.md" } as any);
|
||
const reg2 = new CoeditingRegistry(m as any);
|
||
expect(reg2.isCoediting({ toString: () => "file:///a.md" } as any)).toBe(true);
|
||
expect(reg2.list()).toEqual(["file:///a.md"]);
|
||
});
|
||
|
||
it("enter is idempotent (no duplicate events)", () => {
|
||
const reg = new CoeditingRegistry(memento() as any);
|
||
const events: unknown[] = [];
|
||
reg.onDidChange((e) => events.push(e));
|
||
const uri = { toString: () => "file:///a.md" } as any;
|
||
reg.enter(uri);
|
||
reg.enter(uri);
|
||
expect(events).toHaveLength(1);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run it to verify it fails**
|
||
|
||
Run: `npx vitest run test/coeditingRegistry.test.ts`
|
||
Expected: FAIL — `Cannot find module '../src/coeditingRegistry'`.
|
||
|
||
- [ ] **Step 3: Implement `src/coeditingRegistry.ts`**
|
||
|
||
```typescript
|
||
/**
|
||
* CoeditingRegistry — the INV-10 activation gate (spec §6.4). Holds the set of
|
||
* documents the writer has explicitly entered into coediting; every surface
|
||
* (SCM/QuickDiff, comments, preview annotations, edit commands, decorations)
|
||
* checks membership before attaching. Persisted in workspaceState so a reload
|
||
* restores the set (the durable sidecar carries the artifacts; this carries
|
||
* only the mode).
|
||
*/
|
||
import * as vscode from "vscode";
|
||
|
||
const STATE_KEY = "cowriting.coeditingSet";
|
||
|
||
export class CoeditingRegistry implements vscode.Disposable {
|
||
private readonly set: Set<string>;
|
||
private readonly emitter = new vscode.EventEmitter<{ uri: string; coediting: boolean }>();
|
||
readonly onDidChange = this.emitter.event;
|
||
/** Set by the SCM surface (Task 3) so syncContext can expose the baseline mode. */
|
||
baselineModeOf: ((uri: vscode.Uri) => "head" | "snapshot" | undefined) | undefined;
|
||
|
||
constructor(private readonly state: vscode.Memento) {
|
||
this.set = new Set(state.get<string[]>(STATE_KEY, []));
|
||
}
|
||
|
||
enter(uri: vscode.Uri): void {
|
||
const key = uri.toString();
|
||
if (this.set.has(key)) return;
|
||
this.set.add(key);
|
||
void this.state.update(STATE_KEY, [...this.set]);
|
||
this.emitter.fire({ uri: key, coediting: true });
|
||
}
|
||
|
||
exit(uri: vscode.Uri): void {
|
||
const key = uri.toString();
|
||
if (!this.set.delete(key)) return;
|
||
void this.state.update(STATE_KEY, [...this.set]);
|
||
this.emitter.fire({ uri: key, coediting: false });
|
||
}
|
||
|
||
isCoediting(uri: vscode.Uri): boolean {
|
||
return this.set.has(uri.toString());
|
||
}
|
||
|
||
list(): string[] {
|
||
return [...this.set];
|
||
}
|
||
|
||
syncContext(editor: vscode.TextEditor | undefined): void {
|
||
const coediting = !!editor && this.isCoediting(editor.document.uri);
|
||
void vscode.commands.executeCommand("setContext", "cowriting.isCoediting", coediting);
|
||
const mode = coediting && editor ? this.baselineModeOf?.(editor.document.uri) : undefined;
|
||
void vscode.commands.executeCommand("setContext", "cowriting.baselineMode", mode ?? "");
|
||
}
|
||
|
||
dispose(): void {
|
||
this.emitter.dispose();
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run the unit test — PASS.** `npx vitest run test/coeditingRegistry.test.ts`
|
||
|
||
- [ ] **Step 5: Wire into `extension.ts`**
|
||
|
||
In `src/extension.ts`, immediately after the `LiveProgressUi` block (line ~41), construct the registry and the enter/stop commands; add `coeditingRegistry: CoeditingRegistry` to `CowritingApi` and to the returned object:
|
||
|
||
```typescript
|
||
import { CoeditingRegistry } from "./coeditingRegistry";
|
||
// …
|
||
const coeditingRegistry = new CoeditingRegistry(context.workspaceState);
|
||
context.subscriptions.push(coeditingRegistry);
|
||
context.subscriptions.push(
|
||
vscode.commands.registerCommand("cowriting.coeditDocument", () => {
|
||
const ed = vscode.window.activeTextEditor;
|
||
if (!ed || ed.document.languageId !== "markdown" || !isAuthorable(ed.document.uri.scheme)) {
|
||
void vscode.window.showWarningMessage("Cowriting: open a Markdown document to coedit it.");
|
||
return;
|
||
}
|
||
coeditingRegistry.enter(ed.document.uri);
|
||
coeditingRegistry.syncContext(ed);
|
||
}),
|
||
vscode.commands.registerCommand("cowriting.stopCoediting", () => {
|
||
const ed = vscode.window.activeTextEditor;
|
||
if (!ed) return;
|
||
coeditingRegistry.exit(ed.document.uri);
|
||
coeditingRegistry.syncContext(ed);
|
||
}),
|
||
vscode.window.onDidChangeActiveTextEditor((ed) => coeditingRegistry.syncContext(ed)),
|
||
);
|
||
coeditingRegistry.syncContext(vscode.window.activeTextEditor);
|
||
```
|
||
|
||
- [ ] **Step 6: package.json — commands + context/title menus**
|
||
|
||
Add to `contributes.commands`:
|
||
|
||
```json
|
||
{ "command": "cowriting.coeditDocument", "title": "✦ Coedit this Document with Claude", "category": "Cowriting" },
|
||
{ "command": "cowriting.stopCoediting", "title": "Stop editing with Claude", "category": "Cowriting" }
|
||
```
|
||
|
||
Add to `contributes.menus["editor/context"]`:
|
||
|
||
```json
|
||
{ "command": "cowriting.coeditDocument", "when": "editorLangId == markdown && !cowriting.isCoediting", "group": "1_cowriting@1" },
|
||
{ "command": "cowriting.stopCoediting", "when": "editorLangId == markdown && cowriting.isCoediting", "group": "1_cowriting@9" }
|
||
```
|
||
|
||
- [ ] **Step 7: Host E2E — enter/exit round-trip**
|
||
|
||
```typescript
|
||
// test/e2e/suite/coediting.test.ts
|
||
import * as assert from "node:assert";
|
||
import * as vscode from "vscode";
|
||
|
||
async function api() {
|
||
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
|
||
return (await ext.activate()) as import("../../../src/extension").CowritingApi;
|
||
}
|
||
|
||
suite("coediting registry (PUC-7)", () => {
|
||
test("enter → isCoediting true; exit → false; persists in list()", async () => {
|
||
const { coeditingRegistry } = await api();
|
||
const doc = await vscode.workspace.openTextDocument({ language: "markdown", content: "# t\n\nbody\n" });
|
||
await vscode.window.showTextDocument(doc);
|
||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||
assert.strictEqual(coeditingRegistry.isCoediting(doc.uri), true);
|
||
assert.ok(coeditingRegistry.list().includes(doc.uri.toString()));
|
||
await vscode.commands.executeCommand("cowriting.stopCoediting");
|
||
assert.strictEqual(coeditingRegistry.isCoediting(doc.uri), false);
|
||
});
|
||
});
|
||
```
|
||
|
||
(Use the extension id from package.json `publisher.name`; check `runTest.js` fixtures for the exact id used by existing suites and mirror it.)
|
||
|
||
- [ ] **Step 8: Verify + commit**
|
||
|
||
Run: `npm run typecheck && npm run test && npm run pretest:e2e && npm run test:e2e`
|
||
Expected: all green (new suites included).
|
||
|
||
```bash
|
||
git add src/coeditingRegistry.ts test/coeditingRegistry.test.ts test/e2e/suite/coediting.test.ts src/extension.ts package.json
|
||
git commit -m "feat: CoeditingRegistry opt-in gate (INV-10, spec §6.4) — enter/stop commands + context keys"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: Baseline router — git HEAD + snapshot (INV-7), machine-landing advance retired
|
||
|
||
**Files:**
|
||
- Create: `src/gitBaseline.ts`
|
||
- Modify: `src/diffViewController.ts`, `src/baselineStore.ts` (reason vocabulary), `src/extension.ts` (remove the `onDidApplyAgentEdit → advance` wire), `src/proposalController.ts` (finalize no longer signals a baseline advance)
|
||
- Test: `test/e2e/suite/baselineRouter.test.ts`; update `test/e2e/suite/diffView.test.ts` expectations
|
||
|
||
**Interfaces:**
|
||
- Produces:
|
||
```typescript
|
||
// src/gitBaseline.ts
|
||
export interface HeadBaseline { text: string; commit: string }
|
||
export class GitBaselineAdapter implements vscode.Disposable {
|
||
/** null until the git extension is available; resolves lazily. */
|
||
async headFor(uri: vscode.Uri): Promise<HeadBaseline | null>; // null = untracked/no repo/no HEAD blob
|
||
/** Fires when ANY watched repo's HEAD commit changes (debounced, keyed off state.HEAD.commit). */
|
||
readonly onDidChangeHead: vscode.Event<void>;
|
||
dispose(): void;
|
||
}
|
||
```
|
||
- `DiffViewController` gains/changes (consumed by Tasks 3–4 and the preview):
|
||
```typescript
|
||
type BaselineReason = "entered" | "pinned" | "head"; // replaces opened|machine-landing|pinned
|
||
modeOf(uriString: string): "head" | "snapshot" | undefined;
|
||
async establish(document: vscode.TextDocument): Promise<void>; // called by registry.enter wiring
|
||
pin(document: vscode.TextDocument): void; // snapshot mode only (D14)
|
||
// ensureBaseline(open-time capture) and advance(machine-landing) are REMOVED.
|
||
```
|
||
|
||
- [ ] **Step 1: Write `src/gitBaseline.ts`**
|
||
|
||
The Git extension API is typed inline (no dependency on git.d.ts). Key seams are spike-proven (spec §6.4 v0.2.1):
|
||
|
||
```typescript
|
||
/**
|
||
* GitBaselineAdapter — resolves a document's git-HEAD baseline via the built-in
|
||
* Git extension API (spec §6.4, INV-7). Spike-verified: Repository.show('HEAD',
|
||
* path) + state.onDidChange work as assumed; the change event is coarse, so
|
||
* re-reads key off state.HEAD.commit; a nested repo may need openRepository().
|
||
*/
|
||
import * as fs from "node:fs";
|
||
import * as path from "node:path";
|
||
import * as vscode from "vscode";
|
||
|
||
interface GitRepository {
|
||
rootUri: vscode.Uri;
|
||
state: { HEAD?: { commit?: string }; onDidChange: vscode.Event<void> };
|
||
show(ref: string, path: string): Promise<string>;
|
||
}
|
||
interface GitAPI {
|
||
repositories: GitRepository[];
|
||
getRepository(uri: vscode.Uri): GitRepository | null;
|
||
openRepository(root: vscode.Uri): Promise<GitRepository | null>;
|
||
onDidOpenRepository: vscode.Event<GitRepository>;
|
||
}
|
||
|
||
export interface HeadBaseline { text: string; commit: string }
|
||
|
||
export class GitBaselineAdapter implements vscode.Disposable {
|
||
private api: GitAPI | null | undefined; // undefined = not resolved yet; null = unavailable
|
||
private readonly hooked = new WeakSet<GitRepository>();
|
||
private readonly lastCommit = new Map<string, string>(); // repo root → HEAD commit
|
||
private readonly emitter = new vscode.EventEmitter<void>();
|
||
readonly onDidChangeHead = this.emitter.event;
|
||
private readonly disposables: vscode.Disposable[] = [this.emitter];
|
||
|
||
private async gitApi(): Promise<GitAPI | null> {
|
||
if (this.api !== undefined) return this.api;
|
||
try {
|
||
const ext = vscode.extensions.getExtension("vscode.git");
|
||
if (!ext) return (this.api = null);
|
||
const exports = ext.isActive ? ext.exports : await ext.activate();
|
||
this.api = exports.getAPI(1) as GitAPI;
|
||
this.disposables.push(this.api.onDidOpenRepository((r) => this.hook(r)));
|
||
for (const r of this.api.repositories) this.hook(r);
|
||
} catch {
|
||
this.api = null;
|
||
}
|
||
return this.api;
|
||
}
|
||
|
||
/** HEAD blob text + commit for a tracked file; null when untracked / no repo / no HEAD. */
|
||
async headFor(uri: vscode.Uri): Promise<HeadBaseline | null> {
|
||
if (uri.scheme !== "file") return null;
|
||
const git = await this.gitApi();
|
||
if (!git) return null;
|
||
let repo = git.getRepository(uri);
|
||
if (!repo) repo = await this.discoverNested(git, uri);
|
||
if (!repo) return null;
|
||
this.hook(repo);
|
||
try {
|
||
const text = await repo.show("HEAD", uri.fsPath);
|
||
return { text, commit: repo.state.HEAD?.commit ?? "" };
|
||
} catch {
|
||
return null; // untracked, ignored, or no commit yet → snapshot mode
|
||
}
|
||
}
|
||
|
||
/** Spike finding: a nested repo may not be auto-opened — walk up for .git, then openRepository. */
|
||
private async discoverNested(git: GitAPI, uri: vscode.Uri): Promise<GitRepository | null> {
|
||
let dir = path.dirname(uri.fsPath);
|
||
for (let i = 0; i < 32; i++) {
|
||
if (fs.existsSync(path.join(dir, ".git"))) {
|
||
await git.openRepository(vscode.Uri.file(dir));
|
||
return git.getRepository(uri);
|
||
}
|
||
const parent = path.dirname(dir);
|
||
if (parent === dir) break;
|
||
dir = parent;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** Watch a repo; fire onDidChangeHead only when HEAD's commit actually moves. */
|
||
private hook(repo: GitRepository): void {
|
||
if (this.hooked.has(repo)) return;
|
||
this.hooked.add(repo);
|
||
this.lastCommit.set(repo.rootUri.toString(), repo.state.HEAD?.commit ?? "");
|
||
this.disposables.push(
|
||
repo.state.onDidChange(() => {
|
||
const key = repo.rootUri.toString();
|
||
const commit = repo.state.HEAD?.commit ?? "";
|
||
if (this.lastCommit.get(key) !== commit) {
|
||
this.lastCommit.set(key, commit);
|
||
this.emitter.fire();
|
||
}
|
||
}),
|
||
);
|
||
}
|
||
|
||
dispose(): void {
|
||
for (const d of this.disposables) d.dispose();
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Rework `src/diffViewController.ts`**
|
||
|
||
Changes (keep the class name and file — evolve in place):
|
||
|
||
1. `BaselineReason` in `src/baselineStore.ts` becomes `"entered" | "pinned" | "head"`. On `store.load`, migrate legacy reasons: `"opened"`/`"machine-landing"` → `"entered"` (one `normalizeReason` helper in `baselineStore.ts`).
|
||
2. Constructor becomes `constructor(store: BaselineStore | null, git: GitBaselineAdapter, registry: CoeditingRegistry)`. Remove the `onDidOpenTextDocument → ensureBaseline` self-wiring and the open-time loop; instead:
|
||
```typescript
|
||
this.disposables.push(
|
||
registry.onDidChange(({ uri, coediting }) => {
|
||
if (!coediting) return; // exit keeps the stored baseline (PUC-7)
|
||
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri);
|
||
if (doc) void this.establish(doc);
|
||
}),
|
||
git.onDidChangeHead(() => void this.refreshHeadBaselines()),
|
||
);
|
||
for (const key of registry.list()) {
|
||
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === key);
|
||
if (doc) void this.establish(doc);
|
||
}
|
||
```
|
||
3. New `establish` (replaces `ensureBaseline`) resolves the mode:
|
||
```typescript
|
||
private readonly modes = new Map<string, "head" | "snapshot">();
|
||
|
||
async establish(document: vscode.TextDocument): Promise<void> {
|
||
if (!this.isDiffable(document)) return;
|
||
const key = this.uriKey(document);
|
||
const head = await this.git.headFor(document.uri);
|
||
if (head) {
|
||
this.modes.set(key, "head");
|
||
this.setBaseline(key, head.text, "head");
|
||
return;
|
||
}
|
||
this.modes.set(key, "snapshot");
|
||
if (this.store && this.isPersistable(document)) {
|
||
const stored = this.tryLoad(key); // existing load path, reasons normalized
|
||
if (stored) { this.baselines.set(key, stored); this.fire(key); return; }
|
||
}
|
||
this.capture(document, "entered");
|
||
}
|
||
|
||
modeOf(uriString: string): "head" | "snapshot" | undefined {
|
||
return this.modes.get(uriString);
|
||
}
|
||
|
||
private async refreshHeadBaselines(): Promise<void> {
|
||
for (const [key, mode] of this.modes) {
|
||
if (mode !== "head") continue;
|
||
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === key);
|
||
if (!doc) continue;
|
||
const head = await this.git.headFor(doc.uri);
|
||
if (head && head.text !== this.baselines.get(key)?.text) {
|
||
this.setBaseline(key, head.text, "head"); // commit advanced the baseline (D13)
|
||
}
|
||
}
|
||
}
|
||
|
||
private setBaseline(key: string, text: string, reason: BaselineReason): void {
|
||
const baseline: Baseline = { uri: key, text, capturedAt: new Date().toISOString(), reason };
|
||
this.baselines.set(key, baseline);
|
||
// head-mode baselines are storage-free (INV-7); persist only snapshots
|
||
if (reason !== "head" && this.store) this.trySave(key, baseline);
|
||
this.onDidChangeBaselineEmitter.fire({ uri: key });
|
||
}
|
||
```
|
||
4. `pin()` guards on mode (D14): `if (this.modes.get(key) !== "snapshot") { warn "git-tracked — commit to advance the baseline"; return; }`. The command id `cowriting.pinDiffBaseline` is **renamed** `cowriting.markReviewed` (title "Mark Changes as Reviewed"); update `package.json` commands/menus and every test referencing `pinDiffBaseline`.
|
||
5. **Delete `advance()`** and the `#48`-era `"machine-landing"` branch; in `src/extension.ts` delete the wire `attributionController.onDidApplyAgentEdit((e) => diffViewController.advance(e.document))` (lines ~154–156). In `src/proposalController.ts`, `finalizeInPlace`'s call to `attribution.signalLanded(...)` must no longer trigger any baseline change — keep `signalLanded` (it still marks landing for attribution) but confirm nothing else subscribes for baseline purposes. Kept text now remains a change-since-baseline (spec INV-7 note, D21).
|
||
6. `getBaseline`/`baselineFilePath` keep their signatures (preview + F12 rely on them). The `reason === "pinned"` → clean rendering behavior (#48/INV-33) is preserved by keeping the `"pinned"` reason.
|
||
|
||
- [ ] **Step 3: Update unit expectations**
|
||
|
||
`test/` suites referencing `"opened"`/`"machine-landing"` reasons or `pinDiffBaseline` get updated to the new vocabulary (`git grep -l 'machine-landing\|pinDiffBaseline\|"opened"' test/ src/`). The pin→clean tests keep passing (reason `"pinned"` unchanged).
|
||
|
||
- [ ] **Step 4: Host E2E — both baseline modes**
|
||
|
||
```typescript
|
||
// test/e2e/suite/baselineRouter.test.ts
|
||
import * as assert from "node:assert";
|
||
import { execFileSync } from "node:child_process";
|
||
import * as fs from "node:fs";
|
||
import * as os from "node:os";
|
||
import * as path from "node:path";
|
||
import * as vscode from "vscode";
|
||
|
||
suite("baseline router (PUC-1, INV-7/D13/D14)", () => {
|
||
test("snapshot mode: enter captures; markReviewed re-pins clean", async () => {
|
||
const api = await activateApi();
|
||
const doc = await vscode.workspace.openTextDocument({ language: "markdown", content: "one\n" });
|
||
const ed = await vscode.window.showTextDocument(doc);
|
||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||
await settle();
|
||
assert.strictEqual(api.diffViewController.modeOf(doc.uri.toString()), "snapshot");
|
||
assert.strictEqual(api.diffViewController.getBaseline(doc.uri.toString())?.text, "one\n");
|
||
await ed.edit((b) => b.insert(new vscode.Position(1, 0), "two\n"));
|
||
await vscode.commands.executeCommand("cowriting.markReviewed");
|
||
const b = api.diffViewController.getBaseline(doc.uri.toString());
|
||
assert.strictEqual(b?.text, "one\ntwo\n");
|
||
assert.strictEqual(b?.reason, "pinned");
|
||
});
|
||
|
||
test("head mode: baseline = HEAD; commit advances it", async function () {
|
||
this.timeout(30000);
|
||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cw-git-"));
|
||
const git = (...a: string[]) => execFileSync("git", ["-C", dir, ...a], { encoding: "utf8" });
|
||
git("init", "-q");
|
||
fs.writeFileSync(path.join(dir, "doc.md"), "committed\n");
|
||
git("add", "doc.md");
|
||
git("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "c1");
|
||
const api = await activateApi();
|
||
const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(path.join(dir, "doc.md")));
|
||
const ed = await vscode.window.showTextDocument(doc);
|
||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||
await settleUntil(() => api.diffViewController.modeOf(doc.uri.toString()) === "head", 10000);
|
||
assert.strictEqual(api.diffViewController.getBaseline(doc.uri.toString())?.text, "committed\n");
|
||
await ed.edit((b) => b.insert(new vscode.Position(1, 0), "uncommitted\n"));
|
||
await doc.save();
|
||
git("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-aqm", "c2");
|
||
await settleUntil(
|
||
() => api.diffViewController.getBaseline(doc.uri.toString())?.text === "committed\nuncommitted\n",
|
||
15000,
|
||
);
|
||
assert.strictEqual(api.diffViewController.getBaseline(doc.uri.toString())?.reason, "head");
|
||
});
|
||
});
|
||
// activateApi()/settle()/settleUntil() go in a shared test/e2e/suite/helpers.ts:
|
||
// activateApi = activate + return CowritingApi (mirror the pattern in the existing suites)
|
||
// settle = new Promise(r => setTimeout(r, 150))
|
||
// settleUntil = poll a predicate every 100ms until true or timeout (assert on timeout)
|
||
```
|
||
|
||
- [ ] **Step 5: Verify + commit**
|
||
|
||
Run: `npm run typecheck && npm run test && npm run pretest:e2e && npm run test:e2e`
|
||
Expected: green, including both new baselineRouter tests (the head-mode one exercises the real vscode.git extension in the test host).
|
||
|
||
```bash
|
||
git add src/gitBaseline.ts src/diffViewController.ts src/baselineStore.ts src/proposalController.ts src/extension.ts package.json test/
|
||
git commit -m "feat: baseline router — git HEAD + snapshot per INV-7/D13/D14; retire machine-landing advance (spec §6.4)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: Native diff surface — cowriting-baseline:, QuickDiff, Review Changes, status bar
|
||
|
||
**Files:**
|
||
- Create: `src/scmSurface.ts`
|
||
- Test: extend `test/e2e/suite/baselineRouter.test.ts` + new asserts in `coediting.test.ts`
|
||
- Modify: `src/extension.ts`, `package.json` (Review Changes / Mark Reviewed title-bar menus, status-bar-less commands)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `DiffViewController.getBaseline/modeOf/onDidChangeBaseline`, `CoeditingRegistry.isCoediting/onDidChange`.
|
||
- Produces:
|
||
```typescript
|
||
export const BASELINE_SCHEME = "cowriting-baseline";
|
||
export function baselineUriFor(docUri: vscode.Uri): vscode.Uri; // query = encodeURIComponent(docUri)
|
||
export class ScmSurfaceController implements vscode.Disposable {
|
||
constructor(registry: CoeditingRegistry, diffView: DiffViewController);
|
||
changeCount(uriString: string): number; // test seam
|
||
// commands registered: cowriting.reviewChanges
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 1: Unit test the change counter (pure)**
|
||
|
||
The status-bar count is a line-hunk count between baseline and live text. Add a pure `countLineHunks(oldText, newText): number` to `src/trackChangesModel.ts` (it already owns the diff algebra — implement via the existing `diffBlocks`/`diff` machinery or a line-LCS; test first):
|
||
|
||
```typescript
|
||
// append to test/trackChangesModel.test.ts
|
||
import { countLineHunks } from "../src/trackChangesModel";
|
||
|
||
describe("countLineHunks", () => {
|
||
it("0 for identical text", () => {
|
||
expect(countLineHunks("a\nb\n", "a\nb\n")).toBe(0);
|
||
});
|
||
it("1 for one contiguous change", () => {
|
||
expect(countLineHunks("a\nb\nc\n", "a\nX\nc\n")).toBe(1);
|
||
});
|
||
it("2 for two separated changes", () => {
|
||
expect(countLineHunks("a\nb\nc\nd\ne\n", "a\nX\nc\nd\nY\n")).toBe(2);
|
||
});
|
||
it("counts pure insertions and deletions", () => {
|
||
expect(countLineHunks("a\n", "a\nb\n")).toBe(1);
|
||
expect(countLineHunks("a\nb\n", "a\n")).toBe(1);
|
||
});
|
||
});
|
||
```
|
||
|
||
Implement with the same LCS walk `diffBlocks` uses, at line grain (a run of non-equal lines = one hunk).
|
||
|
||
- [ ] **Step 2: Implement `src/scmSurface.ts`**
|
||
|
||
```typescript
|
||
/**
|
||
* ScmSurfaceController — the native diff surface (spec §6.4/§5, INV-13).
|
||
* Owns: the "Cowriting" SourceControl + QuickDiffProvider (gutter bars), the
|
||
* cowriting-baseline: TextDocumentContentProvider, the "Review Changes" native
|
||
* diff command, and the "✦ Coediting · N changes" status-bar item. Gated on
|
||
* the CoeditingRegistry (INV-10): non-entered docs resolve no original.
|
||
*/
|
||
import * as path from "node:path";
|
||
import * as vscode from "vscode";
|
||
import type { CoeditingRegistry } from "./coeditingRegistry";
|
||
import type { DiffViewController } from "./diffViewController";
|
||
import { countLineHunks } from "./trackChangesModel";
|
||
|
||
export const BASELINE_SCHEME = "cowriting-baseline";
|
||
|
||
export function baselineUriFor(docUri: vscode.Uri): vscode.Uri {
|
||
const name = path.basename(docUri.path) || "untitled.md";
|
||
return vscode.Uri.from({ scheme: BASELINE_SCHEME, path: `/${name}`, query: encodeURIComponent(docUri.toString()) });
|
||
}
|
||
|
||
export class ScmSurfaceController implements vscode.Disposable {
|
||
private readonly disposables: vscode.Disposable[] = [];
|
||
private readonly statusItem: vscode.StatusBarItem;
|
||
private readonly counts = new Map<string, number>();
|
||
private readonly baselineEmitter = new vscode.EventEmitter<vscode.Uri>();
|
||
private readonly recountTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||
|
||
constructor(
|
||
private readonly registry: CoeditingRegistry,
|
||
private readonly diffView: DiffViewController,
|
||
) {
|
||
this.statusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100);
|
||
this.statusItem.command = "cowriting.reviewChanges";
|
||
this.statusItem.tooltip = "Cowriting — click to review changes (baseline ⟷ live)";
|
||
this.disposables.push(this.statusItem, this.baselineEmitter);
|
||
|
||
this.disposables.push(
|
||
vscode.workspace.registerTextDocumentContentProvider(BASELINE_SCHEME, {
|
||
onDidChange: this.baselineEmitter.event,
|
||
provideTextDocumentContent: (uri) =>
|
||
this.diffView.getBaseline(decodeURIComponent(uri.query))?.text ?? "",
|
||
}),
|
||
);
|
||
|
||
const sc = vscode.scm.createSourceControl("cowriting", "✦ Cowriting");
|
||
sc.quickDiffProvider = {
|
||
provideOriginalResource: (uri: vscode.Uri) =>
|
||
this.registry.isCoediting(uri) && this.diffView.getBaseline(uri.toString())
|
||
? baselineUriFor(uri)
|
||
: null,
|
||
};
|
||
this.disposables.push(sc);
|
||
|
||
this.disposables.push(
|
||
vscode.commands.registerCommand("cowriting.reviewChanges", () => this.openReview()),
|
||
this.diffView.onDidChangeBaseline(({ uri }) => {
|
||
this.baselineEmitter.fire(baselineUriFor(vscode.Uri.parse(uri)));
|
||
this.scheduleRecount(uri);
|
||
}),
|
||
this.registry.onDidChange(({ uri, coediting }) => {
|
||
if (coediting) this.scheduleRecount(uri);
|
||
this.refreshStatus();
|
||
}),
|
||
vscode.workspace.onDidChangeTextDocument((e) => {
|
||
if (this.registry.isCoediting(e.document.uri)) this.scheduleRecount(e.document.uri.toString());
|
||
}),
|
||
vscode.window.onDidChangeActiveTextEditor(() => this.refreshStatus()),
|
||
);
|
||
this.refreshStatus();
|
||
}
|
||
|
||
changeCount(uriString: string): number {
|
||
return this.counts.get(uriString) ?? 0;
|
||
}
|
||
|
||
private async openReview(): Promise<void> {
|
||
const ed = vscode.window.activeTextEditor;
|
||
if (!ed || !this.registry.isCoediting(ed.document.uri)) {
|
||
void vscode.window.showWarningMessage("Cowriting: not coediting this document.");
|
||
return;
|
||
}
|
||
this.baselineEmitter.fire(baselineUriFor(ed.document.uri));
|
||
await vscode.commands.executeCommand(
|
||
"vscode.diff",
|
||
baselineUriFor(ed.document.uri),
|
||
ed.document.uri,
|
||
`${path.basename(ed.document.uri.path)} — Coediting (baseline ⟷ live)`,
|
||
{ preview: true },
|
||
);
|
||
}
|
||
|
||
private scheduleRecount(uriString: string): void {
|
||
const prev = this.recountTimers.get(uriString);
|
||
if (prev !== undefined) clearTimeout(prev);
|
||
this.recountTimers.set(uriString, setTimeout(() => {
|
||
this.recountTimers.delete(uriString);
|
||
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uriString);
|
||
const baseline = this.diffView.getBaseline(uriString);
|
||
if (!doc || !baseline) return;
|
||
this.counts.set(uriString, countLineHunks(baseline.text, doc.getText()));
|
||
this.refreshStatus();
|
||
}, 150));
|
||
}
|
||
|
||
private refreshStatus(): void {
|
||
const ed = vscode.window.activeTextEditor;
|
||
if (!ed || !this.registry.isCoediting(ed.document.uri)) {
|
||
this.statusItem.hide();
|
||
return;
|
||
}
|
||
const n = this.counts.get(ed.document.uri.toString()) ?? 0;
|
||
this.statusItem.text = `$(sparkle) Coediting · ${n} change${n === 1 ? "" : "s"}`;
|
||
this.statusItem.show();
|
||
}
|
||
|
||
dispose(): void {
|
||
for (const t of this.recountTimers.values()) clearTimeout(t);
|
||
for (const d of this.disposables) d.dispose();
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Wire + menus**
|
||
|
||
`extension.ts`: construct after `diffViewController`; add to `CowritingApi` as `scmSurfaceController`. Also wire `registry.baselineModeOf = (uri) => diffViewController.modeOf(uri.toString())` (Task 1 hook) and call `registry.syncContext(...)` inside `diffView.onDidChangeBaseline` so the `cowriting.baselineMode` context key tracks mode changes.
|
||
|
||
`package.json` — `editor/title` (replacing the `showTrackChangesPreview` entry in Task 8; for now add alongside):
|
||
|
||
```json
|
||
{ "command": "cowriting.reviewChanges", "when": "resourceLangId == markdown && cowriting.isCoediting", "group": "navigation@1" },
|
||
{ "command": "cowriting.markReviewed", "when": "resourceLangId == markdown && cowriting.isCoediting && cowriting.baselineMode == snapshot", "group": "navigation@4" }
|
||
```
|
||
|
||
with icons on the command declarations: `"icon": "$(git-compare)"` (reviewChanges), `"icon": "$(check-all)"` (markReviewed).
|
||
|
||
- [ ] **Step 4: E2E asserts**
|
||
|
||
Extend `baselineRouter.test.ts`: after entering the snapshot doc and editing, `settleUntil(() => api.scmSurfaceController.changeCount(doc.uri.toString()) === 1)`; after `cowriting.markReviewed`, count returns to 0. Add to `coediting.test.ts`: a **non-entered** markdown doc gets `provideOriginalResource` → null (assert via `api.scmSurfaceController.changeCount` staying 0 and, structurally, `registry.isCoediting === false` — QuickDiff itself isn't queryable from tests; assert the gate inputs).
|
||
|
||
- [ ] **Step 5: Verify + commit**
|
||
|
||
Run: `npm run typecheck && npm run test && npm run pretest:e2e && npm run test:e2e`
|
||
|
||
```bash
|
||
git add src/scmSurface.ts src/trackChangesModel.ts src/extension.ts package.json test/
|
||
git commit -m "feat: native diff surface — QuickDiff + cowriting-baseline: provider + Review Changes + status bar (spec §6.4, INV-13)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: Gate every existing surface on the registry (INV-10)
|
||
|
||
**Files:**
|
||
- Modify: `src/threadController.ts`, `src/attributionController.ts`, `src/proposalController.ts`, `src/editorProposalController.ts`, `src/extension.ts`, `package.json`
|
||
- Test: extend `test/e2e/suite/coediting.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `CoeditingRegistry.isCoediting/onDidChange` (each controller gains a `registry: CoeditingRegistry` constructor param, appended last).
|
||
- Produces: no new API; behavior — non-entered docs get no commenting ranges, no attribution tracking, no proposal rendering, no CodeLens, no decorations.
|
||
|
||
- [ ] **Step 1: ThreadController gating + the spike's re-assign refresh**
|
||
|
||
In `threadController.ts`:
|
||
|
||
```typescript
|
||
// constructor gains: private readonly registry: CoeditingRegistry
|
||
// keep ONE provider object; re-assign it on every registry change (spec §6.4 v0.2.1:
|
||
// re-assignment is the API's only "ranges changed" signal)
|
||
private readonly rangeProvider: vscode.CommentingRangeProvider = {
|
||
provideCommentingRanges: (document) => {
|
||
if (!isAuthorable(document.uri.scheme)) return [];
|
||
if (!this.registry.isCoediting(document.uri)) return [];
|
||
return [new vscode.Range(0, 0, Math.max(0, document.lineCount - 1), 0)];
|
||
},
|
||
};
|
||
// in constructor:
|
||
this.controller.commentingRangeProvider = this.rangeProvider;
|
||
this.disposables.push(
|
||
this.registry.onDidChange(() => {
|
||
this.controller.commentingRangeProvider = this.rangeProvider; // force re-query
|
||
}),
|
||
);
|
||
```
|
||
|
||
Also gate `createThreadOnSelection` and `renderAll` early-return on `!this.registry.isCoediting(document.uri)` — and on `registry.onDidChange`, dispose rendered threads for exited docs / `renderAll` for entered ones (threads persist in the sidecar; exit hides, re-enter restores — PUC-7).
|
||
|
||
- [ ] **Step 2: Attribution/Proposal/CodeLens gating**
|
||
|
||
Same pattern, minimal touch:
|
||
- `attributionController.ts`: the `onDidChangeTextDocument` tracking listener and `loadAll` early-return when `!registry.isCoediting(doc.uri)`.
|
||
- `proposalController.ts`: `renderAll` + `onDidChangeProposals`-driven rendering gated; `propose()` itself stays ungated (the seam is only reachable from gated surfaces + the E2E harness).
|
||
- `editorProposalController.ts`: `provideCodeLenses` returns `[]` and `renderEditor` clears decorations when not coediting; re-render on `registry.onDidChange`.
|
||
- `extension.ts`: `renderIfOpen` checks the registry; the `cowriting.edit`/`editSelection`/`editDocument` commands warn `"Run ✦ Coedit this Document with Claude first."` when the target doc isn't entered.
|
||
|
||
- [ ] **Step 3: package.json when-clauses**
|
||
|
||
Every cowriting menu/keybinding entry that assumed always-on markdown gains `&& cowriting.isCoediting`: `cowriting.edit` (editor/context, editor/title/context, keybinding), `cowriting.createThread`, `cowriting.acceptAllProposals` / `cowriting.rejectAllProposals` palette gates. `cowriting.coeditDocument` keeps `!cowriting.isCoediting`.
|
||
|
||
- [ ] **Step 4: E2E — the no-hijack scenario (PUC-7)**
|
||
|
||
Extend `coediting.test.ts`:
|
||
|
||
```typescript
|
||
test("non-entered doc gets no surfaces; exit detaches; re-enter restores threads", async () => {
|
||
const api = await activateApi();
|
||
const doc = await vscode.workspace.openTextDocument({ language: "markdown", content: "# a\n\npara\n" });
|
||
const ed = await vscode.window.showTextDocument(doc);
|
||
// not entered → no thread creation
|
||
ed.selection = new vscode.Selection(2, 0, 2, 4);
|
||
const before = await api.threadController.createThreadOnSelection("hi");
|
||
assert.strictEqual(before, undefined);
|
||
// entered → works
|
||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||
const id = await api.threadController.createThreadOnSelection("hi");
|
||
assert.ok(id);
|
||
// exit → rendered thread set empty; re-enter → restored from sidecar
|
||
await vscode.commands.executeCommand("cowriting.stopCoediting");
|
||
assert.strictEqual(api.threadController.getRendered(api.proposalController.keyFor(doc)).length, 0);
|
||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||
await settle();
|
||
assert.strictEqual(api.threadController.getRendered(api.proposalController.keyFor(doc)).length, 1);
|
||
});
|
||
```
|
||
|
||
Existing E2E suites (`authorship`, `f10Review`, `f12*`, `diffView`, `undoMarks`) assumed always-on: add `await vscode.commands.executeCommand("cowriting.coeditDocument")` to their setup helpers (do it once in the shared helper that opens the doc).
|
||
|
||
- [ ] **Step 5: Verify + commit**
|
||
|
||
Run: `npm run typecheck && npm run test && npm run pretest:e2e && npm run test:e2e`
|
||
|
||
```bash
|
||
git add src/ package.json test/
|
||
git commit -m "feat: gate every surface on CoeditingRegistry (INV-10) — commenting ranges re-assigned on gate change (spec §6.4 v0.2.1)"
|
||
```
|
||
|
||
---### Task 5: Extract the edit flow from the webview controller
|
||
|
||
**Files:**
|
||
- Create: `src/editFlow.ts`
|
||
- Modify: `src/trackChangesPreview.ts` (delegate to editFlow), `src/extension.ts` (acceptAll/rejectAll route to ProposalController directly)
|
||
- Test: existing `f10Review`/`f12*` suites keep passing (behavior-preserving refactor)
|
||
|
||
**Interfaces:**
|
||
- Produces (Task 6 consumes `runEditAndPropose`; Task 8 deletes the webview knowing nothing else imports it):
|
||
```typescript
|
||
// src/editFlow.ts
|
||
export type EditTarget = { kind: "document" } | { kind: "range"; start: number; end: number };
|
||
export class EditFlow {
|
||
constructor(proposals: ProposalController, attribution: AttributionController, liveProgressUi: LiveProgressUi);
|
||
/** Moved verbatim from trackChangesPreview.ts lines 299–335 (one proposal per changed
|
||
* block for document edits — INV-39/40; block-union single proposal for range edits). */
|
||
runEditAndPropose(document: vscode.TextDocument, target: EditTarget, instruction: string,
|
||
opts?: RunEditTurnOptions): Promise<string[]>;
|
||
setEditTurnForTest(fn: EditTurn): void; // preserved test seam
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 1: Move, don't rewrite.** Cut `runEditAndPropose`, its `EditTarget`/`EditTurn` types, and the `cowriting.editDocument` command registration out of `trackChangesPreview.ts` into `src/editFlow.ts`; the preview controller receives the `EditFlow` instance and delegates (its webview `askClaude` messages call `editFlow.runEditAndPropose`). `extension.ts` constructs `EditFlow` before the preview controller, passes it to both the preview controller and (Task 6) the thread controller, and **adds `editFlow: EditFlow` to `CowritingApi`** (the Task 6/9 E2E suites drive `editFlow.setEditTurnForTest`).
|
||
- [ ] **Step 2: Re-route accept/reject-all.** `cowriting.acceptAllProposals` → `proposalController.acceptAllProposals(doc)`; `cowriting.rejectAllProposals` → `proposalController.rejectAll(doc)` (drop the preview-controller indirection; keep the same toasts by moving the report strings).
|
||
- [ ] **Step 3: Verify + commit.** `npm run typecheck && npm run test && npm run pretest:e2e && npm run test:e2e` — all existing suites green (pure refactor).
|
||
|
||
```bash
|
||
git add src/editFlow.ts src/trackChangesPreview.ts src/extension.ts
|
||
git commit -m "refactor: extract EditFlow (runEditAndPropose + editDocument) from the review webview ahead of its sunset (spec §6.10)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: Comments-first ask + comment-response loop (D19/D10/D8); sunset the input webview
|
||
|
||
**Files:**
|
||
- Modify: `src/threadController.ts` (the loop), `src/extension.ts` (Ask Claude command; editSelection prompt path), `package.json` (Ask Claude, Make-this-edit menus)
|
||
- Delete: `src/editInstructionInput.ts` (+ its media client if separate; `git grep editInstructionInput media/` first)
|
||
- Test: `test/e2e/suite/commentLoop.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `EditFlow.runEditAndPropose`, `LiveProgressUi.begin`, `runEditTurn` (dynamic import), registry.
|
||
- Produces:
|
||
```typescript
|
||
// ThreadController additions
|
||
askClaude(): Promise<void>; // command cowriting.askClaude
|
||
/** Test seam: run the offer for a thread without the UI button. */
|
||
makeThreadEdit(threadId: string, docPath: string): Promise<string[]>;
|
||
setTurnRunnerForTest(fn: (instruction: string, context: string, opts?: RunEditTurnOptions) => Promise<EditTurnResult>): void;
|
||
```
|
||
|
||
- [ ] **Step 1: Ask Claude = focused comment box (spec §6.4 v0.2.1)**
|
||
|
||
```typescript
|
||
// threadController.ts
|
||
async askClaude(): Promise<void> {
|
||
const ed = vscode.window.activeTextEditor;
|
||
if (!ed || !this.registry.isCoediting(ed.document.uri)) {
|
||
void vscode.window.showWarningMessage("Run ✦ Coedit this Document with Claude first.");
|
||
return;
|
||
}
|
||
if (ed.selection.isEmpty) {
|
||
// whole-document ask → top-anchored thread (D19)
|
||
ed.selection = new vscode.Selection(0, 0, 0, 0);
|
||
ed.revealRange(new vscode.Range(0, 0, 0, 0));
|
||
}
|
||
// Spike finding: workbench.action.addComment is the only path that opens the
|
||
// comment widget WITH its input focused (no thread.focus() API exists).
|
||
this.controller.commentingRangeProvider = this.rangeProvider; // defensive refresh
|
||
await vscode.commands.executeCommand("workbench.action.addComment");
|
||
}
|
||
```
|
||
|
||
Register `cowriting.askClaude` in the constructor's command block. Command declaration: `{ "command": "cowriting.askClaude", "title": "Ask Claude", "category": "Cowriting", "icon": "$(sparkle)" }`; `editor/title` menu entry `when: "resourceLangId == markdown && cowriting.isCoediting"`, `group: "navigation@2"`.
|
||
|
||
- [ ] **Step 2: Every comment on a coedited doc runs a turn (D10)**
|
||
|
||
The existing `cowriting.reply` command (and thread creation via the `+` gutter → the contributed reply button) is the ingress. Extend `reply(r)`: after persisting the human message, if `this.registry.isCoediting(r.thread.uri)` and the author isn't the machine, fire the response loop:
|
||
|
||
```typescript
|
||
private async respondInThread(vsThread: vscode.CommentThread, state: DocState, threadId: string, ask: string): Promise<void> {
|
||
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === vsThread.uri.toString());
|
||
if (!doc) return;
|
||
const contextText = this.threadContextText(doc, vsThread); // passage for a ranged thread, full doc for top-anchored
|
||
try {
|
||
await vscode.window.withProgress(
|
||
{ location: vscode.ProgressLocation.Notification, title: "Cowriting: asking Claude…", cancellable: true },
|
||
async (progress, token) => {
|
||
const run = this.turnRunner ?? (await import("./liveTurn")).runEditTurn;
|
||
const ui = this.liveProgressUi.begin(ask, progress, token);
|
||
let turn: EditTurnResult;
|
||
try {
|
||
turn = await run(REPLY_PROMPT(ask), contextText, { onProgress: ui.onProgress, signal: ui.signal });
|
||
} catch (err) {
|
||
if (token.isCancellationRequested) return;
|
||
throw err;
|
||
}
|
||
// machine reply, onBehalfOf provenance (INV-8)
|
||
appendMessage(state.artifact, threadId, {
|
||
author: { kind: "agent", id: "claude", agent: { sdk: "@cline/sdk", model: turn.model, sessionId: turn.sessionId, onBehalfOf: this.currentAuthor() } },
|
||
body: turn.replacement,
|
||
});
|
||
this.persist(state);
|
||
this.refreshComments(vsThread, state, threadId);
|
||
vsThread.contextValue = "offer"; // when-key: commentThread == offer
|
||
this.offers.set(vsThread, { ask, threadId });
|
||
},
|
||
);
|
||
} catch (err) {
|
||
void vscode.window.showErrorMessage(`Cowriting: Claude reply failed — ${err instanceof Error ? err.message : String(err)}`);
|
||
}
|
||
}
|
||
```
|
||
|
||
`REPLY_PROMPT(ask)` (module const): `"Reply conversationally (2-4 sentences) to this remark about the text, then, if the remark implies an edit, state the edit you would make. Remark: " + ask`. `threadContextText`: `doc.getText(vsThread.range)` when the thread has a non-empty range, else `doc.getText()`.
|
||
|
||
- [ ] **Step 3: The offer → pending proposals (INV-5)**
|
||
|
||
```typescript
|
||
// command cowriting.makeThreadEdit — menu arg is CommentReply or CommentThread
|
||
private async makeEditFromThread(arg: { thread: vscode.CommentThread } | vscode.CommentThread): Promise<void> {
|
||
const vsThread = "thread" in arg ? arg.thread : arg;
|
||
const offer = this.offers.get(vsThread);
|
||
if (!offer || !this.registry.isCoediting(vsThread.uri)) return;
|
||
const doc = await vscode.workspace.openTextDocument(vsThread.uri);
|
||
const target = this.targetOf(doc, vsThread); // {kind:"range", start,end} for ranged threads, {kind:"document"} for top-anchored
|
||
const ids = await this.editFlow.runEditAndPropose(doc, target, offer.ask);
|
||
vsThread.contextValue = "offer-done";
|
||
if (ids.length) void vscode.window.showInformationMessage(
|
||
`Cowriting: ${ids.length} pending change${ids.length === 1 ? "" : "s"} landed in the buffer — ✓ Keep / ✗ Reject there.`);
|
||
}
|
||
```
|
||
|
||
`ThreadController` constructor gains `editFlow: EditFlow` and `liveProgressUi: LiveProgressUi` params (extension.ts passes them). `offers` is a `WeakMap<vscode.CommentThread, { ask: string; threadId: string }>`.
|
||
|
||
`package.json` menus (note the **`commentThread`** when-key — spec §6.4 v0.2.1):
|
||
|
||
```json
|
||
"comments/commentThread/title": [
|
||
{ "command": "cowriting.makeThreadEdit", "when": "commentController == cowriting.threads && commentThread == offer", "group": "inline@1" },
|
||
…existing resolve/reopen entries…
|
||
],
|
||
"comments/commentThread/context": [
|
||
{ "command": "cowriting.reply", "when": "commentController == cowriting.threads", "group": "inline@1" },
|
||
{ "command": "cowriting.makeThreadEdit", "when": "commentController == cowriting.threads && commentThread == offer", "group": "inline@2" }
|
||
]
|
||
```
|
||
|
||
with `{ "command": "cowriting.makeThreadEdit", "title": "✦ Make this edit", "icon": "$(sparkle)" }` in commands.
|
||
|
||
- [ ] **Step 4: Replace the input webview at its two call sites**
|
||
|
||
- `extension.ts` `cowriting.editSelection` (line ~254): replace `trackChangesPreviewController.askEditInstruction(...)` with creating a thread on the selection + focusing its input — the ask *is* the comment now (D19). Concretely: `await threadController.askClaude(); return;` — the loop (Step 2/3) takes over; delete the rest of the inline turn plumbing from that command (it now lives in the loop + EditFlow). Keep `cowriting.edit` routing (selection/document both end at `askClaude`).
|
||
- Delete `src/editInstructionInput.ts` and the preview controller's `askEditInstruction` default (point it at a rejected promise with a clear message; it dies fully in Task 8). `git grep -n "editInstructionInput\|promptEditInstruction\|askEditInstruction" src/ test/` must return only `trackChangesPreview.ts` internals slated for Task 8.
|
||
|
||
- [ ] **Step 5: E2E — the loop with a stubbed turn (PUC-8)**
|
||
|
||
```typescript
|
||
// test/e2e/suite/commentLoop.test.ts
|
||
suite("comment → reply → offer → proposal (PUC-8, D10/D19)", () => {
|
||
test("reply on a coedited doc summons a machine reply + offer; accept lands pending proposals", async () => {
|
||
const api = await activateApi();
|
||
const doc = await vscode.workspace.openTextDocument({ language: "markdown", content: "# T\n\nThe quick brown fox jumps over the lazy dog paragraph.\n" });
|
||
const ed = await vscode.window.showTextDocument(doc);
|
||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||
api.threadController.setTurnRunnerForTest(async () => ({ replacement: "I would tighten this sentence.", model: "stub", sessionId: "s1" }));
|
||
api.editFlow.setEditTurnForTest(async () => ({ replacement: "The quick fox jumps the lazy dog.", model: "stub", sessionId: "s1" }));
|
||
ed.selection = new vscode.Selection(2, 0, 2, 20);
|
||
const threadId = (await api.threadController.createThreadOnSelection("tighten this"))!;
|
||
// reply-loop fires on the human message; wait for the machine message to persist
|
||
await settleUntil(() => {
|
||
const t = api.sidecarRouter.load(api.proposalController.keyFor(doc))?.threads.find((x) => x.id === threadId);
|
||
return (t?.messages.length ?? 0) >= 2 && t!.messages[1].author.kind === "agent";
|
||
}, 10000);
|
||
const ids = await api.threadController.makeThreadEdit(threadId, api.proposalController.keyFor(doc));
|
||
assert.ok(ids.length >= 1);
|
||
assert.ok(api.proposalController.listProposals(doc).length >= 1); // pending, INV-5
|
||
});
|
||
|
||
test("a comment on a NON-coedited doc summons nothing (INV-10)", async () => {
|
||
const api = await activateApi();
|
||
const doc = await vscode.workspace.openTextDocument({ language: "markdown", content: "plain\n" });
|
||
await vscode.window.showTextDocument(doc);
|
||
const id = await api.threadController.createThreadOnSelection("hello");
|
||
assert.strictEqual(id, undefined); // gate refuses thread creation entirely
|
||
});
|
||
});
|
||
```
|
||
|
||
(Wire `createThreadOnSelection` to fire the same respond loop as `reply` when the doc is coedited — the first message of a new thread is also "a comment on a coedited doc", D10. `makeThreadEdit(threadId, docPath)` test-seam overload resolves the `vsThread` from `state.vsThreads`.)
|
||
|
||
- [ ] **Step 6: Verify + commit**
|
||
|
||
Run: `npm run typecheck && npm run test && npm run pretest:e2e && npm run test:e2e`
|
||
|
||
```bash
|
||
git add src/threadController.ts src/extension.ts package.json test/ && git rm src/editInstructionInput.ts
|
||
git commit -m "feat: comments-first ask + comment→reply→offer→proposal loop (D19/D10/D8, PUC-8); sunset the input webview (spec §6.10)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: Annotations in the built-in Markdown preview (D3/D21, PUC-3)
|
||
|
||
**Files:**
|
||
- Create: `src/previewAnnotations.ts`, `media/preview-annotations.css`
|
||
- Modify: `src/extension.ts` (export `extendMarkdownIt` from `activate`'s return), `package.json` (`markdown.markdownItPlugins: true`, `markdown.previewStyles`, `cowriting.annotations` setting, Toggle command), `esbuild.mjs` only if the css needs copying (previewStyles paths are repo-relative — no build step needed)
|
||
- Test: `test/previewAnnotations.test.ts` (pure, vitest + markdown-it), `test/e2e/suite/previewAnnotations.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `injectSentinels`, `sentinelsToSpans`, `wordEditHunks`, `landedTextOf` from `trackChangesModel.ts` (all existing pure exports); `AttributionController.spansFor`; `DiffViewController.getBaseline`; `ProposalController.listProposals`; registry.
|
||
- Produces:
|
||
```typescript
|
||
// src/previewAnnotations.ts (pure module + one thin host hook)
|
||
export interface AnnotationInputs {
|
||
baselineText: string | undefined;
|
||
baselineReason: "entered" | "pinned" | "head" | undefined;
|
||
spans: AuthorSpan[];
|
||
proposals: ProposalView[];
|
||
enabled: boolean;
|
||
}
|
||
/** Pure: markdown source → sentinel-annotated source (authorship + change + proposal marks). */
|
||
export function annotateSource(src: string, inputs: AnnotationInputs): string;
|
||
/** markdown-it plugin factory; host supplies inputs per render. */
|
||
export function cowritingMarkdownItPlugin(md: any, host: { inputsFor(env: unknown): AnnotationInputs | undefined }): any;
|
||
```
|
||
|
||
- [ ] **Step 1: Unit tests first (token-in → HTML-out, vscode-free)**
|
||
|
||
```typescript
|
||
// test/previewAnnotations.test.ts
|
||
import MarkdownIt from "markdown-it";
|
||
import { describe, expect, it } from "vitest";
|
||
import { cowritingMarkdownItPlugin } from "../src/previewAnnotations";
|
||
|
||
function render(src: string, inputs: any): string {
|
||
const md = new MarkdownIt();
|
||
cowritingMarkdownItPlugin(md, { inputsFor: () => inputs });
|
||
return md.render(src);
|
||
}
|
||
|
||
const base = { baselineReason: "entered", proposals: [], enabled: true };
|
||
|
||
describe("preview annotations (PUC-3/D21)", () => {
|
||
it("renders clean when disabled", () => {
|
||
const html = render("hello brave world\n", { ...base, enabled: false, baselineText: "hello world\n", spans: [] });
|
||
expect(html).not.toContain("cw-");
|
||
});
|
||
it("colors machine spans blue and human insertions green vs baseline", () => {
|
||
const html = render("hello brave new world\n", {
|
||
...base,
|
||
baselineText: "hello world\n",
|
||
spans: [{ start: 6, end: 12, author: "claude" }, { start: 12, end: 16, author: "human" }],
|
||
});
|
||
expect(html).toContain('class="cw-ins-claude"');
|
||
expect(html).toContain('class="cw-ins-human"');
|
||
});
|
||
it("strikes deletions vs baseline", () => {
|
||
const html = render("hello world\n", { ...base, baselineText: "hello cruel world\n", spans: [] });
|
||
expect(html).toContain("cw-del");
|
||
expect(html).toContain("cruel");
|
||
});
|
||
it("pinned baseline renders clean even with spans (pin→clean, INV-33/#48)", () => {
|
||
const html = render("hello brave world\n", {
|
||
...base, baselineReason: "pinned", baselineText: "hello brave world\n",
|
||
spans: [{ start: 6, end: 12, author: "claude" }],
|
||
});
|
||
expect(html).not.toContain("cw-ins");
|
||
});
|
||
it("survives intra-emphasis boundaries (#33 discipline)", () => {
|
||
const html = render("a *bold claim* here\n", {
|
||
...base, baselineText: "a here\n", spans: [{ start: 2, end: 14, author: "claude" }],
|
||
});
|
||
expect(html).toContain("cw-ins-claude");
|
||
expect(html).not.toMatch(/[-]/); // no PUA sentinel leaks
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Implement `previewAnnotations.ts`**
|
||
|
||
Mechanism (reuses the shipped sentinel discipline instead of inventing a new one):
|
||
1. `annotateSource` computes, against `baselineText` (skip everything when `enabled === false` or `baselineReason === "pinned"` with zero diff — the #48 rule): (a) **insertion marks** from `spans` clipped to changed regions (`wordEditHunks(current, baseline)` gives changed ranges; a span portion inside a changed range gets an `ins-<author>` sentinel pair via `injectSentinels`); (b) **deletion marks**: for each hunk, the baseline-side dropped words are re-inserted into the source at the hunk start wrapped in `del` sentinels; (c) **pending proposals** already sit in the buffer text — mark them via their `anchorStart/anchorEnd` as `ins-claude` + their `original`'s dropped words as `del`.
|
||
2. `cowritingMarkdownItPlugin` installs (a) a core rule **before `normalize`** that swaps `state.src` for `annotateSource(state.src, inputs)` when `host.inputsFor(state.env)` yields inputs, and (b) a `text` renderer rule that runs the existing `sentinelsToSpans` token-aware walker (#33/#47-hardened) over the escaped token content so sentinel pairs become `<span class="cw-…">` even across emphasis/tag boundaries.
|
||
3. The host hook in `extension.ts`:
|
||
```typescript
|
||
const annotationHost = {
|
||
inputsFor(env: unknown): AnnotationInputs | undefined {
|
||
const envUri = (env as { currentDocument?: { toString(): string } })?.currentDocument?.toString();
|
||
const key = envUri && coeditingRegistry.list().includes(envUri) ? envUri : lastActiveCoedited();
|
||
if (!key) return undefined;
|
||
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === key);
|
||
if (!doc) return undefined;
|
||
return {
|
||
baselineText: diffViewController.getBaseline(key)?.text,
|
||
baselineReason: diffViewController.getBaseline(key)?.reason,
|
||
spans: attributionController.spansFor(doc),
|
||
proposals: proposalController.listProposals(doc),
|
||
enabled: vscode.workspace.getConfiguration("cowriting").get<boolean>("annotations", true),
|
||
};
|
||
},
|
||
};
|
||
// activate() return gains:
|
||
return { …api, extendMarkdownIt: (md: any) => cowritingMarkdownItPlugin(md, annotationHost) };
|
||
```
|
||
(`env.currentDocument` is observed, not contractual — the `lastActiveCoedited()` fallback keeps single-doc correctness; record this in the module docstring.)
|
||
4. Toggle: setting `cowriting.annotations` (boolean, default true) + command `cowriting.toggleAnnotations` (icon `$(eye)`, title-bar `navigation@3`, gated `cowriting.isCoediting`) that flips the setting and runs `vscode.commands.executeCommand("markdown.preview.refresh")`.
|
||
5. `media/preview-annotations.css` (declared under `markdown.previewStyles`): the F10 class vocabulary — `.cw-ins-claude` (blue bg + underline), `.cw-ins-human` (green bg + underline), `.cw-del` (struck, red), light/dark variants keyed on `body.vscode-dark`.
|
||
6. **Mermaid (Q4 check, recorded):** the built-in preview does not render mermaid itself. Contribute `markdown.previewScripts` with the already-bundled mermaid (reusing the F7.1 pure re-emit: the markdown-it plugin re-emits changed-diagram sources through `mermaidDiff.ts` before the previewScript renders them). If `markdown.previewScripts` + bundled mermaid proves unworkable in the E2E step, **stop and record the Q4 finding in the spec before proceeding** — do not build a webview fallback silently (INV-3).
|
||
|
||
- [ ] **Step 3: package.json**
|
||
|
||
```json
|
||
"markdown.markdownItPlugins": true,
|
||
"markdown.previewStyles": ["./media/preview-annotations.css"],
|
||
"markdown.previewScripts": ["./media/preview-mermaid.js"],
|
||
"configuration": { "properties": { "cowriting.annotations": { "type": "boolean", "default": true, "description": "Show authorship + change annotations in the Markdown preview for coedited documents." }, …existing… } }
|
||
```
|
||
|
||
- [ ] **Step 4: E2E (structural asserts, §6.8 discipline)**
|
||
|
||
The preview DOM isn't queryable from the host — assert the pure transform inputs/outputs instead: open + enter a doc, make a machine-attributed change (via the `cowriting.applyAgentEdit` harness seam), assert `annotateSource(doc.getText(), inputsFromApi(...))` contains `cw-ins-claude`; flip `cowriting.annotations` to false via `workspace.getConfiguration().update` and assert the transform returns the source unchanged. (Named scenario PUC-3 annotate-toggle.)
|
||
|
||
- [ ] **Step 5: Verify + commit**
|
||
|
||
Run: `npm run typecheck && npm run test && npm run pretest:e2e && npm run test:e2e`
|
||
|
||
```bash
|
||
git add src/previewAnnotations.ts media/preview-annotations.css src/extension.ts package.json test/
|
||
git commit -m "feat: authorship + change annotations in the built-in Markdown preview (D3/D21, PUC-3); cowriting.annotations toggle"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: Sunset the review-panel webview; surface cleanup
|
||
|
||
**Files:**
|
||
- Delete: `src/trackChangesPreview.ts`, its webview client assets (`git grep -l "preview.js\|acquireVsCodeApi" media/ src/` to enumerate), `test/e2e/suite/f10Review.test.ts` (webview-bound asserts; fold its INV coverage into the surviving suites first — see Step 2)
|
||
- Modify: `src/extension.ts`, `package.json`, `src/editorProposalController.ts` (lens copy), `README.md`
|
||
|
||
**Interfaces:** none new; removals — `TrackChangesPreviewController` leaves `CowritingApi`.
|
||
|
||
- [ ] **Step 1: Re-point the entry surfaces.** `cowriting.showTrackChangesPreview` (command, ⌘⌥R keybinding, title/context/explorer menus) is replaced by: keybinding ⌘⌥R → `cowriting.reviewChanges`; the "Open Review Panel" context entries (#41) → `markdown.showPreviewToSide` preceded by `cowriting.coeditDocument` if not entered (one small wrapper command `cowriting.openReviewPreview`, title "Open Cowriting Review Preview"). Remove the `cowriting.editDocument` palette entry (the ask is comments-first now); keep the command registered for the E2E harness.
|
||
- [ ] **Step 2: Preserve INV coverage before deleting tests.** `f10Review.test.ts` covers accept/reject routing + INV-32/33 rendering: the routing asserts move to `commentLoop.test.ts`/`f12Accept.test.ts` (buffer surface), the render asserts to `test/previewAnnotations.test.ts` (pure). Do this move in the same commit as the deletion so coverage never drops.
|
||
- [ ] **Step 3: Delete** `src/trackChangesPreview.ts` + client assets; `extension.ts` drops its construction and the `askEditInstruction` stub; `EditFlow` (Task 5) already owns everything live. `git grep -n "trackChangesPreview\|TrackChangesPreviewController"` must return nothing.
|
||
- [ ] **Step 4: Lens copy (PUC-2 wording).** In `editorProposalController.ts` `provideCodeLenses`: per-proposal titles become `"✓ Keep"` / `"✗ Reject"` (commands unchanged: `finalizeInPlace`/`revertInPlace` via the menu handlers); add a top-of-file pair when ≥2 applied proposals: `"✓ Keep all (N)"` → `cowriting.acceptAllProposals`, `"✗ Reject all"` → `cowriting.rejectAllProposals`. Update `f12*` E2E title asserts.
|
||
- [ ] **Step 5: README (DOC-2).** Rewrite the "how it works" section as the native-surface map (spec §5 table): enter → title bar/status bar → comments-first ask → pending in buffer → native diff → built-in preview. One screenshot-free walkthrough.
|
||
- [ ] **Step 6: Verify + commit.**
|
||
|
||
Run: `npm run typecheck && npm run test && npm run pretest:e2e && npm run test:e2e`
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "feat!: sunset the review-panel webview — built-in preview + native diff are the review surfaces (D3/D17, spec §6.10); Keep/Reject lens copy (PUC-2)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: Full-loop E2E + live smoke (rung 3 folded in)
|
||
|
||
**Files:**
|
||
- Create: `test/e2e/suite/fullLoop.test.ts`, `scripts/smoke-native-loop.mjs`
|
||
- Modify: `package.json` (script `smoke:native`)
|
||
|
||
- [ ] **Step 1: The named full loop (§6.8), stub-turned:** one E2E that walks PUC-7 → PUC-8 → PUC-2 → PUC-1 in a single doc: enter (snapshot) → thread ask (stub turn) → offer → pending proposals decorated (assert `listProposals` + `isApplied`) → tweak by typing inside a pending range (assert re-anchor: proposal still resolvable) → Keep one (assert attribution split via `spansFor`: claude words + human tweak) → Reject one (assert buffer text equals pre-proposal text for that range **exactly** — INV-5) → `cowriting.markReviewed` → change count 0.
|
||
- [ ] **Step 2: Live smoke (rung 3 — the real SDK, manual gate):** `scripts/smoke-native-loop.mjs` mirrors `scripts/smoke-live-turn.mjs` but through the comment loop: launches the EDH, enters a sandbox doc, posts a real comment, waits for the real `@cline/sdk` reply + offer, accepts, prints the created proposal ids. `npm run smoke:native`. This is operator-run (real tokens); the plan's definition of done for rung 3 is one green run reported in the session transcript.
|
||
- [ ] **Step 3: Verify + commit.**
|
||
|
||
```bash
|
||
git add test/e2e/suite/fullLoop.test.ts scripts/smoke-native-loop.mjs package.json
|
||
git commit -m "test: full native-loop E2E (PUC-1/2/7/8) + real-SDK smoke (spec §6.8, §7.1 rung 3)"
|
||
```
|
||
|
||
---
|
||
|
||
## Execution order & green-ness invariant
|
||
|
||
Tasks run 1→9 strictly; every task leaves `typecheck + unit + host E2E` green. The two sunsets (Tasks 6, 8) each land only after their native replacement's tests pass in the same task (spec §6.10). No §9 web pipeline applies (VS Code extension — spec §7.2); "done" = acceptance (§1.9) + suites green + the Task 9 live smoke.
|
||
|
||
## Deferred (recorded, not silently dropped)
|
||
|
||
- **D15 — preview-initiated comments** (select in the rendered preview → native thread on the source, 💬 deep-link marker): the spike validated only the editor-selection and whole-document halves of E4; the preview-selection→source-map half was explicitly **not** spiked (spec §7.1 v0.2.1 note), and the built-in preview's script sandbox makes its feasibility a real question. It ships in a follow-up increment behind its own feasibility check; this plan's ask surfaces are the title-bar/editor-selection/whole-document paths. Reviewer may veto and pull it in.
|
||
- **PUC-4/6 thread re-anchor/displaced coverage** rides the existing F2 suites unchanged (the anchoring cores don't move in this migration).
|
||
|
||
## Spec-coverage self-check (§ → task)
|
||
|
||
- INV-7/D13/D14 baseline router → Tasks 2–3 · INV-10/PUC-7 gate → Tasks 1, 4 · INV-13 discovery → Task 3 · D19/D10/D8/PUC-8 comment loop → Task 6 · D3/D21/PUC-3 preview → Task 7 · D18/D20/PUC-2 buffer review → shipped (F12) + copy/gating in Tasks 4, 8 · INV-5/INV-12 → existing proposal seam, asserted in Task 9 · INV-8 onBehalfOf → Task 6 reply provenance · PUC-5 progress → shipped (#60), reused in Task 6 · §6.10 sunsets → Tasks 6, 8 · §6.8 named E2E → Tasks 2, 4, 6, 7, 9 · Q4 mermaid check → Task 7 Step 2.6 · DOC-2 README → Task 8.
|